From c6574925712a25a1612e7e690c8817a1ac6c67be Mon Sep 17 00:00:00 2001 From: Mike Hathaway Date: Thu, 3 Aug 2023 18:21:27 -0400 Subject: [PATCH] Merge Develop (#121) * expand unit test code coverage * Add invariants DP7, SS10, SS11, ES4, ES5 and DR4 (#105) * Add invariants DP7, SS10, SS11, ES4, ES5 and DR4 * Fix stack too deep issue * PR feedback * Update invariants (#106) * rename invariant tests * refactor invariant test structure * additional refactoring * refactor multiple distribution invariants * fix stack too deep error * fix screening invariants * fix DR4 bug * remove redundant DP6 check * pr feedback --------- Co-authored-by: Mike * Check all invariants across multiple distribution periods (#107) * add support for multiple distribution periods to funding invariants * fix FS7 for multiple dist * begin updating screening invariants * remaining fixes to funding stage invariants * get screening stage invariants working in multiple distribution scenario * pr feedback --------- Co-authored-by: Mike * Add grant fund interaction with gnosis safe unit test (#109) * Invariant improvements (#108) * add support for multiple distribution periods to funding invariants * fix FS7 for multiple dist * begin updating screening invariants * remaining fixes to funding stage invariants * get screening stage invariants working in multiple distribution scenario * begin fixing todos * fix negative funding votes in happy path; update proposal token requested; update DR5 invariant check * improve ES2 invariant check * add Logger contract; cleanup logging; add useCurrentBlock modifier to TestBase * add support for env variables in invariant tests * expand usage of useCurrentBlock * cleanup invariants list doc * add P1 invariant * cleanups * cleanup multiple distribution scenario logging; add logActorDelegationRewards * fix findUnclaimedRewards; expand logging * improve DR5 check across multiple periods * cleanups * initial pr feedback * Invariant Improvements: Fix Actor log and improve `screeningVote` handler (#110) * Fix actors logs for Multiple Distribution Invariant when no distribution started * Improve screeningVote by using _screeningVoteParams to generate parameter * Add configuration for logging in invariants (#111) * update README * update docs * paramterize percentageTokensReq * fix compilation warnings * Add invariant CS7: The highest submitted funded proposal slate should have won or tied depending on when it was submitted. (#113) --------- Co-authored-by: Mike Co-authored-by: Prateek Gupta * update makefile (#115) * various invariant doc cleanups (#117) * Add fuzz test for screening and funding stage (#116) * expand CS7 invariant check (#118) * expand CS7 invariant check * fix nit --------- Co-authored-by: Mike * adjusted README * Fix make file spacing * deploy BurnWrapper --------- Co-authored-by: Mike Co-authored-by: Prateek Gupta Co-authored-by: Ed Noepel Co-authored-by: Ed Noepel <46749157+EdNoepel@users.noreply.github.com> Co-authored-by: prateek105 --- .env.example | 6 +- Makefile | 14 +- README.md | 30 +- foundry.toml | 3 +- script/BurnWrapper.s.sol | 20 ++ test/README.md | 51 ++++ .../GrantFundWithGnosisSafe.t.sol | 191 +++++++++++++ test/interactions/interfaces.sol | 53 ++++ test/invariants/INVARIANTS.md | 31 +-- .../StandardFinalizeInvariant.t.sol | 259 ------------------ .../invariants/StandardFundingInvariant.t.sol | 152 ---------- ...tandardMultipleDistributionInvariant.t.sol | 221 --------------- .../StandardScreeningInvariant.t.sol | 150 ---------- .../base/DistributionPeriodInvariants.sol | 155 +++++++++++ test/invariants/base/FinalizeInvariants.sol | 242 ++++++++++++++++ test/invariants/base/FundingInvariants.sol | 124 +++++++++ test/invariants/base/Logger.sol | 246 +++++++++++++++++ test/invariants/base/ScreeningInvariants.sol | 157 +++++++++++ test/invariants/base/StandardTestBase.sol | 27 +- test/invariants/base/TestBase.sol | 26 +- test/invariants/handlers/Handler.sol | 6 +- test/invariants/handlers/StandardHandler.sol | 241 ++++++---------- .../scenarios/FinalizeInvariant.t.sol | 78 ++++++ .../scenarios/FundingInvariant.t.sol | 64 +++++ .../MultipleDistributionInvariant.t.sol | 82 ++++++ .../scenarios/ScreeningInvariant.t.sol | 46 ++++ test/invariants/test-invariant.sh | 16 ++ test/unit/StandardFunding.t.sol | 199 ++++++++++++++ test/utils/GrantFundTestHelper.sol | 3 +- 29 files changed, 1899 insertions(+), 994 deletions(-) create mode 100644 script/BurnWrapper.s.sol create mode 100644 test/README.md create mode 100644 test/interactions/GrantFundWithGnosisSafe.t.sol create mode 100644 test/interactions/interfaces.sol delete mode 100644 test/invariants/StandardFinalizeInvariant.t.sol delete mode 100644 test/invariants/StandardFundingInvariant.t.sol delete mode 100644 test/invariants/StandardMultipleDistributionInvariant.t.sol delete mode 100644 test/invariants/StandardScreeningInvariant.t.sol create mode 100644 test/invariants/base/DistributionPeriodInvariants.sol create mode 100644 test/invariants/base/FinalizeInvariants.sol create mode 100644 test/invariants/base/FundingInvariants.sol create mode 100644 test/invariants/base/Logger.sol create mode 100644 test/invariants/base/ScreeningInvariants.sol create mode 100644 test/invariants/scenarios/FinalizeInvariant.t.sol create mode 100644 test/invariants/scenarios/FundingInvariant.t.sol create mode 100644 test/invariants/scenarios/MultipleDistributionInvariant.t.sol create mode 100644 test/invariants/scenarios/ScreeningInvariant.t.sol create mode 100755 test/invariants/test-invariant.sh diff --git a/.env.example b/.env.example index 68cfd3c7..82a3be48 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ ## Ethereum node endpoint ## ETH_RPC_URL= -FOUNDRY_EVM_VERSION=paris \ No newline at end of file +FOUNDRY_EVM_VERSION=paris +SCENARIO=MultipleDistribution # Type of Invariant Scenario to run: Screening | FundingInvariant | Finalize | MultipleDistribution +NUM_ACTORS=10 # Number of actors to simulate in invariants +NUM_PROPOSALS=10 # Maximum number of proposals to simulate in invariants +PER_ADDRESS_TOKEN_REQ_CAP=10 # Percentage of funds available to request per proposal recipient in invariants diff --git a/Makefile b/Makefile index 85dddc1b..7465d9ec 100644 --- a/Makefile +++ b/Makefile @@ -15,10 +15,12 @@ install :; git submodule update --init --recursive build :; forge clean && forge build --optimize --optimizer-runs 1000000 # Tests -tests :; forge clean && forge test --mt test --optimize --optimizer-runs 1000000 -v # --ffi # enable if you need the `ffi` cheat code on HEVM -test-with-gas-report :; forge clean && forge build && forge test --mt test --optimize --optimizer-runs 1000000 -v --gas-report # --ffi # enable if you need the `ffi` cheat code on HEVM -test-invariant :; forge clean && forge t --mt invariant -coverage :; forge coverage +tests :; forge clean && forge test --mt test --optimize --optimizer-runs 1000000 -v # --ffi # enable if you need the `ffi` cheat code on HEVM +test-with-gas-report :; forge clean && forge build && forge test --mt test --optimize --optimizer-runs 1000000 -v --gas-report # --ffi # enable if you need the `ffi` cheat code on HEVM +test-invariant :; ./test/invariants/test-invariant.sh ${SCENARIO} ${NUM_ACTORS} ${NUM_PROPOSALS} ${PER_ADDRESS_TOKEN_REQ_CAP} +test-invariant-all :; forge clean && forge t --mt invariant +test-invariant-multiple-distribution :; forge clean && ./test/invariants/test-invariant.sh MultipleDistribution 2 25 200 +coverage :; forge coverage # Generate Gas Snapshots snapshot :; forge clean && forge snapshot --optimize --optimize-runs 1000000 @@ -32,3 +34,7 @@ deploy-grantfund: eval AJNA_TOKEN=${ajna} forge script script/GrantFund.s.sol:DeployGrantFund \ --rpc-url ${ETH_RPC_URL} --sender ${DEPLOY_ADDRESS} --keystore ${DEPLOY_KEY} --broadcast -vvv --verify +deploy-burnwrapper: + eval AJNA_TOKEN=${ajna} + forge script script/BurnWrapper.s.sol:DeployBurnWrapper \ + --rpc-url ${ETH_RPC_URL} --sender ${DEPLOY_ADDRESS} --keystore ${DEPLOY_KEY} --broadcast -vvv --verify \ No newline at end of file diff --git a/README.md b/README.md index 2322d12c..d5232f37 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@
+## **[Tests](./test/README.md)** + +For information on running tests and checking code coverage see the **[Tests README](./test/README.md)**. + + ## **Development** Install Foundry [instructions](https://github.com/gakonst/foundry/blob/master/README.md#installation) then, install the [foundry](https://github.com/gakonst/foundry) toolchain installer (`foundryup`) with: @@ -31,23 +36,6 @@ foundryup make all ``` -#### Run Tests - -```bash -make tests -``` - -#### Code Coverage -- generate basic code coverage report: -```bash -make coverage -``` -- exclude tests from code coverage report: -``` -apt-get install lcov -bash ./check-code-coverage.sh -``` - ### Contract Deployment Ensure the following env variables are in your `.env` file or exported into your environment. | Environment Variable | Purpose | @@ -80,11 +68,15 @@ make deploy-ajnatoken mintto= ``` Record the address of the token upon deployment. See [AJNA_TOKEN.md](src/token/AJNA_TOKEN.md#deployment) for validation. +#### Burn Wrapper +To deploy, ensure the correct AJNA token address is specified in code. Then, run: +``` +make deploy-burnwrapper ajna= +``` + #### Grant Fund Deployment of the Grant Coordination Fund requires an argument to specify the address of the AJNA token. The deployment script also uses the token address to determine funding level. -Before deploying, edit `src/grants/base/Storage.sol` to set the correct AJNA token address for the target chain. - To deploy, run: ``` make deploy-grantfund ajna= diff --git a/foundry.toml b/foundry.toml index 4cfb918f..3de0b88b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,11 +10,12 @@ runs = 150 [invariant] runs = 3 # The number of calls to make in the invariant tests -depth = 10000 # The number of times to run the invariant tests +depth = 10000 # The number of times to run the invariant tests call_override = false # Override calls fail_on_revert = true # Fail the test if the contract reverts dictionary_weight = 80 include_storage = true include_push_bytes = true +shrink_sequence = false # See more config options https://github.com/foundry-rs/foundry/tree/master/config diff --git a/script/BurnWrapper.s.sol b/script/BurnWrapper.s.sol new file mode 100644 index 00000000..d0441de0 --- /dev/null +++ b/script/BurnWrapper.s.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.7; + +import { Script } from "forge-std/Script.sol"; +import { console } from "forge-std/console.sol"; + +import { BurnWrappedAjna } from "../src/token/BurnWrapper.sol"; +import { IERC20 } from "@oz/token/ERC20/IERC20.sol"; + +contract DeployBurnWrapper is Script { + function run() public { + IERC20 ajna = IERC20(vm.envAddress("AJNA_TOKEN")); + + vm.startBroadcast(); + address wrapperAddress = address(new BurnWrappedAjna(ajna)); + vm.stopBroadcast(); + + console.log("Created BurnWrapper at %s for AJNA token at %s", wrapperAddress, address(ajna)); + } +} diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..64cd25a2 --- /dev/null +++ b/test/README.md @@ -0,0 +1,51 @@ +# Ecosystem Coordination Tests +## Forge tests +### Unit tests +- validation tests: +```bash +make tests +``` +- validation tests with gas report: +```bash +make test-with-gas-report +``` + +### Invariant tests +#### Configuration +Invariant test scenarios can be externally configured by customizing following environment variables: +| Variable | Default | Description | +| ------------- | ------------- | ------------- | +| SCENARIO | MultipleDistribution | Type of Invariant Scenario to run: Screening, FundingInvariant, Finalize, MultipleDistribution | +| PER_ADDRESS_TOKEN_REQ_CAP | 10 | Percentage of funds available to request per proposal recipient in invariants | +| NUM_ACTORS | 20 | Max number of actors to participate in invariant testing | +| NUM_PROPOSALS | 200 | Max number of proposals that can be proposed in invariant testing | +| LOGS_VERBOSITY | 0 |

Details to log

0 = No Logs

1 = Calls details, Proposal details, Time details

2 = Calls details, Proposal details, Time details, Funding proposal details, Finalize proposals details

3 = Calls details, Proposal details, Time details, Funding proposal details, Finalize proposals details, Actor details + +#### Custom Scenarios + +Custom scenario configurations are defined in [scenarios](forge/invariants/scenarios/) directory. +For running a custom scenario +```bash +make test-invariant SCENARIO= +``` +For example, to test all invariants for multiple distribution (Roll between each handler call can be max 5000 blocks): +```bash +make test-invariant SCENARIO=MultipleDistribution +``` + +#### Commands +- run all invariant tests: +```bash +make test-invariant-all +``` + +### Code Coverage +- generate basic code coverage report: +```bash +make coverage +``` +- exclude tests from code coverage report: +``` +apt-get install lcov +bash ../check-code-coverage.sh +``` diff --git a/test/interactions/GrantFundWithGnosisSafe.t.sol b/test/interactions/GrantFundWithGnosisSafe.t.sol new file mode 100644 index 00000000..ebcf271a --- /dev/null +++ b/test/interactions/GrantFundWithGnosisSafe.t.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { Strings } from "@oz/utils/Strings.sol"; + +import { GrantFund } from "../../src/grants/GrantFund.sol"; +import { IGrantFundState } from "../../src/grants/interfaces/IGrantFundState.sol"; + +import "./interfaces.sol"; +import { GrantFundTestHelper } from "../utils/GrantFundTestHelper.sol"; +import { IAjnaToken } from "../utils/IAjnaToken.sol"; + +contract GrantFundWithGnosisSafe is GrantFundTestHelper { + + IGnosisSafeFactory internal _gnosisSafeFactory; + IGnosisSafe internal _gnosisSafe; + + IAjnaToken internal _token; + GrantFund internal _grantFund; + + // Ajna token Holder at the Ajna contract creation on mainnet + address internal _tokenDeployer = 0x666cf594fB18622e1ddB91468309a7E194ccb799; + + struct MultiSigOwner { + address walletAddress; + uint256 privateKey; + } + + struct Proposals { + address[] targets; + uint256[] values; + bytes[] calldatas; + string description; + bytes32 descriptionHash; + uint256 proposalId; + } + + address[] internal _votersArr; + + uint256 _treasury = 500_000_000 * 1e18; + + uint256 _nonces = 0; + + function setUp() external { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + address gnosisSafeFactoryAddress = 0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2; // mainnet gnosisSafeFactory contract address + _gnosisSafeFactory = IGnosisSafeFactory(gnosisSafeFactoryAddress); + + address singletonAddress = 0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552; // mainnet singleton contract address + + // deploy gnosis safe + address gnosisSafeAddress = _gnosisSafeFactory.createProxy(singletonAddress, ""); + + _gnosisSafe = IGnosisSafe(gnosisSafeAddress); + + (_grantFund, _token) = _deployAndFundGrantFund(_tokenDeployer, _treasury, _votersArr, 0); + + // transfer tokens to gnosis safe + changePrank(_tokenDeployer); + _token.transfer(gnosisSafeAddress, 25_000_000 * 1e18); + } + + function testGrantFundWithMultiSigWallet() external { + MultiSigOwner[] memory multiSigOwners = new MultiSigOwner[](3); + + (multiSigOwners[0].walletAddress, multiSigOwners[0].privateKey) = makeAddrAndKey("_multiSigOwner1"); + (multiSigOwners[1].walletAddress, multiSigOwners[1].privateKey) = makeAddrAndKey("_multiSigOwner2"); + (multiSigOwners[2].walletAddress, multiSigOwners[2].privateKey) = makeAddrAndKey("_multiSigOwner3"); + + address[] memory owners = new address[](3); + owners[0] = multiSigOwners[0].walletAddress; + owners[1] = multiSigOwners[1].walletAddress; + owners[2] = multiSigOwners[2].walletAddress; + + // Setup gnosis safe with 3 owners and 2 threshold to execute transaction + _gnosisSafe.setup(owners, 2, address(0), "", address(0), address(0), 0, payable(address(0))); + + // self delegate votes + bytes memory callData = abi.encodeWithSignature("delegate(address)", address(_gnosisSafe)); + _executeTransaction(address(_token), callData, multiSigOwners); + + vm.roll(block.number + 100); + + // Start distribution period + _startDistributionPeriod(_grantFund); + + uint24 distributionId = _grantFund.getDistributionId(); + + // generate proposals for distribution + Proposals[] memory proposals = _generateProposals(2); + + // propose first proposal + callData = abi.encodeWithSignature("propose(address[],uint256[],bytes[],string)", proposals[0].targets, proposals[0].values, proposals[0].calldatas, proposals[0].description); + _executeTransaction(address(_grantFund), callData, multiSigOwners); + + // propose second proposal + callData = abi.encodeWithSignature("propose(address[],uint256[],bytes[],string)", proposals[1].targets, proposals[1].values, proposals[1].calldatas, proposals[1].description); + _executeTransaction(address(_grantFund), callData, multiSigOwners); + + // skip to screening stage + vm.roll(block.number + 100); + + // construct vote params + IGrantFundState.ScreeningVoteParams[] memory screeningVoteParams = new IGrantFundState.ScreeningVoteParams[](1); + screeningVoteParams[0].proposalId = proposals[0].proposalId; + screeningVoteParams[0].votes = 20_000_000 * 1e18; + + // cast screening vote + callData = abi.encodeWithSignature("screeningVote((uint256,uint256)[])", screeningVoteParams); + _executeTransaction(address(_grantFund), callData, multiSigOwners); + + // skip to funding stage + vm.roll(block.number + 550_000); + + // construct vote params + IGrantFundState.FundingVoteParams[] memory fundingVoteParams = new IGrantFundState.FundingVoteParams[](1); + fundingVoteParams[0].proposalId = proposals[0].proposalId; + fundingVoteParams[0].votesUsed = 20_000_000 * 1e18; + + // cast funding vote + callData = abi.encodeWithSignature("fundingVote((uint256,int256)[])", fundingVoteParams); + _executeTransaction(address(_grantFund), callData, multiSigOwners); + + // skip to the Challenge period + vm.roll(block.number + 50_000); + + // construct potential proposal slate + uint256[] memory potentialProposalSlate = new uint256[](1); + potentialProposalSlate[0] = proposals[0].proposalId; + + // update slate + callData = abi.encodeWithSignature("updateSlate(uint256[],uint24)", potentialProposalSlate, distributionId); + _executeTransaction(address(_grantFund), callData, multiSigOwners); + + // skip to the end of distribution period + vm.roll(block.number + 100_000); + + // execute proposal + callData = abi.encodeWithSignature("execute(address[],uint256[],bytes[],bytes32)", proposals[0].targets, proposals[0].values, proposals[0].calldatas, proposals[0].descriptionHash); + _executeTransaction(address(_grantFund), callData, multiSigOwners); + + // claim delegate reward + callData = abi.encodeWithSignature("claimDelegateReward(uint24)", distributionId); + _executeTransaction(address(_grantFund), callData, multiSigOwners); + } + + function _executeTransaction(address contractAddress, bytes memory callData, MultiSigOwner[] memory multiSigOwners) internal { + bytes32 transactionHash = _gnosisSafe.getTransactionHash(contractAddress, 0, callData, IGnosisSafe.Operation.Call, 0, 0, 0, address(0), address(0), _nonces++); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(multiSigOwners[0].privateKey, transactionHash); + bytes memory signature1 = abi.encodePacked(r, s, v); + + (v, r, s) = vm.sign(multiSigOwners[1].privateKey, transactionHash); + bytes memory signature2 = abi.encodePacked(r, s, v); + + bytes memory signatures = abi.encodePacked(signature1, signature2); + _gnosisSafe.execTransaction(contractAddress, 0, callData, IGnosisSafe.Operation.Call, 0, 0, 0, address(0), payable(address(0)), signatures); + + } + + function _generateProposals(uint256 noOfProposals_) internal view returns(Proposals[] memory) { + Proposals[] memory proposals_ = new Proposals[](noOfProposals_); + + // generate proposal targets + address[] memory ajnaTokenTargets = new address[](1); + ajnaTokenTargets[0] = address(_token); + + // generate proposal values + uint256[] memory values = new uint256[](1); + values[0] = 0; + + // generate proposal calldata + bytes[] memory proposalCalldata = new bytes[](1); + proposalCalldata[0] = abi.encodeWithSignature( + "transfer(address,uint256)", + address(_gnosisSafe), + 1_000_000 * 1e18 + ); + + for(uint i = 0; i < noOfProposals_; i++) { + // generate proposal message + string memory description = string(abi.encodePacked("Proposal", Strings.toString(i))); + bytes32 descriptionHash = _grantFund.getDescriptionHash(description); + uint256 proposalId = _grantFund.hashProposal(ajnaTokenTargets, values, proposalCalldata, descriptionHash); + proposals_[i] = Proposals(ajnaTokenTargets, values, proposalCalldata, description, descriptionHash, proposalId); + } + return proposals_; + } + +} \ No newline at end of file diff --git a/test/interactions/interfaces.sol b/test/interactions/interfaces.sol new file mode 100644 index 00000000..57715669 --- /dev/null +++ b/test/interactions/interfaces.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +interface IGnosisSafeFactory { + function createProxy( + address _singleton, + bytes memory data + ) external returns(address gnosisSafe_); +} + +interface IGnosisSafe { + enum Operation { + Call, + DelegateCall + } + function setup( + address[]calldata _owners, + uint256 _threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes memory signatures + ) external payable returns (bool success); + + function getTransactionHash ( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes32); +} \ No newline at end of file diff --git a/test/invariants/INVARIANTS.md b/test/invariants/INVARIANTS.md index 037a5e42..85eb2ad3 100644 --- a/test/invariants/INVARIANTS.md +++ b/test/invariants/INVARIANTS.md @@ -1,11 +1,5 @@ # GrantFund Invariants -## Treasury Invariants: - - **T1**: The Grant Fund's `treasury` should always be less than or equal to the contract's token balance. - - **T2**: The Grant Fund's `treasury` should always be less than or equal to the Ajna token total supply. - -## Standard Funding Mechanism Invariants - - #### Distribution Period: - **DP1**: Only one distribution period should be active at a time. Each successive distribution period's start block should be greater than the previous periods end block. - **DP2**: Each winning proposal successfully claims no more that what was finalized in the challenge stage @@ -22,12 +16,11 @@ - **SS4**: Screening vote's cast can only be positive. - **SS5**: Screening votes can only be cast on a proposal in it's distribution period's screening stage. - **SS6**: For every proposal, it is included in the top 10 list if, and only if, it has as many or more votes as the last member of the top ten list (typically the 10th of course, but it may be shorter than ten proposals). - - - **SS7**: A proposal should never receive more vote than the Ajna token supply. - - **SS8**: A proposal can only receive screening votes if it was created via `propose()`. - - **SS9**: A proposal can only be created during a distribution period's screening stage. - - **SS10**: A proposal's proposalId must be unique. - - **SS11**: A proposal's tokens requested must be <= GBC>. + - **SS7**: Screening votes on a proposal should cause addition to the topTenProposals if no proposal has been added yet + - **SS8**: A proposal should never receive more screening votes than the Ajna token supply. + - **SS9**: A proposal can only receive screening votes if it was created via `propose()`. + - **SS10**: A proposal can only be created during a distribution period's screening stage. + - **SS11**: A proposal's tokens requested must be <= 90% of GBC. - #### Funding Stage: - **FS1**: Only 10 proposals can be voted on in the funding stage @@ -36,7 +29,7 @@ - **FS4**: Sum of square of votes cast by a given actor are less than or equal to the actor's Ajna delegated balance, squared. - **FS5**: Sum of voter's votesCast should be equal to the square root of the voting power expended (FS4 restated, but added to test intermediate state as well as final). - **FS6**: All voter funding votes on a proposal should be cast in the same direction. Multiple votes on the same proposal should see the voting power increase according to the combined cost of votes. - - **FS7** List of top ten proposals should never change once the funding stage has started. + - **FS7**: List of top ten proposals should never change once the funding stage has started. - **FS8**: a voter should never be able to cast more votes than the Ajna token supply. - #### Challenge Stage: @@ -46,13 +39,14 @@ - **CS4**: Funded proposals are all a subset of the ones voted on in funding stage. - **CS5**: Funded proposal slate's should never contain duplicate proposals. - **CS6**: Funded proposal slate's can only be updated during a distribution period's challenge stage. + - **CS7**: The highest submitted funded proposal slate should have won or tied depending on when it was submitted. - #### Execute: - **ES1**: A proposal can only be executed if it's listed in the final funded proposal slate at the end of the challenge round. - **ES2**: A proposal can only be executed after the challenge stage is complete. - **ES3**: A proposal can only be executed once. - **ES4**: A proposal can only be executed if it was in the top ten screened proposals at the end of the screening stage. - - **ES5**: An executed proposal should only ever transfer tokens <= GBC. + - **ES5**: An executed proposal should only ever transfer tokens <= 90% of GBC. - #### Delegation Rewards: - **DR1**: Cumulative delegation rewards should be <= 10% of a distribution periods GBC. @@ -61,7 +55,10 @@ - **DR4**: Delegation rewards can only be claimed for a distribution period after it ended. - **DR5**: Cumulative rewards claimed should be within 99.99% of all available delegation rewards. +- #### Proposal: + - **P1**: A proposal should never enter an unused state (pending, canceled, queued, expired). + - **P2**: A proposal's proposalId must be unique. - -## Global Invariants: - - **G1**: A proposal should never enter an unused state (canceled, queued, expired). +- #### Treasury: + - **T1**: The Grant Fund's `treasury` should always be less than or equal to the contract's token balance. + - **T2**: The Grant Fund's `treasury` should always be less than or equal to the Ajna token total supply. diff --git a/test/invariants/StandardFinalizeInvariant.t.sol b/test/invariants/StandardFinalizeInvariant.t.sol deleted file mode 100644 index b0a5b815..00000000 --- a/test/invariants/StandardFinalizeInvariant.t.sol +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.18; - -import { console } from "@std/console.sol"; -import { Math } from "@oz/utils/math/Math.sol"; -import { SafeCast } from "@oz/utils/math/SafeCast.sol"; - -import { IGrantFund } from "../../src/grants/interfaces/IGrantFund.sol"; -import { Maths } from "../../src/grants/libraries/Maths.sol"; - -import { StandardTestBase } from "./base/StandardTestBase.sol"; -import { StandardHandler } from "./handlers/StandardHandler.sol"; -import { Handler } from "./handlers/Handler.sol"; - -contract StandardFinalizeInvariant is StandardTestBase { - - // override setup to start tests in the challenge stage with proposals that have already been screened and funded - function setUp() public override { - super.setUp(); - - startDistributionPeriod(); - - // create 15 proposals - _standardHandler.createProposals(15); - - // cast screening votes on proposals - _standardHandler.screeningVoteProposals(); - - // skip time into the funding stage - uint24 distributionId = _grantFund.getDistributionId(); - (, uint256 startBlock, uint256 endBlock, , , ) = _grantFund.getDistributionPeriodInfo(distributionId); - uint256 fundingStageStartBlock = _grantFund.getScreeningStageEndBlock(startBlock) + 1; - vm.roll(fundingStageStartBlock + 100); - currentBlock = fundingStageStartBlock + 100; - - // cast funding votes on proposals - _standardHandler.fundingVoteProposals(); - - _standardHandler.setCurrentScenarioType(Handler.ScenarioType.Medium); - - // skip time into the challenge stage - uint256 challengeStageStartBlock = _grantFund.getChallengeStageStartBlock(endBlock); - vm.roll(challengeStageStartBlock + 100); - currentBlock = challengeStageStartBlock + 100; - - // set the list of function selectors to run - bytes4[] memory selectors = new bytes4[](5); - selectors[0] = _standardHandler.fundingVote.selector; - selectors[1] = _standardHandler.updateSlate.selector; - selectors[2] = _standardHandler.execute.selector; - selectors[3] = _standardHandler.claimDelegateReward.selector; - selectors[4] = _standardHandler.roll.selector; - - // ensure utility functions are excluded from the invariant runs - targetSelector(FuzzSelector({ - addr: address(_standardHandler), - selectors: selectors - })); - } - - function invariant_CS1_CS2_CS3_CS4_CS5_CS6() view external { - uint24 distributionId = _grantFund.getDistributionId(); - - (, , uint256 endBlock, uint128 fundsAvailable, , bytes32 topSlateHash) = _grantFund.getDistributionPeriodInfo(distributionId); - - uint256[] memory topSlateProposalIds = _grantFund.getFundedProposalSlate(topSlateHash); - - uint256[] memory topTenScreenedProposalIds = _grantFund.getTopTenProposals(distributionId); - - require( - topSlateProposalIds.length <= 10, - "invariant CS2: top slate should have 10 or less proposals" - ); - - // check proposal state of the constituents of the top slate - uint256 totalTokensRequested = 0; - for (uint256 i = 0; i < topSlateProposalIds.length; ++i) { - uint256 proposalId = topSlateProposalIds[i]; - (, , , uint128 tokensRequested, int128 fundingVotesReceived, ) = _grantFund.getProposalInfo(proposalId); - totalTokensRequested += tokensRequested; - - require( - fundingVotesReceived >= 0, - "invariant CS3: Proposal slate should never contain a proposal with negative funding votes received" - ); - - require( - _findProposalIndex(proposalId, topTenScreenedProposalIds) != -1, - "invariant CS4: Proposal slate should never contain a proposal that wasn't in the top ten in the funding stage." - ); - } - - require( - totalTokensRequested <= uint256(fundsAvailable) * 9 / 10, - "invariant CS1: total tokens requested should be <= 90% of fundsAvailable" - ); - - require( - !_standardHandler.hasDuplicates(topSlateProposalIds), - "invariant CS5: proposal slate should never contain duplicate proposals" - ); - - // check DistributionState for top slate updates - StandardHandler.DistributionState memory state = _standardHandler.getDistributionState(distributionId); - for (uint i = 0; i < state.topSlates.length; ++i) { - StandardHandler.Slate memory slate = state.topSlates[i]; - - require( - slate.updateBlock <= endBlock && slate.updateBlock >= _grantFund.getChallengeStageStartBlock(endBlock), - "invariant CS6: Funded proposal slate's can only be updated during a distribution period's challenge stage" - ); - } - } - - function invariant_ES1_ES2_ES3() external { - uint24 distributionId = _grantFund.getDistributionId(); - (, , , , , bytes32 topSlateHash) = _grantFund.getDistributionPeriodInfo(distributionId); - - uint256[] memory topSlateProposalIds = _grantFund.getFundedProposalSlate(topSlateHash); - - // calculate the total tokens requested by the proposals in the top slate - uint256 totalTokensRequested = 0; - for (uint256 i = 0; i < topSlateProposalIds.length; ++i) { - uint256 proposalId = topSlateProposalIds[i]; - (, , , uint128 tokensRequested, , ) = _grantFund.getProposalInfo(proposalId); - totalTokensRequested += tokensRequested; - } - - uint256[] memory standardFundingProposals = _standardHandler.getStandardFundingProposals(distributionId); - - // check the state of every proposal submitted in this distribution period - for (uint256 i = 0; i < standardFundingProposals.length; ++i) { - uint256 proposalId = standardFundingProposals[i]; - (, uint24 proposalDistributionId, , , , bool executed) = _grantFund.getProposalInfo(proposalId); - int256 proposalIndex = _findProposalIndex(proposalId, topSlateProposalIds); - // invariant ES1: A proposal can only be executed if it's listed in the final funded proposal slate at the end of the challenge round. - if (proposalIndex == -1) { - assertFalse(executed); - } - - // invariant ES2: A proposal can only be executed if it's listed in the final funded proposal slate at the end of the challenge round. - assertEq(distributionId, proposalDistributionId); - if (executed) { - (, , uint48 endBlock, , , ) = _grantFund.getDistributionPeriodInfo(proposalDistributionId); - // TODO: store and check proposal execution time - require( - currentBlock > endBlock, - "invariant ES2: A proposal can only be executed after the challenge stage is complete." - ); - } - } - - require( - !_standardHandler.hasDuplicates(_standardHandler.getProposalsExecuted()), - "invariant ES3: A proposal can only be executed once." - ); - } - - function invariant_DR1_DR2_DR3_DR5() external { - uint24 distributionId = _grantFund.getDistributionId(); - (, , , uint128 fundsAvailable, uint256 fundingVotePowerCast, ) = _grantFund.getDistributionPeriodInfo(distributionId); - - uint256 totalRewardsClaimed; - - for (uint256 i = 0; i < _standardHandler.getActorsCount(); ++i) { - address actor = _standardHandler.actors(i); - - // get the initial funding stage voting power of the actor - (uint128 votingPower, uint128 remainingVotingPower, ) = _grantFund.getVoterInfo(distributionId, actor); - - // get actor info from standard handler - ( - IGrantFund.FundingVoteParams[] memory fundingVoteParams, - IGrantFund.ScreeningVoteParams[] memory screeningVoteParams, - uint256 delegationRewardsClaimed - ) = _standardHandler.getVotingActorsInfo(actor, distributionId); - - totalRewardsClaimed += delegationRewardsClaimed; - - if (delegationRewardsClaimed != 0) { - // check that delegation rewards are greater tahn 0 if they did vote in both stages - assertTrue(delegationRewardsClaimed >= 0); - - uint256 votingPowerAllocatedByDelegatee = votingPower - remainingVotingPower; - uint256 rootVotingPowerAllocatedByDelegatee = Math.sqrt(votingPowerAllocatedByDelegatee * 1e18); - - require( - fundingVoteParams.length > 0 && screeningVoteParams.length > 0, - "invariant DR2: Delegation rewards are 0 if voter didn't vote in both stages." - ); - - uint256 rewards; - if (votingPowerAllocatedByDelegatee > 0) { - rewards = Math.mulDiv( - fundsAvailable, - rootVotingPowerAllocatedByDelegatee, - 10 * fundingVotePowerCast - ); - } - - require( - delegationRewardsClaimed == rewards, - "invariant DR3: Delegation rewards are proportional to voters funding power allocated in the funding stage." - ); - } - } - - require( - totalRewardsClaimed <= fundsAvailable * 1 / 10, - "invariant DR1: Cumulative delegation rewards should be <= 10% of a distribution periods GBC" - ); - - // check state after all possible delegation rewards have been claimed - if (_standardHandler.numberOfCalls('SFH.claimDelegateReward.success') == _standardHandler.getActorsCount()) { - require( - totalRewardsClaimed >= Maths.wmul(fundsAvailable * 1 / 10, 0.9999 * 1e18), - "invariant DR5: Cumulative rewards claimed should be within 99.99% -or- 0.01 AJNA tokens of all available delegation rewards" - ); - assertEq(totalRewardsClaimed, fundsAvailable * 1 / 10); - } - } - - function invariant_call_summary() external view { - uint24 distributionId = _grantFund.getDistributionId(); - - _standardHandler.logCallSummary(); - _standardHandler.logActorSummary(distributionId, false, false); - _standardHandler.logProposalSummary(); - _standardHandler.logTimeSummary(); - _logFinalizeSummary(distributionId); - } - - function _logFinalizeSummary(uint24 distributionId_) internal view { - (, , , uint128 fundsAvailable, , bytes32 topSlateHash) = _grantFund.getDistributionPeriodInfo(distributionId_); - uint256[] memory topSlateProposalIds = _grantFund.getFundedProposalSlate(topSlateHash); - - uint256[] memory topTenScreenedProposalIds = _grantFund.getTopTenProposals(distributionId_); - - console.log("\nFinalize Summary\n"); - console.log("------------------"); - console.log("Distribution Id: ", distributionId_); - console.log("Delegation Rewards Claimed: ", _standardHandler.numberOfCalls('SFH.claimDelegateReward.success')); - console.log("Proposal Execute attempt: ", _standardHandler.numberOfCalls('SFH.execute.attempt')); - console.log("Proposal Execute Count: ", _standardHandler.numberOfCalls('SFH.execute.success')); - console.log("Slate Created: ", _standardHandler.numberOfCalls('SFH.updateSlate.prep')); - console.log("Slate Update Called: ", _standardHandler.numberOfCalls('SFH.updateSlate.called')); - console.log("Slate Update Count: ", _standardHandler.numberOfCalls('SFH.updateSlate.success')); - console.log("Next Slate length: ", _standardHandler.numberOfCalls('updateSlate.length')); - console.log("Top Slate Proposal Count: ", topSlateProposalIds.length); - console.log("Top Ten Proposal Count: ", topTenScreenedProposalIds.length); - console.log("Funds Available: ", fundsAvailable); - console.log("Top slate funds requested: ", _standardHandler.getTokensRequestedInFundedSlateInvariant(topSlateHash)); - (, , , , uint256 fundingPowerCast, ) = _grantFund.getDistributionPeriodInfo(distributionId_); - console.log("Total Funding Power Cast ", fundingPowerCast); - console.log("------------------"); - } - -} diff --git a/test/invariants/StandardFundingInvariant.t.sol b/test/invariants/StandardFundingInvariant.t.sol deleted file mode 100644 index bb6cb7f9..00000000 --- a/test/invariants/StandardFundingInvariant.t.sol +++ /dev/null @@ -1,152 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.18; - -import { console } from "@std/console.sol"; -import { SafeCast } from "@oz/utils/math/SafeCast.sol"; - -import { IGrantFund } from "../../src/grants/interfaces/IGrantFund.sol"; - -import { StandardTestBase } from "./base/StandardTestBase.sol"; -import { StandardHandler } from "./handlers/StandardHandler.sol"; - -contract StandardFundingInvariant is StandardTestBase { - - // hash the top ten proposals at the start of the funding stage to check composition - bytes32 initialTopTenHash; - - // override setup to start tests in the funding stage with already screened proposals - function setUp() public override { - super.setUp(); - - startDistributionPeriod(); - - // create 15 proposals - _standardHandler.createProposals(15); - - // cast screening votes on proposals - _standardHandler.screeningVoteProposals(); - - // skip time into the funding stage - uint24 distributionId = _grantFund.getDistributionId(); - (, uint256 startBlock, , , , ) = _grantFund.getDistributionPeriodInfo(distributionId); - uint256 fundingStageStartBlock = _grantFund.getScreeningStageEndBlock(startBlock) + 1; - vm.roll(fundingStageStartBlock + 100); - currentBlock = fundingStageStartBlock + 100; - - // set the list of function selectors to run - bytes4[] memory selectors = new bytes4[](2); - selectors[0] = _standardHandler.fundingVote.selector; - selectors[1] = _standardHandler.updateSlate.selector; - - // ensure utility functions are excluded from the invariant runs - targetSelector(FuzzSelector({ - addr: address(_standardHandler), - selectors: selectors - })); - - uint256[] memory initialTopTenProposals = _grantFund.getTopTenProposals(_grantFund.getDistributionId()); - initialTopTenHash = keccak256(abi.encode(initialTopTenProposals)); - } - - function invariant_FS1_FS2_FS3() external { - uint256[] memory topTenProposals = _grantFund.getTopTenProposals(_grantFund.getDistributionId()); - - // invariant FS1: 10 or less proposals should make it through the screening stage - assertTrue(topTenProposals.length <= 10); - assertTrue(topTenProposals.length > 0); // check if something went wrong in setup - - uint24 distributionId = _grantFund.getDistributionId(); - uint256[] memory standardFundingProposals = _standardHandler.getStandardFundingProposals(distributionId); - - // check invariants against every proposal - for (uint256 j = 0; j < standardFundingProposals.length; ++j) { - uint256 proposalId = _standardHandler.standardFundingProposals(distributionId, j); - (, uint24 proposalDistributionId, , , int128 fundingVotesReceived, ) = _grantFund.getProposalInfo(proposalId); - - // invariant FS2: proposals not in the top ten should not be able to recieve funding votes - if (_findProposalIndex(proposalId, topTenProposals) == -1) { - assertEq(fundingVotesReceived, 0); - } - - require( - distributionId == proposalDistributionId, - "invariant FS3: distribution id for a proposal should be the same as the current distribution id" - ); - } - } - - function invariant_FS4_FS5_FS6_FS7_FS8() external { - uint24 distributionId = _grantFund.getDistributionId(); - - // check invariants against every actor - for (uint256 i = 0; i < _standardHandler.getActorsCount(); ++i) { - address actor = _standardHandler.actors(i); - - // get the initial funding stage voting power of the actor - (uint128 votingPower, uint128 remainingVotingPower, uint256 numberOfProposalsVotedOn) = _grantFund.getVoterInfo(distributionId, actor); - - // get the voting info of the actor - (IGrantFund.FundingVoteParams[] memory fundingVoteParams, , ) = _standardHandler.getVotingActorsInfo(actor, distributionId); - - uint128 sumOfSquares = SafeCast.toUint128(_standardHandler.sumSquareOfVotesCast(fundingVoteParams)); - - // check voter votes cast are less than or equal to the sqrt of the voting power of the actor - IGrantFund.FundingVoteParams[] memory fundingVotesCast = _grantFund.getFundingVotesCast(distributionId, actor); - - require( - sumOfSquares <= votingPower, - "invariant FS4: sum of square of votes cast <= voting power of actor" - ); - // invariant FS5: Sum of voter's votesCast should be equal to the square root of the voting power expended (FS4 restated, but added to test intermediate state as well as final). - assertEq(sumOfSquares, votingPower - remainingVotingPower); - - // check that the test functioned as expected - if (votingPower != 0 && remainingVotingPower == 0) { - assertTrue(numberOfProposalsVotedOn == fundingVotesCast.length); - assertTrue(numberOfProposalsVotedOn > 0); - } - - require( - uint256(_standardHandler.sumFundingVotes(fundingVoteParams)) <= _ajna.totalSupply(), - "invariant FS8: a voter should never be able to cast more votes than the Ajna token supply" - ); - - // check that there weren't any duplicate proposal entries, as votes for same proposal should be combined - uint256[] memory proposalIdsVotedOn = new uint256[](fundingVotesCast.length); - for (uint j = 0; j < fundingVotesCast.length; ) { - proposalIdsVotedOn[j] = fundingVotesCast[j].proposalId; - ++j; - } - require( - _standardHandler.hasDuplicates(proposalIdsVotedOn) == false, - "invariant FS6: All voter funding votes on a proposal should be cast in the same direction. Multiple votes on the same proposal should see the voting power increase according to the combined cost of votes." - ); - } - - require( - keccak256(abi.encode(_grantFund.getTopTenProposals(distributionId))) == initialTopTenHash, - "invariant FS7: List of top ten proposals should never change once the funding stage has started" - ); - } - - function invariant_call_summary() external view { - uint24 distributionId = _grantFund.getDistributionId(); - - _standardHandler.logCallSummary(); - // _standardHandler.logProposalSummary(); - // _standardHandler.logActorSummary(distributionId, true, false); - _logFundingSummary(distributionId); - } - - function _logFundingSummary(uint24 distributionId_) internal view { - console.log("\nFunding Summary\n"); - console.log("------------------"); - console.log("number of funding stage starts: ", _standardHandler.numberOfCalls("SFH.FundingStage")); - console.log("number of funding stage success votes: ", _standardHandler.numberOfCalls("SFH.fundingVote.success")); - console.log("distributionId: ", distributionId_); - console.log("SFH.updateSlate.success: ", _standardHandler.numberOfCalls("SFH.updateSlate.success")); - console.log("------------------"); - } - -} diff --git a/test/invariants/StandardMultipleDistributionInvariant.t.sol b/test/invariants/StandardMultipleDistributionInvariant.t.sol deleted file mode 100644 index 81c79bbb..00000000 --- a/test/invariants/StandardMultipleDistributionInvariant.t.sol +++ /dev/null @@ -1,221 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.18; - -import { console } from "@std/console.sol"; -import { SafeCast } from "@oz/utils/math/SafeCast.sol"; - -import { Maths } from "../../src/grants/libraries/Maths.sol"; - -import { StandardTestBase } from "./base/StandardTestBase.sol"; -import { StandardHandler } from "./handlers/StandardHandler.sol"; -import { Handler } from "./handlers/Handler.sol"; - -contract StandardMultipleDistributionInvariant is StandardTestBase { - - // run tests against all functions, having just started a distribution period - function setUp() public override { - super.setUp(); - - // set the list of function selectors to run - bytes4[] memory selectors = new bytes4[](8); - selectors[0] = _standardHandler.startNewDistributionPeriod.selector; - selectors[1] = _standardHandler.propose.selector; - selectors[2] = _standardHandler.screeningVote.selector; - selectors[3] = _standardHandler.fundingVote.selector; - selectors[4] = _standardHandler.updateSlate.selector; - selectors[5] = _standardHandler.execute.selector; - selectors[6] = _standardHandler.claimDelegateReward.selector; - selectors[7] = _standardHandler.roll.selector; - - // ensure utility functions are excluded from the invariant runs - targetSelector(FuzzSelector({ - addr: address(_standardHandler), - selectors: selectors - })); - - // update scenarioType to fast to have larger rolls - _standardHandler.setCurrentScenarioType(Handler.ScenarioType.Fast); - - vm.roll(block.number + 100); - currentBlock = block.number; - } - - function invariant_DP1_DP2_DP3_DP4_DP5() external { - uint24 distributionId = _grantFund.getDistributionId(); - console.log("distributionId??", distributionId); - ( - , - uint256 startBlockCurrent, - uint256 endBlockCurrent, - , - , - ) = _grantFund.getDistributionPeriodInfo(distributionId); - - uint256 totalFundsAvailable = 0; - - uint24 i = distributionId; - while (i > 0) { - ( - , - uint256 startBlockPrev, - uint256 endBlockPrev, - uint128 fundsAvailablePrev, - , - ) = _grantFund.getDistributionPeriodInfo(i); - StandardHandler.DistributionState memory state = _standardHandler.getDistributionState(i); - uint256 currentTreasury = state.treasuryBeforeStart; - - totalFundsAvailable += fundsAvailablePrev; - require( - totalFundsAvailable < currentTreasury, - "invariant DP5: The treasury balance should be greater than the sum of the funds available in all distribution periods" - ); - - require( - fundsAvailablePrev == Maths.wmul(.03 * 1e18, state.treasuryAtStartBlock + fundsAvailablePrev), - "invariant DP3: A distribution's fundsAvailablePrev should be equal to 3% of the treasurie's balance at the block `startNewDistributionPeriod()` is called" - ); - - require( - endBlockPrev > startBlockPrev, - "invariant DP4: A distribution's endBlock should be greater than its startBlock" - ); - - uint256 totalTokensRequestedByProposals = 0; - - // check the top funded proposal slate - uint256[] memory proposalSlate = _grantFund.getFundedProposalSlate(state.currentTopSlate); - for (uint j = 0; j < proposalSlate.length; ++j) { - ( - , - uint24 proposalDistributionId, - , - uint128 tokensRequested, - , - bool executed - ) = _grantFund.getProposalInfo(proposalSlate[j]); - assertEq(proposalDistributionId, i); - - if (executed) { - // invariant DP2: Each winning proposal successfully claims no more that what was finalized in the challenge stage - assertLt(tokensRequested, fundsAvailablePrev); - } - totalTokensRequestedByProposals += tokensRequested; - } - assertTrue(totalTokensRequestedByProposals <= fundsAvailablePrev); - - // check invariants against each previous distribution periods - if (i != distributionId) { - // check each distribution period's end block and ensure that only 1 has an endblock not in the past. - require( - endBlockPrev < startBlockCurrent && endBlockPrev < currentBlock, - "invariant DP1: Only one distribution period should be active at a time" - ); - - // decrement blocks to ensure that the next distribution period's end block is less than the current block - startBlockCurrent = startBlockPrev; - endBlockCurrent = endBlockPrev; - } - - --i; - } - } - - function invariant_DP6() external { - uint24 distributionId = _grantFund.getDistributionId(); - - for (uint24 i = 0; i <= distributionId; ) { - if (i == 0) { - ++i; - continue; - } - - ( - , - , - , - uint128 fundsAvailable, - , - ) = _grantFund.getDistributionPeriodInfo(i); - StandardHandler.DistributionState memory state = _standardHandler.getDistributionState(i); - - // check prior distributions for surplus to return to treasury - uint24 prevDistributionId = i - 1; - ( - , - , - , - uint128 fundsAvailablePrev, - , - bytes32 topSlateHashPrev - ) = _grantFund.getDistributionPeriodInfo(prevDistributionId); - - // calculate the expected treasury amount at the start of the current distribution period - uint256 expectedTreasury = state.treasuryBeforeStart; - uint256 surplus = _standardHandler.updateTreasury(prevDistributionId, fundsAvailablePrev, topSlateHashPrev); - expectedTreasury += surplus; - - if (i == 1) { - require( - fundsAvailable == Maths.wmul(.03 * 1e18, state.treasuryBeforeStart), - "invariant DP6: Surplus funds from distribution periods whose token's requested in the final funded slate was less than the total funds available are readded to the treasury" - ); - } - else { - require( - fundsAvailable == Maths.wmul(.03 * 1e18, expectedTreasury), - "invariant DP6: Surplus funds from distribution periods whose token's requested in the final funded slate was less than the total funds available are readded to the treasury" - ); - } - - ++i; - } - } - - function invariant_T1_T2() external view { - require( - _grantFund.treasury() <= _ajna.balanceOf(address(_grantFund)), - "invariant T1: The Grant Fund's treasury should always be less than or equal to the contract's token blance" - ); - - require( - _grantFund.treasury() <= _ajna.totalSupply(), - "invariant T2: The Grant Fund's treasury should always be less than or equal to the Ajna token total supply" - ); - } - - function invariant_call_summary() external view { - // uint24 distributionId = _grantFund.getDistributionId(); - - _standardHandler.logCallSummary(); - _standardHandler.logTimeSummary(); - - console.log("scenario type", uint8(_standardHandler.getCurrentScenarioType())); - - console.log("Delegation Rewards: ", _standardHandler.numberOfCalls('delegationRewardSet')); - console.log("Delegation Rewards Claimed: ", _standardHandler.numberOfCalls('SFH.claimDelegateReward.success')); - console.log("Proposal Execute attempt: ", _standardHandler.numberOfCalls('SFH.execute.attempt')); - console.log("Proposal Execute Count: ", _standardHandler.numberOfCalls('SFH.execute.success')); - console.log("Slate Update Prep: ", _standardHandler.numberOfCalls('SFH.updateSlate.prep')); - console.log("Slate Update length: ", _standardHandler.numberOfCalls('updateSlate.length')); - console.log("Slate Update Called: ", _standardHandler.numberOfCalls('SFH.updateSlate.called')); - console.log("Slate Update Success: ", _standardHandler.numberOfCalls('SFH.updateSlate.success')); - console.log("Slate Proposals: ", _standardHandler.numberOfCalls('proposalsInSlates')); - console.log("unused proposal: ", _standardHandler.numberOfCalls('unused.proposal')); - console.log("unexecuted proposal: ", _standardHandler.numberOfCalls('unexecuted.proposal')); - console.log("funding stage starts: ", _standardHandler.numberOfCalls("SFH.FundingStage")); - console.log("funding stage success votes ", _standardHandler.numberOfCalls("SFH.fundingVote.success")); - - - (, , , , uint256 fundingPowerCast, ) = _grantFund.getDistributionPeriodInfo(2); - console.log("Total Funding Power Cast ", fundingPowerCast); - - - if (_standardHandler.numberOfCalls('unexecuted.proposal') != 0) { - console.log("state of unexecuted: ", uint8(_grantFund.state(_standardHandler.numberOfCalls('unexecuted.proposal')))); - } - // _standardHandler.logProposalSummary(); - // _standardHandler.logActorSummary(distributionId, true, true); - } -} diff --git a/test/invariants/StandardScreeningInvariant.t.sol b/test/invariants/StandardScreeningInvariant.t.sol deleted file mode 100644 index 50f8c10e..00000000 --- a/test/invariants/StandardScreeningInvariant.t.sol +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.18; - -import { console } from "@std/console.sol"; - -import { IGrantFund } from "../../src/grants/interfaces/IGrantFund.sol"; - -import { StandardTestBase } from "./base/StandardTestBase.sol"; -import { StandardHandler } from "./handlers/StandardHandler.sol"; - -contract StandardScreeningInvariant is StandardTestBase { - - function setUp() public override { - super.setUp(); - - startDistributionPeriod(); - - // set the list of function selectors to run - bytes4[] memory selectors = new bytes4[](3); - selectors[0] = _standardHandler.startNewDistributionPeriod.selector; - selectors[1] = _standardHandler.propose.selector; - selectors[2] = _standardHandler.screeningVote.selector; - - // ensure utility functions are excluded from the invariant runs - targetSelector(FuzzSelector({ - addr: address(_standardHandler), - selectors: selectors - })); - - } - - function invariant_SS1_SS3_SS4_SS5_SS6_SS7_SS9() external { - uint24 distributionId = _grantFund.getDistributionId(); - uint256 standardFundingProposalsSubmitted = _standardHandler.getStandardFundingProposals(distributionId).length; - uint256[] memory topTenProposals = _grantFund.getTopTenProposals(distributionId); - (, uint256 startBlock, , , , ) = _grantFund.getDistributionPeriodInfo(distributionId); - - require( - topTenProposals.length <= 10 && standardFundingProposalsSubmitted >= topTenProposals.length, - "invariant SS1: 10 or less proposals should make it through the screening stage" - ); - - // check the state of the top ten proposals - if (topTenProposals.length > 1) { - for (uint256 i = 0; i < topTenProposals.length - 1; ++i) { - // check the current proposals votes received against the next proposal in the top ten list - (, uint24 distributionIdCurr, uint256 votesReceivedCurr, , , ) = _grantFund.getProposalInfo(topTenProposals[i]); - (, uint24 distributionIdNext, uint256 votesReceivedNext, , , ) = _grantFund.getProposalInfo(topTenProposals[i + 1]); - require( - votesReceivedCurr >= votesReceivedNext, - "invariant SS3: proposals should be sorted in descending order" - ); - - require( - votesReceivedCurr >= 0 && votesReceivedNext >= 0, - "invariant SS4: Screening votes recieved for a proposal can only be positive" - ); - - // TODO: improve this check - require( - distributionIdCurr == distributionIdNext && distributionIdCurr == distributionId, - "invariant SS5: distribution id for a proposal should be the same as the current distribution id" - ); - } - } - - // find the number of screening votes received by the last proposal in the top ten list - uint256 votesReceivedLast; - if (topTenProposals.length != 0) { - (, , votesReceivedLast, , , ) = _grantFund.getProposalInfo(topTenProposals[topTenProposals.length - 1]); - assertGe(votesReceivedLast, 0); - } - - // check invariants against all submitted proposals - for (uint256 j = 0; j < standardFundingProposalsSubmitted; ++j) { - (uint256 proposalId, , uint256 votesReceived, , , ) = _grantFund.getProposalInfo(_standardHandler.standardFundingProposals(distributionId, j)); - require( - votesReceived >= 0, - "invariant SS4: Screening votes recieved for a proposal can only be positive" - ); - - require( - votesReceived <= _ajna.totalSupply(), - "invariant SS7: a proposal should never receive more screening votes than the token supply" - ); - - // check each submitted proposals votes against the last proposal in the top ten list - if (_findProposalIndex(proposalId, topTenProposals) == -1) { - if (votesReceivedLast != 0) { - require( - votesReceived <= votesReceivedLast, - "invariant SS6: proposals should be incorporated into the top ten list if, and only if, they have as many or more votes as the last member of the top ten list." - ); - } - } - - // TODO: account for multiple distribution periods? - TestProposal memory testProposal = _standardHandler.getTestProposal(proposalId); - require( - testProposal.blockAtCreation <= _grantFund.getScreeningStageEndBlock(startBlock), - "invariant SS9: A proposal can only be created during a distribution period's screening stage" - ); - } - - // invariant SS6: proposals should be incorporated into the top ten list if, and only if, they have as many or more votes as the last member of the top ten list. - if (_standardHandler.screeningVotesCast() > 0) { - assertTrue(topTenProposals.length > 0); - } - } - - function invariant_SS2_SS4_SS8() external view { - uint256 actorCount = _standardHandler.getActorsCount(); - uint24 distributionId = _grantFund.getDistributionId(); - - // check invariants for all actors - for (uint256 i = 0; i < actorCount; ++i) { - address actor = _standardHandler.actors(i); - uint256 votingPower = _grantFund.getVotesScreening(distributionId, actor); - - require( - _standardHandler.sumVoterScreeningVotes(actor, distributionId) <= votingPower, - "invariant SS2: can only vote up to the amount of voting power at the snapshot blocks" - ); - - // check the screening votes cast by the actor - ( , IGrantFund.ScreeningVoteParams[] memory screeningVoteParams, ) = _standardHandler.getVotingActorsInfo(actor, distributionId); - for (uint256 j = 0; j < screeningVoteParams.length; ++j) { - require( - screeningVoteParams[j].votes >= 0, - "invariant SS4: can only cast positive votes" - ); - - require( - _findProposalIndex(screeningVoteParams[j].proposalId, _standardHandler.getStandardFundingProposals(distributionId)) != -1, - "invariant SS8: a proposal can only receive screening votes if it was created via propose()" - ); - } - } - } - - function invariant_call_summary() external view { - uint24 distributionId = _grantFund.getDistributionId(); - - _standardHandler.logCallSummary(); - // _standardHandler.logProposalSummary(); - _standardHandler.logActorSummary(distributionId, false, true); - } - -} diff --git a/test/invariants/base/DistributionPeriodInvariants.sol b/test/invariants/base/DistributionPeriodInvariants.sol new file mode 100644 index 00000000..a481ba5e --- /dev/null +++ b/test/invariants/base/DistributionPeriodInvariants.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; +import { Math } from "@oz/utils/math/Math.sol"; +import { SafeCast } from "@oz/utils/math/SafeCast.sol"; + +import { GrantFund } from "../../../src/grants/GrantFund.sol"; +import { IGrantFund } from "../../../src/grants/interfaces/IGrantFund.sol"; +import { Maths } from "../../../src/grants/libraries/Maths.sol"; + +import { TestBase } from "./TestBase.sol"; +import { StandardHandler } from "../handlers/StandardHandler.sol"; + +abstract contract DistributionPeriodInvariants is TestBase { + + function _invariant_DP1_DP2_DP3_DP4_DP5(GrantFund grantFund_, StandardHandler standardHandler_) internal { + uint24 distributionId = grantFund_.getDistributionId(); + ( + , + uint256 startBlockCurrent, + uint256 endBlockCurrent, + , + , + ) = grantFund_.getDistributionPeriodInfo(distributionId); + + uint256 totalFundsAvailable = 0; + + uint24 i = distributionId; + while (i > 0) { + ( + , + uint256 startBlockPrev, + uint256 endBlockPrev, + uint128 fundsAvailablePrev, + , + ) = grantFund_.getDistributionPeriodInfo(i); + StandardHandler.DistributionState memory state = standardHandler_.getDistributionState(i); + + totalFundsAvailable += fundsAvailablePrev; + require( + totalFundsAvailable < state.treasuryBeforeStart, + "invariant DP5: The treasury balance should be greater than the sum of the funds available in all distribution periods" + ); + + require( + fundsAvailablePrev == Maths.wmul(.03 * 1e18, state.treasuryAtStartBlock + fundsAvailablePrev), + "invariant DP3: A distribution's fundsAvailablePrev should be equal to 3% of the treasury's balance at the block `startNewDistributionPeriod()` is called" + ); + + require( + endBlockPrev > startBlockPrev, + "invariant DP4: A distribution's endBlock should be greater than its startBlock" + ); + + // check invariant DP5 + // seperate function avoids stack too deep error + _invariant_DP5(_grantFund, state, i, fundsAvailablePrev); + + // check invariants against each previous distribution periods + if (i != distributionId) { + // check each distribution period's end block and ensure that only 1 has an endblock not in the past. + require( + endBlockPrev < startBlockCurrent && endBlockPrev < currentBlock, + "invariant DP1: Only one distribution period should be active at a time" + ); + + // decrement blocks to ensure that the next distribution period's end block is less than the current block + startBlockCurrent = startBlockPrev; + endBlockCurrent = endBlockPrev; + } + + --i; + } + } + + function _invariant_DP5(GrantFund grantFund_, StandardHandler.DistributionState memory state, uint256 distributionId_, uint256 fundsAvailablePrev_) internal { + uint256 totalTokensRequestedByProposals = 0; + + // check the top funded proposal slate + uint256[] memory proposalSlate = grantFund_.getFundedProposalSlate(state.currentTopSlate); + for (uint j = 0; j < proposalSlate.length; ++j) { + ( + , + uint24 proposalDistributionId, + , + uint128 tokensRequested, + , + bool executed + ) = grantFund_.getProposalInfo(proposalSlate[j]); + assertEq(proposalDistributionId, distributionId_); + + if (executed) { + require( + tokensRequested < fundsAvailablePrev_, + "invariant DP2: Each winning proposal successfully claims no more that what was finalized in the challenge stage" + ); + } + totalTokensRequestedByProposals += tokensRequested; + } + assertTrue(totalTokensRequestedByProposals <= fundsAvailablePrev_); + } + + function _invariant_DP6(GrantFund grantFund_, StandardHandler standardHandler_) internal { + uint24 distributionId = grantFund_.getDistributionId(); + + for (uint24 i = 1; i <= distributionId; ) { + ( + , + , + , + uint128 fundsAvailable, + , + ) = grantFund_.getDistributionPeriodInfo(i); + StandardHandler.DistributionState memory state = standardHandler_.getDistributionState(i); + + // check prior distributions for surplus to return to treasury + uint24 prevDistributionId = i - 1; + ( + , + , + , + uint128 fundsAvailablePrev, + , + bytes32 topSlateHashPrev + ) = grantFund_.getDistributionPeriodInfo(prevDistributionId); + + // calculate the expected treasury amount at the start of the current distribution period + uint256 expectedTreasury = state.treasuryBeforeStart; + uint256 surplus = standardHandler_.updateTreasury(prevDistributionId, fundsAvailablePrev, topSlateHashPrev); + expectedTreasury += surplus; + + require( + fundsAvailable == Maths.wmul(.03 * 1e18, expectedTreasury), + "invariant DP6: Surplus funds from distribution periods whose token's requested in the final funded slate was less than the total funds available are readded to the treasury" + ); + + ++i; + } + } + + function _invariant_T1_T2(GrantFund grantFund_) internal view { + require( + grantFund_.treasury() <= _ajna.balanceOf(address(grantFund_)), + "invariant T1: The Grant Fund's treasury should always be less than or equal to the contract's token blance" + ); + + require( + grantFund_.treasury() <= _ajna.totalSupply(), + "invariant T2: The Grant Fund's treasury should always be less than or equal to the Ajna token total supply" + ); + } + +} diff --git a/test/invariants/base/FinalizeInvariants.sol b/test/invariants/base/FinalizeInvariants.sol new file mode 100644 index 00000000..05da2894 --- /dev/null +++ b/test/invariants/base/FinalizeInvariants.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; +import { Math } from "@oz/utils/math/Math.sol"; +import { SafeCast } from "@oz/utils/math/SafeCast.sol"; + +import { GrantFund } from "../../../src/grants/GrantFund.sol"; +import { IGrantFund } from "../../../src/grants/interfaces/IGrantFund.sol"; +import { Maths } from "../../../src/grants/libraries/Maths.sol"; + +import { TestBase } from "./TestBase.sol"; +import { StandardHandler } from "../handlers/StandardHandler.sol"; + +abstract contract FinalizeInvariants is TestBase { + + struct LocalVotersInfo { + uint128 fundingVotingPower; + uint128 fundingRemainingVotingPower; + uint256 votesCast; + } + + struct DistributionInfo { + uint24 id; + uint48 startBlock; + uint48 endBlock; + uint128 fundsAvailable; + uint256 fundingVotePowerCast; + bytes32 fundedSlateHash; + } + + function _invariant_CS1_CS2_CS3_CS4_CS5_CS6_CS7(GrantFund grantFund_, StandardHandler standardHandler_) view internal { + uint24 distributionId = grantFund_.getDistributionId(); + + (, , uint256 endBlock, uint128 fundsAvailable, , bytes32 topSlateHash) = grantFund_.getDistributionPeriodInfo(distributionId); + + uint256[] memory topSlateProposalIds = grantFund_.getFundedProposalSlate(topSlateHash); + uint256[] memory topTenScreenedProposalIds = grantFund_.getTopTenProposals(distributionId); + + require( + topSlateProposalIds.length <= 10, + "invariant CS2: top slate should have 10 or less proposals" + ); + + // check proposal state of the constituents of the top slate + uint256 totalTokensRequested = 0; + int256 topSlateTotalVotesReceived = 0; + for (uint256 i = 0; i < topSlateProposalIds.length; ++i) { + uint256 proposalId = topSlateProposalIds[i]; + (, , , uint128 tokensRequested, int128 fundingVotesReceived, ) = grantFund_.getProposalInfo(proposalId); + totalTokensRequested += tokensRequested; + topSlateTotalVotesReceived += fundingVotesReceived; + + require( + fundingVotesReceived >= 0, + "invariant CS3: Proposal slate should never contain a proposal with negative funding votes received" + ); + + require( + _findProposalIndex(proposalId, topTenScreenedProposalIds) != -1, + "invariant CS4: Proposal slate should never contain a proposal that wasn't in the top ten in the funding stage." + ); + } + + require( + totalTokensRequested <= uint256(fundsAvailable) * 9 / 10, + "invariant CS1: total tokens requested should be <= 90% of fundsAvailable" + ); + + require( + !standardHandler_.hasDuplicates(topSlateProposalIds), + "invariant CS5: proposal slate should never contain duplicate proposals" + ); + + // check DistributionState for top slate updates + StandardHandler.DistributionState memory state = standardHandler_.getDistributionState(distributionId); + for (uint i = 0; i < state.topSlates.length; ++i) { + StandardHandler.Slate memory slate = state.topSlates[i]; + + require( + slate.updateBlock <= endBlock && slate.updateBlock >= grantFund_.getChallengeStageStartBlock(endBlock), + "invariant CS6: Funded proposal slate's can only be updated during a distribution period's challenge stage" + ); + + if (slate.slateHash != topSlateHash) { + // ensure slates that aren't the top slates have total votes less than the top slate's votes + int256 sumSlateFundingVotes = standardHandler_.sumSlateFundingVotes(slate.slateHash); + + require( + sumSlateFundingVotes <= topSlateTotalVotesReceived, + "invariant CS7: The highest submitted funded proposal slate should have won or tied depending on when it was submitted." + ); + } + } + + require( + state.currentTopSlate == topSlateHash, + "invariant CS7: The highest submitted funded proposal slate should have won or tied depending on when it was submitted." + ); + } + + function _invariant_ES1_ES2_ES3_ES4_ES5(GrantFund grantFund_, StandardHandler standardHandler_) internal { + uint24 distributionId = grantFund_.getDistributionId(); + while (distributionId > 0) { + (, , , uint256 gbc, , bytes32 topSlateHash) = grantFund_.getDistributionPeriodInfo(distributionId); + uint256[] memory topSlateProposalIds = grantFund_.getFundedProposalSlate(topSlateHash); + uint256[] memory standardFundingProposals = standardHandler_.getStandardFundingProposals(distributionId); + uint256[] memory topTenScreenedProposalIds = grantFund_.getTopTenProposals(distributionId); + + // check the state of every proposal submitted in this distribution period + for (uint256 i = 0; i < standardFundingProposals.length; ++i) { + uint256 proposalId = standardFundingProposals[i]; + (, uint24 proposalDistributionId, , uint256 tokenRequested, , bool executed) = grantFund_.getProposalInfo(proposalId); + int256 proposalIndex = _findProposalIndex(proposalId, topSlateProposalIds); + if (proposalIndex == -1) { + require( + !executed, + "invariant ES1: A proposal can only be executed if it's listed in the final funded proposal slate at the end of the challenge round." + ); + } + + // invariant ES2: A proposal can only be executed after the challenge stage is complete. + assertEq(distributionId, proposalDistributionId); + if (executed) { + (, , uint48 endBlock, , , ) = grantFund_.getDistributionPeriodInfo(proposalDistributionId); + TestProposal memory testProposal = standardHandler_.getTestProposal(proposalId); + require( + testProposal.blockAtExecution > endBlock, + "invariant ES2: A proposal can only be executed after the challenge stage is complete." + ); + + // check if proposalId exist in topTenScreenedProposals if it is executed + require( + _findProposalIndex(proposalId, topTenScreenedProposalIds) != -1, + "invariant ES4: A proposal can only be executed if it was in the top ten screened proposals at the end of the screening stage." + ); + + require( + tokenRequested <= gbc * 9 / 10, + "invariant ES5: An executed proposal should only ever transfer tokens <= 90% of GBC" + ); + + } + } + + require( + !standardHandler_.hasDuplicates(standardHandler_.getProposalsExecuted()), + "invariant ES3: A proposal can only be executed once." + ); + + --distributionId; + } + } + + function _invariant_DR1_DR2_DR3_DR4_DR5(GrantFund grantFund_, StandardHandler standardHandler_) internal { + uint24 distributionId = grantFund_.getDistributionId(); + DistributionInfo memory distributionInfo; + while (distributionId > 0) { + (, , distributionInfo.endBlock, distributionInfo.fundsAvailable, distributionInfo.fundingVotePowerCast, ) = grantFund_.getDistributionPeriodInfo(distributionId); + + uint256 totalRewardsClaimed; + uint256 actorsWithRewards; + + for (uint256 i = 0; i < standardHandler_.getActorsCount(); ++i) { + address actor = standardHandler_.actors(i); + + // get the initial funding stage voting power of the actor + LocalVotersInfo memory votersInfo; + (votersInfo.fundingVotingPower, votersInfo.fundingRemainingVotingPower, ) = grantFund_.getVoterInfo(distributionId, actor); + + // get actor info from standard handler + ( + IGrantFund.FundingVoteParams[] memory fundingVoteParams, + IGrantFund.ScreeningVoteParams[] memory screeningVoteParams, + uint256 delegationRewardsClaimed + ) = standardHandler_.getVotingActorsInfo(actor, distributionId); + + totalRewardsClaimed += delegationRewardsClaimed; + + if (delegationRewardsClaimed != 0) { + // check that delegation rewards are greater tahn 0 if they did vote in both stages + assertTrue(delegationRewardsClaimed >= 0); + + actorsWithRewards += 1; + + uint256 votingPowerAllocatedByDelegatee = votersInfo.fundingVotingPower - votersInfo.fundingRemainingVotingPower; + uint256 rootVotingPowerAllocatedByDelegatee = Math.sqrt(votingPowerAllocatedByDelegatee * 1e18); + + require( + fundingVoteParams.length > 0 && screeningVoteParams.length > 0, + "invariant DR2: Delegation rewards are 0 if voter didn't vote in both stages." + ); + + uint256 rewards; + if (votingPowerAllocatedByDelegatee > 0) { + rewards = Math.mulDiv( + distributionInfo.fundsAvailable, + rootVotingPowerAllocatedByDelegatee, + 10 * distributionInfo.fundingVotePowerCast + ); + } + + require( + delegationRewardsClaimed == rewards, + "invariant DR3: Delegation rewards are proportional to voters funding power allocated in the funding stage." + ); + + if (distributionInfo.endBlock >= currentBlock) { + require( + grantFund_.getHasClaimedRewards(distributionId, actor) == false, + "invariant DR4: Delegation rewards can only be claimed for a distribution period after it ended" + ); + } + } + } + + require( + totalRewardsClaimed <= distributionInfo.fundsAvailable * 1 / 10, + "invariant DR1: Cumulative delegation rewards should be <= 10% of a distribution periods GBC" + ); + + // check state after all possible delegation rewards have been claimed + StandardHandler.DistributionState memory state = standardHandler_.getDistributionState(distributionId); + if (state.numVoterRewardsClaimed == standardHandler_.getNumVotersWithRewards(distributionId) && distributionInfo.endBlock < currentBlock) { + requireWithinDiff( + totalRewardsClaimed, + distributionInfo.fundsAvailable * 1 / 10, + 1e12, + "invariant DR5: Cumulative rewards claimed should be within 99.99% -or- 0.01 AJNA tokens of all available delegation rewards" + ); + } + require( + totalRewardsClaimed <= distributionInfo.fundsAvailable * 1 / 10, + "invariant DR5: Cumulative rewards claimed should be within 99.99% -or- 0.01 AJNA tokens of all available delegation rewards" + ); + + --distributionId; + } + } + +} diff --git a/test/invariants/base/FundingInvariants.sol b/test/invariants/base/FundingInvariants.sol new file mode 100644 index 00000000..76972863 --- /dev/null +++ b/test/invariants/base/FundingInvariants.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; +import { SafeCast } from "@oz/utils/math/SafeCast.sol"; + +import { GrantFund } from "../../../src/grants/GrantFund.sol"; +import { IGrantFund } from "../../../src/grants/interfaces/IGrantFund.sol"; + +import { TestBase } from "./TestBase.sol"; +import { StandardHandler } from "../handlers/StandardHandler.sol"; + +abstract contract FundingInvariants is TestBase { + + // hash the top ten proposals at the start of the funding stage to check composition + bytes32 initialTopTenHash; + + function _invariant_FS1_FS2_FS3(GrantFund grantFund_, StandardHandler standardHandler_) internal view { + uint24 distributionId = grantFund_.getDistributionId(); + while (distributionId > 0) { + + uint256[] memory topTenProposals = grantFund_.getTopTenProposals(distributionId); + + require( + topTenProposals.length <= 10, + "invariant FS1: 10 or less proposals should make it through the screening stage" + ); + + uint256[] memory standardFundingProposals = standardHandler_.getStandardFundingProposals(distributionId); + + // check invariants against every proposal + for (uint256 j = 0; j < standardFundingProposals.length; ++j) { + uint256 proposalId = standardHandler_.standardFundingProposals(distributionId, j); + (, uint24 proposalDistributionId, , , int128 fundingVotesReceived, ) = grantFund_.getProposalInfo(proposalId); + + if (_findProposalIndex(proposalId, topTenProposals) == -1) { + require( + fundingVotesReceived == 0, + "invariant FS2: proposals not in the top ten should not be able to recieve funding votes" + ); + } + + require( + distributionId == proposalDistributionId, + "invariant FS3: distribution id for a proposal should be the same as the current distribution id" + ); + } + --distributionId; + } + } + + function _invariant_FS4_FS5_FS6_FS7_FS8(GrantFund grantFund_, StandardHandler standardHandler_) internal { + uint24 distributionId = grantFund_.getDistributionId(); + while (distributionId > 0) { + + // check invariants against every actor + for (uint256 i = 0; i < standardHandler_.getActorsCount(); ++i) { + address actor = standardHandler_.actors(i); + + // get the initial funding stage voting power of the actor + (uint128 votingPower, uint128 remainingVotingPower, uint256 numberOfProposalsVotedOn) = grantFund_.getVoterInfo(distributionId, actor); + + // get the voting info of the actor + (IGrantFund.FundingVoteParams[] memory fundingVoteParams, , ) = standardHandler_.getVotingActorsInfo(actor, distributionId); + + uint128 sumOfSquares = SafeCast.toUint128(standardHandler_.sumSquareOfVotesCast(fundingVoteParams)); + + // check voter votes cast are less than or equal to the sqrt of the voting power of the actor + IGrantFund.FundingVoteParams[] memory fundingVotesCast = grantFund_.getFundingVotesCast(distributionId, actor); + + require( + sumOfSquares <= votingPower, + "invariant FS4: sum of square of votes cast <= voting power of actor" + ); + require( + sumOfSquares == votingPower - remainingVotingPower, + "invariant FS5: Sum of voter's votesCast should be equal to the square root of the voting power expended (FS4 restated, but added to test intermediate state as well as final)." + ); + + // check that the test functioned as expected + if (votingPower != 0 && remainingVotingPower == 0) { + assertTrue(numberOfProposalsVotedOn == fundingVotesCast.length); + assertTrue(numberOfProposalsVotedOn > 0); + } + + require( + uint256(standardHandler_.sumFundingVotes(fundingVoteParams)) <= _ajna.totalSupply(), + "invariant FS8: a voter should never be able to cast more votes than the Ajna token supply" + ); + + // check that there weren't any duplicate proposal entries, as votes for same proposal should be combined + uint256[] memory proposalIdsVotedOn = new uint256[](fundingVotesCast.length); + for (uint j = 0; j < fundingVotesCast.length; ) { + proposalIdsVotedOn[j] = fundingVotesCast[j].proposalId; + ++j; + } + require( + standardHandler_.hasDuplicates(proposalIdsVotedOn) == false, + "invariant FS6: All voter funding votes on a proposal should be cast in the same direction. Multiple votes on the same proposal should see the voting power increase according to the combined cost of votes." + ); + } + + (, uint256 startBlock, , , , ) = grantFund_.getDistributionPeriodInfo(distributionId); + StandardHandler.DistributionState memory state = standardHandler_.getDistributionState(distributionId); + // hash the top ten proposals at the start of the funding stage to check composition in FS7 + // if calling from the funding scenario, this is prepoulated and not accessed. Can't rely on hashing empty array as it will be non-zero + initialTopTenHash = state.topTenHashAtLastScreeningVote != 0 ? state.topTenHashAtLastScreeningVote : keccak256(abi.encode(grantFund_.getTopTenProposals(distributionId))); + + // check global invariants + if (currentBlock > grantFund_.getScreeningStageEndBlock(startBlock)) { + require( + keccak256(abi.encode(grantFund_.getTopTenProposals(distributionId))) == initialTopTenHash, + "invariant FS7: List of top ten proposals should never change once the funding stage has started" + ); + } + + // check the previous distribution period if available + --distributionId; + } + } + +} + diff --git a/test/invariants/base/Logger.sol b/test/invariants/base/Logger.sol new file mode 100644 index 00000000..587e4116 --- /dev/null +++ b/test/invariants/base/Logger.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; +import { Math } from "@oz/utils/math/Math.sol"; +import { Test } from "@std/Test.sol"; + +import { GrantFund } from "../../../src/grants/GrantFund.sol"; +import { IGrantFundState } from "../../../src/grants/interfaces/IGrantFundState.sol"; +import { Maths } from "../../../src/grants/libraries/Maths.sol"; + +import { StandardHandler } from "../handlers/StandardHandler.sol"; + +import { IAjnaToken } from "../../utils/IAjnaToken.sol"; +import { ITestBase } from "../base/ITestBase.sol"; + +contract Logger is Test { + + IAjnaToken internal _ajna; + GrantFund internal _grantFund; + ITestBase internal testContract; + StandardHandler internal _standardHandler; + uint256 internal logFileVerbosity; + + constructor(address grantFund_, address standardHandler_, address testContract_) { + _ajna = IAjnaToken(GrantFund(grantFund_).ajnaTokenAddress()); + _grantFund = GrantFund(grantFund_); + _standardHandler = StandardHandler(standardHandler_); + testContract = ITestBase(testContract_); + // Verbosity of Log file + logFileVerbosity = uint256(vm.envOr("LOGS_VERBOSITY", uint256(2))); + } + + /**************************/ + /*** Logging Functions ****/ + /**************************/ + + function logCallSummary() external view { + if (logFileVerbosity < 1) return; + console.log("\nCall Summary\n"); + console.log("--SFM----------"); + console.log("SFH.startNewDistributionPeriod ", _standardHandler.numberOfCalls("SFH.startNewDistributionPeriod")); + console.log("SFH.propose ", _standardHandler.numberOfCalls("SFH.propose")); + console.log("SFH.screeningVote ", _standardHandler.numberOfCalls("SFH.screeningVote")); + console.log("SFH.fundingVote ", _standardHandler.numberOfCalls("SFH.fundingVote")); + console.log("SFH.updateSlate ", _standardHandler.numberOfCalls("SFH.updateSlate")); + console.log("SFH.execute ", _standardHandler.numberOfCalls("SFH.execute")); + console.log("SFH.claimDelegateReward ", _standardHandler.numberOfCalls("SFH.claimDelegateReward")); + console.log("roll ", _standardHandler.numberOfCalls("roll")); + console.log("------------------"); + console.log( + "Total Calls:", + _standardHandler.numberOfCalls("SFH.startNewDistributionPeriod") + + _standardHandler.numberOfCalls("SFH.propose") + + _standardHandler.numberOfCalls("SFH.screeningVote") + + _standardHandler.numberOfCalls("SFH.fundingVote") + + _standardHandler.numberOfCalls("SFH.updateSlate") + + _standardHandler.numberOfCalls("SFH.execute") + + _standardHandler.numberOfCalls("SFH.claimDelegateReward") + + _standardHandler.numberOfCalls("roll") + ); + } + + function logProposalSummary() external view { + if (logFileVerbosity < 1) return; + uint24 distributionId = _grantFund.getDistributionId(); + uint256[] memory proposals = _standardHandler.getStandardFundingProposals(distributionId); + + console.log("\nProposal Summary\n"); + console.log("Number of Proposals", proposals.length); + for (uint256 i = 0; i < proposals.length; ++i) { + console.log("------------------"); + (uint256 proposalId, , uint128 votesReceived, uint128 tokensRequested, int128 fundingVotesReceived, bool executed) = _grantFund.getProposalInfo(proposals[i]); + console.log("proposalId: ", proposalId); + console.log("distributionId: ", distributionId); + console.log("executed: ", executed); + console.log("screening votesReceived: ", votesReceived); + console.log("tokensRequested: ", tokensRequested); + if (fundingVotesReceived < 0) { + console.log("Negative fundingVotesReceived: ", uint256(Maths.abs(fundingVotesReceived))); + } + else { + console.log("Positive fundingVotesReceived: ", uint256(int256(fundingVotesReceived))); + } + + console.log("------------------"); + } + console.log("\n"); + } + + function logTimeSummary() external view { + if (logFileVerbosity < 1) return; + uint24 distributionId = _grantFund.getDistributionId(); + (, uint256 startBlock, uint256 endBlock, , , ) = _grantFund.getDistributionPeriodInfo(distributionId); + console.log("\nTime Summary\n"); + console.log("------------------"); + console.log("Distribution Id: %s", distributionId); + console.log("start block: %s", startBlock); + console.log("end block: %s", endBlock); + console.log("block number: %s", block.number); + console.log("current block: %s", testContract.currentBlock()); + console.log("------------------"); + } + + function logFinalizeSummary(uint24 distributionId_) external view { + if (logFileVerbosity < 2) return; + (, , , uint128 fundsAvailable, , bytes32 topSlateHash) = _grantFund.getDistributionPeriodInfo(distributionId_); + uint256[] memory topSlateProposalIds = _grantFund.getFundedProposalSlate(topSlateHash); + + uint256[] memory topTenScreenedProposalIds = _grantFund.getTopTenProposals(distributionId_); + + console.log("\nFinalize Summary\n"); + console.log("------------------"); + console.log("Distribution Id: ", distributionId_); + console.log("Funds Available: ", fundsAvailable); + // DELEGATION REWARDS LOGS + console.log("Delegation Rewards Set: ", _standardHandler.numberOfCalls('delegationRewardSet')); + console.log("Delegation Rewards Claimed: ", _standardHandler.numberOfCalls('SFH.claimDelegateReward.success')); + // EXECUTE LOGS + console.log("Proposal Execute attempt: ", _standardHandler.numberOfCalls('SFH.execute.attempt')); + console.log("Proposal Execute Count: ", _standardHandler.numberOfCalls('SFH.execute.success')); + console.log("unexecuted proposal: ", _standardHandler.numberOfCalls('unexecuted.proposal')); + // UPDATE SLATE LOGS + console.log("Slate Update Prep: ", _standardHandler.numberOfCalls('SFH.updateSlate.prep')); + console.log("Slate Update length: ", _standardHandler.numberOfCalls('updateSlate.length')); + console.log("Slate Update Called: ", _standardHandler.numberOfCalls('SFH.updateSlate.called')); + console.log("Slate Update Success: ", _standardHandler.numberOfCalls('SFH.updateSlate.success')); + console.log("Slate Proposals: ", _standardHandler.numberOfCalls('proposalsInSlates')); + console.log("Next Slate length: ", _standardHandler.numberOfCalls('updateSlate.length')); + console.log("unused proposal: ", _standardHandler.numberOfCalls('unused.proposal')); + console.log("Top Slate Proposal Count: ", topSlateProposalIds.length); + console.log("Top Ten Proposal Count: ", topTenScreenedProposalIds.length); + console.log("Top slate funds requested: ", _standardHandler.getTokensRequestedInFundedSlateInvariant(topSlateHash)); + console.log("------------------"); + } + + function logFundingSummary(uint24 distributionId_) external view { + if (logFileVerbosity < 2) return; + console.log("\nFunding Summary\n"); + console.log("------------------"); + console.log("number of funding stage starts: ", _standardHandler.numberOfCalls("SFH.FundingStage")); + console.log("number of funding stage success votes: ", _standardHandler.numberOfCalls("SFH.fundingVote.success")); + console.log("number of proposals receiving funding: ", _standardHandler.numberOfCalls("SFH.fundingVote.proposal")); + console.log("number of funding stage negative votes: ", _standardHandler.numberOfCalls("SFH.negativeFundingVote")); + console.log("distributionId: ", distributionId_); + console.log("SFH.updateSlate.success: ", _standardHandler.numberOfCalls("SFH.updateSlate.success")); + (, , , , uint256 fundingPowerCast, ) = _grantFund.getDistributionPeriodInfo(distributionId_); + console.log("Total Funding Power Cast: ", fundingPowerCast); + console.log("------------------"); + } + + function logActorSummary(uint24 distributionId_, bool funding_, bool screening_) external view { + if (logFileVerbosity < 3) return; + console.log("\nActor Summary\n"); + + console.log("------------------"); + console.log("Number of Actors", _standardHandler.getActorsCount()); + + // sum proposal votes of each actor + for (uint256 i = 0; i < _standardHandler.getActorsCount(); ++i) { + address actor = _standardHandler.actors(i); + + // get actor info + ( + IGrantFundState.FundingVoteParams[] memory fundingVoteParams, + IGrantFundState.ScreeningVoteParams[] memory screeningVoteParams, + uint256 delegationRewardsClaimed + ) = _standardHandler.getVotingActorsInfo(actor, distributionId_); + + console.log("Actor: ", actor); + console.log("Delegate: ", _ajna.delegates(actor)); + console.log("delegationRewardsClaimed: ", delegationRewardsClaimed); + console.log("\n"); + + // log funding info + if (funding_) { + console.log("--Funding----------"); + console.log("Funding proposals voted for: ", fundingVoteParams.length); + console.log("Sum of squares of fvc: ", _standardHandler.sumSquareOfVotesCast(fundingVoteParams)); + console.log("Funding Votes Cast: ", uint256(_standardHandler.sumFundingVotes(fundingVoteParams))); + console.log("Negative Funding Votes Cast: ", _standardHandler.countNegativeFundingVotes(fundingVoteParams)); + console.log("------------------"); + console.log("\n"); + } + + if (screening_) { + console.log("--Screening----------"); + console.log("Screening Voting Power: ", _grantFund.getVotesScreening(distributionId_, actor)); + console.log("Screening Votes Cast: ", _standardHandler.sumVoterScreeningVotes(actor, distributionId_)); + console.log("Screening proposals voted for: ", screeningVoteParams.length); + console.log("------------------"); + console.log("\n"); + } + } + } + + function logActorDelegationRewards(uint24 distributionId_) external view { + if (logFileVerbosity < 3) return; + console.log("\nActor Delegation Rewards\n"); + + console.log("------------------"); + console.log("Number of Actors", _standardHandler.getActorsCount()); + console.log("------------------"); + console.log("\n"); + + uint256 totalDelegationRewardsClaimed = 0; + + // get voter info + for (uint256 i = 0; i < _standardHandler.getActorsCount(); ++i) { + address actor = _standardHandler.actors(i); + + // get actor info + ( + , + , + uint256 delegationRewardsClaimed + ) = _standardHandler.getVotingActorsInfo(actor, distributionId_); + + totalDelegationRewardsClaimed += delegationRewardsClaimed; + + console.log("Actor: ", actor); + console.log("Delegate: ", _ajna.delegates(actor)); + console.log("delegationRewardsClaimed: ", delegationRewardsClaimed); + + (uint256 votingPower, uint256 remainingVotingPower, ) = _grantFund.getVoterInfo(distributionId_, actor); + + uint256 votingPowerAllocatedByDelegatee = votingPower - remainingVotingPower; + uint256 rootVotingPowerAllocatedByDelegatee = Math.sqrt(votingPowerAllocatedByDelegatee * 1e18); + console.log("votingPower: ", votingPower); + console.log("remainingVotingPower: ", remainingVotingPower); + console.log("votingPowerAllocatedByDelegatee: ", votingPowerAllocatedByDelegatee); + console.log("rootVotingPowerAllocatedByDelegatee: ", rootVotingPowerAllocatedByDelegatee); + + if (votingPowerAllocatedByDelegatee > 0 && rootVotingPowerAllocatedByDelegatee == 0) { + console.log("ACTOR ROUNDED TO 0 REWARDS: ", actor); + } + console.log("------------------"); + console.log("\n"); + + } + console.log("totalDelegationRewardsClaimed: ", totalDelegationRewardsClaimed); + } +} + + diff --git a/test/invariants/base/ScreeningInvariants.sol b/test/invariants/base/ScreeningInvariants.sol new file mode 100644 index 00000000..349f06c1 --- /dev/null +++ b/test/invariants/base/ScreeningInvariants.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; + +import { GrantFund } from "../../../src/grants/GrantFund.sol"; +import { IGrantFund } from "../../../src/grants/interfaces/IGrantFund.sol"; +import { IGrantFundState } from "../../../src/grants/interfaces/IGrantFundState.sol"; + +import { TestBase } from "./TestBase.sol"; +import { StandardHandler } from "../handlers/StandardHandler.sol"; + +abstract contract ScreeningInvariants is TestBase { + + /********************/ + /**** Invariants ****/ + /********************/ + + function _invariant_SS1_SS3_SS4_SS5_SS6_SS7_SS8_SS10_SS11_P1_P2(GrantFund grantFund_, StandardHandler standardHandler_) internal { + uint24 distributionId = grantFund_.getDistributionId(); + while (distributionId > 0) { + + uint256[] memory allProposals = standardHandler_.getStandardFundingProposals(distributionId); + uint256 standardFundingProposalsSubmitted = allProposals.length; + uint256[] memory topTenProposals = grantFund_.getTopTenProposals(distributionId); + (, uint256 startBlock, , uint256 gbc, , ) = grantFund_.getDistributionPeriodInfo(distributionId); + + require( + topTenProposals.length <= 10 && standardFundingProposalsSubmitted >= topTenProposals.length, + "invariant SS1: 10 or less proposals should make it through the screening stage" + ); + + // check the state of the top ten proposals + if (topTenProposals.length > 1) { + for (uint256 i = 0; i < topTenProposals.length - 1; ++i) { + // check the current proposals votes received against the next proposal in the top ten list + (, uint24 distributionIdCurr, uint256 votesReceivedCurr, , , ) = grantFund_.getProposalInfo(topTenProposals[i]); + (, uint24 distributionIdNext, uint256 votesReceivedNext, , , ) = grantFund_.getProposalInfo(topTenProposals[i + 1]); + require( + votesReceivedCurr >= votesReceivedNext, + "invariant SS3: proposals should be sorted in descending order" + ); + + require( + votesReceivedCurr >= 0 && votesReceivedNext >= 0, + "invariant SS4: Screening votes recieved for a proposal can only be positive" + ); + + require( + distributionIdCurr == distributionIdNext && distributionIdCurr == distributionId, + "invariant SS5: distribution id for a proposal should be the same as the current distribution id" + ); + } + } + + // find the number of screening votes received by the last proposal in the top ten list + uint256 votesReceivedLast; + if (topTenProposals.length != 0) { + (, , votesReceivedLast, , , ) = grantFund_.getProposalInfo(topTenProposals[topTenProposals.length - 1]); + assertGe(votesReceivedLast, 0); + } + + // check invariants against all submitted proposals + for (uint256 j = 0; j < standardFundingProposalsSubmitted; ++j) { + (uint256 proposalId, , uint256 votesReceived, uint256 tokensRequested, , ) = grantFund_.getProposalInfo(standardHandler_.standardFundingProposals(distributionId, j)); + require( + votesReceived >= 0, + "invariant SS4: Screening votes recieved for a proposal can only be positive" + ); + + require( + votesReceived <= _ajna.totalSupply(), + "invariant SS8: a proposal should never receive more screening votes than the token supply" + ); + + // check each submitted proposals votes against the last proposal in the top ten list + if (_findProposalIndex(proposalId, topTenProposals) == -1) { + if (votesReceivedLast != 0) { + require( + votesReceived <= votesReceivedLast, + "invariant SS6: proposals should be incorporated into the top ten list if, and only if, they have as many or more votes as the last member of the top ten list." + ); + } + } + + TestProposal memory testProposal = standardHandler_.getTestProposal(proposalId); + require( + testProposal.blockAtCreation <= grantFund_.getScreeningStageEndBlock(startBlock), + "invariant SS10: A proposal can only be created during a distribution period's screening stage" + ); + + require( + tokensRequested <= gbc * 9 / 10, "invariant SS11: A proposal's tokens requested must be <= 90% of GBC" + ); + + IGrantFundState.ProposalState state = grantFund_.state(proposalId); + require( + state != IGrantFundState.ProposalState.Pending && + state != IGrantFundState.ProposalState.Canceled && + state != IGrantFundState.ProposalState.Expired && + state != IGrantFundState.ProposalState.Queued, + "Invariant P1: A proposal should never enter an unused state (pending, canceled, queued, expired)." + ); + } + + // check proposalIds for duplicates + require( + !hasDuplicates(allProposals), "invariant P2: A proposal's proposalId must be unique" + ); + + if (standardHandler_.screeningVotesCast(distributionId) > 0) { + require( + topTenProposals.length > 0, + "invariant SS7: Screening vote's on a proposal should cause addition to the topTenProposals if the array is unpopulated." + ); + } + + --distributionId; + } + } + + function _invariant_SS2_SS4_SS9(GrantFund grantFund_, StandardHandler standardHandler_) internal view { + uint256 actorCount = standardHandler_.getActorsCount(); + uint24 distributionId = grantFund_.getDistributionId(); + while (distributionId > 0) { + + // check invariants for all actors + for (uint256 i = 0; i < actorCount; ++i) { + address actor = standardHandler_.actors(i); + uint256 votingPower = grantFund_.getVotesScreening(distributionId, actor); + + require( + standardHandler_.sumVoterScreeningVotes(actor, distributionId) <= votingPower, + "invariant SS2: can only vote up to the amount of voting power at the snapshot blocks" + ); + + // check the screening votes cast by the actor + ( , IGrantFund.ScreeningVoteParams[] memory screeningVoteParams, ) = standardHandler_.getVotingActorsInfo(actor, distributionId); + for (uint256 j = 0; j < screeningVoteParams.length; ++j) { + require( + screeningVoteParams[j].votes >= 0, + "invariant SS4: can only cast positive votes" + ); + + require( + _findProposalIndex(screeningVoteParams[j].proposalId, standardHandler_.getStandardFundingProposals(distributionId)) != -1, + "invariant SS9: a proposal can only receive screening votes if it was created via propose()" + ); + } + } + + --distributionId; + } + } + +} diff --git a/test/invariants/base/StandardTestBase.sol b/test/invariants/base/StandardTestBase.sol index 76f7fd13..b21b348e 100644 --- a/test/invariants/base/StandardTestBase.sol +++ b/test/invariants/base/StandardTestBase.sol @@ -4,15 +4,24 @@ pragma solidity 0.8.18; import { console } from "@std/console.sol"; -import { TestBase } from "./TestBase.sol"; +import { Logger } from "./Logger.sol"; import { StandardHandler } from "../handlers/StandardHandler.sol"; +import { TestBase } from "./TestBase.sol"; + +import { DistributionPeriodInvariants } from "./DistributionPeriodInvariants.sol"; +import { FinalizeInvariants } from "./FinalizeInvariants.sol"; +import { FundingInvariants } from "./FundingInvariants.sol"; +import { ScreeningInvariants } from "./ScreeningInvariants.sol"; -contract StandardTestBase is TestBase { +contract StandardTestBase is DistributionPeriodInvariants, FinalizeInvariants, FundingInvariants, ScreeningInvariants { - uint256 internal constant NUM_ACTORS = 20; + uint256 internal constant NUM_ACTORS = 20; // default number of actors + uint256 internal constant NUM_PROPOSALS = 200; // default maximum number of proposals that can be created in a distribution period + uint256 internal constant PER_ADDRESS_TOKEN_REQ_CAP = 10; // Percentage of funds available to request per proposal recipient in invariants uint256 public constant TOKENS_TO_DISTRIBUTE = 500_000_000 * 1e18; StandardHandler internal _standardHandler; + Logger internal _logger; function setUp() public virtual override { super.setUp(); @@ -21,19 +30,29 @@ contract StandardTestBase is TestBase { payable(address(_grantFund)), address(_ajna), _tokenDeployer, - NUM_ACTORS, + vm.envOr("NUM_ACTORS", NUM_ACTORS), + vm.envOr("NUM_PROPOSALS", NUM_PROPOSALS), + vm.envOr("PER_ADDRESS_TOKEN_REQ_CAP", PER_ADDRESS_TOKEN_REQ_CAP), TOKENS_TO_DISTRIBUTE, address(this) ); + // instantiate logger + _logger = new Logger(address(_grantFund), address(_standardHandler), address(this)); + // explicitly target handler targetContract(address(_standardHandler)); } + /***************************/ + /**** Utility Functions ****/ + /***************************/ + function startDistributionPeriod() internal { // skip time for snapshots and start distribution period vm.roll(currentBlock + 100); currentBlock = block.number; _grantFund.startNewDistributionPeriod(); } + } diff --git a/test/invariants/base/TestBase.sol b/test/invariants/base/TestBase.sol index cf3eb53b..f2600c60 100644 --- a/test/invariants/base/TestBase.sol +++ b/test/invariants/base/TestBase.sol @@ -36,8 +36,32 @@ contract TestBase is Test, GrantFundTestHelper { currentBlock = block.number; } - function setCurrentBlock(uint256 currentBlock_) external { + /*****************/ + /*** Modifiers ***/ + /*****************/ + + modifier useCurrentBlock() { + vm.roll(currentBlock); + + _; + + setCurrentBlock(block.number); + } + + /***************************/ + /**** Utility Functions ****/ + /***************************/ + + function setCurrentBlock(uint256 currentBlock_) public { currentBlock = currentBlock_; } + function getDiff(uint256 x, uint256 y) internal pure returns (uint256 diff) { + diff = x > y ? x - y : y - x; + } + + function requireWithinDiff(uint256 x, uint256 y, uint256 expectedDiff, string memory err) internal pure { + require(getDiff(x, y) <= expectedDiff, err); + } + } diff --git a/test/invariants/handlers/Handler.sol b/test/invariants/handlers/Handler.sol index 1921a355..19efd4e3 100644 --- a/test/invariants/handlers/Handler.sol +++ b/test/invariants/handlers/Handler.sol @@ -198,7 +198,7 @@ contract Handler is Test, GrantFundTestHelper { } function randomAmount(uint256 maxAmount_) internal returns (uint256) { - return constrictToRange(randomSeed(), 1, maxAmount_); + return constrictToRange(randomSeed(), 0, maxAmount_); } function randomActor() internal returns (address) { @@ -218,6 +218,10 @@ contract Handler is Test, GrantFundTestHelper { /*** View Functions ****/ /***********************/ + function getActors() public view returns(address[] memory) { + return actors; + } + function getActorsCount() public view returns(uint256) { return actors.length; } diff --git a/test/invariants/handlers/StandardHandler.sol b/test/invariants/handlers/StandardHandler.sol index 21d52468..c95547a4 100644 --- a/test/invariants/handlers/StandardHandler.sol +++ b/test/invariants/handlers/StandardHandler.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.18; import { console } from "@std/console.sol"; import { Test } from "forge-std/Test.sol"; -import { Math } from "@oz/utils/math/Math.sol"; import { SafeCast } from "@oz/utils/math/SafeCast.sol"; import { Strings } from "@oz/utils/Strings.sol"; import { Math } from "@oz/utils/math/Math.sol"; @@ -25,9 +24,10 @@ contract StandardHandler is Handler { // proposalId of proposals executed uint256[] public proposalsExecuted; + uint256 public maxProposals; // maximum number of proposals for a distribution period + uint256 public percentageTokensReq; // Percentage of funds available to request per proposal recipient in invariants // number of proposals that recieved a vote in the given stage - uint256 public screeningVotesCast; uint256 public fundingVotesCast; struct VotingActor { @@ -43,6 +43,8 @@ contract StandardHandler is Handler { Slate[] topSlates; // assume that the last element in the list is the top slate bool treasuryUpdated; // whether the distribution period's surplus tokens have been readded to the treasury uint256 totalRewardsClaimed; // total delegation rewards claimed in a distribution period + uint256 numVoterRewardsClaimed; // number of unique voters who claimed rewards in a distribution period + bytes32 topTenHashAtLastScreeningVote; // slate hash of top ten proposals at the last time a sreening vote is cast } struct Slate { @@ -58,6 +60,7 @@ contract StandardHandler is Handler { mapping(address => mapping(uint24 => VotingActor)) internal votingActors; // actor => distributionId => VotingActor mapping(uint256 => TestProposal) public testProposals; // proposalId => TestProposal mapping(uint24 => bool) public distributionIdSurplusAdded; + mapping(uint24 => uint256) public screeningVotesCast; // total screening votes cast in a distribution period /*******************/ /*** Constructor ***/ @@ -68,9 +71,14 @@ contract StandardHandler is Handler { address token_, address tokenDeployer_, uint256 numOfActors_, + uint256 maxProposals_, + uint256 percentageTokensReq_, uint256 treasury_, address testContract_ - ) Handler(grantFund_, token_, tokenDeployer_, numOfActors_, treasury_, testContract_) {} + ) Handler(grantFund_, token_, tokenDeployer_, numOfActors_, treasury_, testContract_) { + maxProposals = maxProposals_; + percentageTokensReq = percentageTokensReq_; + } /*************************/ /*** Wrapped Functions ***/ @@ -116,6 +124,9 @@ contract StandardHandler is Handler { string memory description ) = generateProposalParams(address(_ajna), testProposalParams); + // liimit the number of proposals created in a distribution period + if (standardFundingProposals[distributionId].length >= maxProposals) return; + try _grantFund.propose(targets, values, calldatas, description) returns (uint256 proposalId) { standardFundingProposals[distributionId].push(proposalId); @@ -144,21 +155,8 @@ contract StandardHandler is Handler { vm.roll(block.number + 100); - // get actor voting power - uint256 votingPower = _grantFund.getVotesScreening(_grantFund.getDistributionId(), _actor); - // construct vote params - IGrantFundState.ScreeningVoteParams[] memory screeningVoteParams = new IGrantFundState.ScreeningVoteParams[](proposalsToVoteOn_); - for (uint256 i = 0; i < proposalsToVoteOn_; i++) { - // get a random proposal - uint256 proposalId = randomProposal(); - - // generate screening vote params - screeningVoteParams[i] = IGrantFundState.ScreeningVoteParams({ - proposalId: proposalId, - votes: constrictToRange(randomSeed(), 0, votingPower) // TODO: account for previously used voting power in a happy path scenario - }); - } + IGrantFundState.ScreeningVoteParams[] memory screeningVoteParams = _screeningVoteParams(_actor, distributionId, proposalsToVoteOn_, true); try _grantFund.screeningVote(screeningVoteParams) { // update actor screeningVotes if vote was successful @@ -166,10 +164,11 @@ contract StandardHandler is Handler { for (uint256 i = 0; i < proposalsToVoteOn_; ) { actor.screeningVotes.push(screeningVoteParams[i]); - screeningVotesCast++; + screeningVotesCast[distributionId]++; ++i; } + distributionStates[distributionId].topTenHashAtLastScreeningVote = keccak256(abi.encode(_grantFund.getTopTenProposals(distributionId))); } catch (bytes memory _err){ bytes32 err = keccak256(_err); @@ -198,7 +197,6 @@ contract StandardHandler is Handler { // bind proposalsToVoteOn_ to the number of proposals proposalsToVoteOn_ = constrictToRange(proposalsToVoteOn_, 1, standardFundingProposals[distributionId].length); - // TODO: switch this to true or false? potentially also move flip coin up // get the fundingVoteParams for the votes the actor is about to cast // take the chaotic path, and cast votes that will likely exceed the actor's voting power IGrantFundState.FundingVoteParams[] memory fundingVoteParams = _fundingVoteParams(_actor, distributionId, proposalsToVoteOn_, true); @@ -324,7 +322,6 @@ contract StandardHandler is Handler { uint24 distributionId = _grantFund.getDistributionId(); if (distributionId == 0) return; - // TODO: implement unhappy path uint256 proposalId = _findUnexecutedProposalId(distributionId); TestProposal memory proposal = testProposals[proposalId]; numberOfCalls['unexecuted.proposal'] = proposalId; @@ -340,8 +337,10 @@ contract StandardHandler is Handler { try _grantFund.execute(targets, values, calldatas, keccak256(bytes(proposal.description))) returns (uint256 proposalId_) { assertEq(proposalId_, proposal.proposalId); + numberOfCalls['SFH.execute.success']++; proposalsExecuted.push(proposalId_); + testProposals[proposalId].blockAtExecution = block.number; } catch (bytes memory _err){ bytes32 err = keccak256(_err); @@ -359,20 +358,23 @@ contract StandardHandler is Handler { uint24 distributionId = _grantFund.getDistributionId(); if (distributionId == 0) return; - uint24 distributionIdToClaim = _findUnclaimedReward(_actor, distributionId); + (address actor, uint24 distributionIdToClaim) = _findUnclaimedReward(distributionId); + + changePrank(actor); try _grantFund.claimDelegateReward(distributionIdToClaim) returns (uint256 rewardClaimed_) { numberOfCalls['SFH.claimDelegateReward.success']++; // should only be able to claim delegation rewards once - assertEq(votingActors[_actor][distributionIdToClaim].delegationRewardsClaimed, 0); + assertEq(votingActors[actor][distributionIdToClaim].delegationRewardsClaimed, 0); // rewards should be non zero assertTrue(rewardClaimed_ > 0); // record the newly claimed rewards - votingActors[_actor][distributionIdToClaim].delegationRewardsClaimed = rewardClaimed_; + votingActors[actor][distributionIdToClaim].delegationRewardsClaimed = rewardClaimed_; distributionStates[distributionIdToClaim].totalRewardsClaimed += rewardClaimed_; + distributionStates[distributionIdToClaim].numVoterRewardsClaimed++; } catch (bytes memory _err){ bytes32 err = keccak256(_err); @@ -380,6 +382,7 @@ contract StandardHandler is Handler { err == keccak256(abi.encodeWithSignature("DelegateRewardInvalid()")) || err == keccak256(abi.encodeWithSignature("DistributionPeriodStillActive()")) || err == keccak256(abi.encodeWithSignature("RewardAlreadyClaimed()")), + // err == keccak256("Division or modulo by 0"), // when called with 0 funding voting power or Math.sqrt() rounds down to 0 power UNEXPECTED_REVERT ); } @@ -441,8 +444,9 @@ contract StandardHandler is Handler { uint24 distributionId = _grantFund.getDistributionId(); (, , , uint128 fundsAvailable, , ) = _grantFund.getDistributionPeriodInfo(distributionId); - // account for amount that was previously requested - uint256 additionalTokensRequested = randomAmount(uint256(fundsAvailable * 9 /10) - totalTokensRequested); + // set a proposals tokens requested for an address's max amount to a configurable percentage of the funds available in a period + // account for amount that was previously requested with totalTokensRequested accumulator + uint256 additionalTokensRequested = randomAmount(uint256(fundsAvailable * percentageTokensReq / 100) - totalTokensRequested); totalTokensRequested += additionalTokensRequested; testProposalParams_[i] = TestProposalParams({ @@ -543,10 +547,26 @@ contract StandardHandler is Handler { } } - // Need to account for a proposal prior vote direction in test setup + // flip a coin to see if we should generate a positive or negative vote + if (randomSeed() % 2 == 0) { + numberOfCalls['SFH.negativeFundingVote']++; + // generate negative vote + fundingVoteParams_[i] = IGrantFundState.FundingVoteParams({ + proposalId: proposalId, + votesUsed: -1 * votesToCast + }); + } + numberOfCalls['SFH.fundingVote.proposal']++; + + // Ensure new vote won't revert from a change of direction if (priorVoteIndex != -1) { - // check if prior vote was negative - if (priorVotes[uint256(priorVoteIndex)].votesUsed < 0) { + int256 priorVotesUsed = priorVotes[uint256(priorVoteIndex)].votesUsed; + // check if prior vote was negative and this vote is positive + if (priorVotesUsed < 0 && votesToCast > 0) { + votesToCast = votesToCast * -1; + } + // check if prior vote was positive and this vote is negative + if (priorVotesUsed > 0 && votesToCast < 0) { votesToCast = votesToCast * -1; } } @@ -570,20 +590,6 @@ contract StandardHandler is Handler { // start voting on the next proposal ++i; } - else { - // TODO: move flip coin into happy path - // flip a coin to see if should instead use a negative vote - if (randomSeed() % 2 == 0) { - numberOfCalls['SFH.negativeFundingVote']++; - // generate negative vote - fundingVoteParams_[i] = IGrantFundState.FundingVoteParams({ - proposalId: proposalId, - votesUsed: -1 * votesToCast - }); - ++i; - continue; - } - } } } @@ -593,8 +599,8 @@ contract StandardHandler is Handler { uint256 numProposalsToVoteOn_, bool happyPath_ ) internal returns (IGrantFundState.ScreeningVoteParams[] memory screeningVoteParams_) { - uint256 votingPower = _grantFund.getVotesScreening(distributionId_, actor_); - uint256 totalVotesUsed = 0; + uint256 votingPower = _grantFund.getVotesScreening(distributionId_, actor_); + uint256 totalVotesUsed = _grantFund.getScreeningVotesCast(distributionId_, actor_); // determine which proposals should be voted upon screeningVoteParams_ = new IGrantFundState.ScreeningVoteParams[](numProposalsToVoteOn_); @@ -663,7 +669,7 @@ contract StandardHandler is Handler { VotingActor storage actor = votingActors[actor_][distributionId]; for (uint256 i = 0; i < numProposalsToVoteOn; ) { actor.screeningVotes.push(screeningVoteParams[i]); - screeningVotesCast++; + screeningVotesCast[distributionId]++; ++i; } @@ -717,135 +723,36 @@ contract StandardHandler is Handler { } } - function _findUnclaimedReward(address actor_, uint24 endingDistributionId_) internal returns (uint24 distributionIdToClaim_) { - for (uint24 i = 1; i <= endingDistributionId_; ) { - uint256 delegationReward = _grantFund.getDelegateReward(i, actor_); - numberOfCalls["delegationRewardSet"]++; - if (delegationReward > 0) { - numberOfCalls["delegationRewardSet"]++; - distributionIdToClaim_ = i; - break; - } - ++i; - } - } - - /**************************/ - /*** Logging Functions ****/ - /**************************/ - - function logActorSummary(uint24 distributionId_, bool funding_, bool screening_) external view { - console.log("\nActor Summary\n"); - - console.log("------------------"); - console.log("Number of Actors", getActorsCount()); - - // sum proposal votes of each actor - for (uint256 i = 0; i < getActorsCount(); ++i) { + function _findUnclaimedReward(uint24 endingDistributionId_) internal returns (address, uint24) { + for (uint256 i = 0; i < actors.length; ++i) { + // get an actor who hasn't already claimed rewards for a period address actor = actors[i]; - // get actor info - ( - IGrantFundState.FundingVoteParams[] memory fundingVoteParams, - IGrantFundState.ScreeningVoteParams[] memory screeningVoteParams, - uint256 delegationRewardsClaimed - ) = getVotingActorsInfo(actor, distributionId_); - - console.log("Actor: ", actor); - console.log("Delegate: ", _ajna.delegates(actor)); - console.log("delegationRewardsClaimed: ", delegationRewardsClaimed); - console.log("\n"); - - // log funding info - if (funding_) { - console.log("--Funding----------"); - console.log("Funding proposals voted for: ", fundingVoteParams.length); - console.log("Sum of squares of fvc: ", sumSquareOfVotesCast(fundingVoteParams)); - console.log("Funding Votes Cast: ", uint256(sumFundingVotes(fundingVoteParams))); - console.log("Negative Funding Votes Cast: ", countNegativeFundingVotes(fundingVoteParams)); - console.log("------------------"); - console.log("\n"); - } - - if (screening_) { - console.log("--Screening----------"); - console.log("Screening Voting Power: ", _grantFund.getVotesScreening(distributionId_, actor)); - console.log("Screening Votes Cast: ", sumVoterScreeningVotes(actor, distributionId_)); - console.log("Screening proposals voted for: ", screeningVoteParams.length); - console.log("------------------"); - console.log("\n"); + for (uint24 j = 1; j <= endingDistributionId_; ) { + uint256 delegationReward = _grantFund.getDelegateReward(j, actor); + numberOfCalls["delegationRewardSet"]++; + if (delegationReward > 0 && _grantFund.getHasClaimedRewards(j, actor) == false) { + numberOfCalls["delegationRewardSet"]++; + return (actor, j); + } + ++j; } } + return (address(0), 0); } - function logCallSummary() external view { - console.log("\nCall Summary\n"); - console.log("--SFM----------"); - console.log("SFH.startNewDistributionPeriod ", numberOfCalls["SFH.startNewDistributionPeriod"]); - console.log("SFH.propose ", numberOfCalls["SFH.propose"]); - console.log("SFH.screeningVote ", numberOfCalls["SFH.screeningVote"]); - console.log("SFH.fundingVote ", numberOfCalls["SFH.fundingVote"]); - console.log("SFH.updateSlate ", numberOfCalls["SFH.updateSlate"]); - console.log("SFH.execute ", numberOfCalls["SFH.execute"]); - console.log("SFH.claimDelegateReward ", numberOfCalls["SFH.claimDelegateReward"]); - console.log("roll ", numberOfCalls["roll"]); - console.log("------------------"); - console.log( - "Total Calls:", - numberOfCalls["SFH.startNewDistributionPeriod"] + - numberOfCalls["SFH.propose"] + - numberOfCalls["SFH.screeningVote"] + - numberOfCalls["SFH.fundingVote"] + - numberOfCalls["SFH.updateSlate"] + - numberOfCalls["SFH.execute"] + - numberOfCalls["SFH.claimDelegateReward"] + - numberOfCalls["roll"] - ); - } + /***********************/ + /*** View Functions ****/ + /***********************/ - function logProposalSummary() external view { - uint24 distributionId = _grantFund.getDistributionId(); - uint256[] memory proposals = standardFundingProposals[distributionId]; - - console.log("\nProposal Summary\n"); - console.log("Number of Proposals", proposals.length); - for (uint256 i = 0; i < proposals.length; ++i) { - console.log("------------------"); - (uint256 proposalId, , uint128 votesReceived, uint128 tokensRequested, int128 fundingVotesReceived, bool executed) = _grantFund.getProposalInfo(proposals[i]); - console.log("proposalId: ", proposalId); - console.log("distributionId: ", distributionId); - console.log("executed: ", executed); - console.log("screening votesReceived: ", votesReceived); - console.log("tokensRequested: ", tokensRequested); - if (fundingVotesReceived < 0) { - console.log("Negative fundingVotesReceived: ", uint256(Maths.abs(fundingVotesReceived))); - } - else { - console.log("Positive fundingVotesReceived: ", uint256(int256(fundingVotesReceived))); + function getNumVotersWithRewards(uint24 distributionId) external view returns (uint256 numVoters_) { + for (uint256 i = 0; i < actors.length; ++i) { + if (_grantFund.getDelegateReward(distributionId, actors[i]) > 0) { + numVoters_++; } - - console.log("------------------"); } - console.log("\n"); } - function logTimeSummary() external view { - uint24 distributionId = _grantFund.getDistributionId(); - (, uint256 startBlock, uint256 endBlock, , , ) = _grantFund.getDistributionPeriodInfo(distributionId); - console.log("\nTime Summary\n"); - console.log("------------------"); - console.log("Distribution Id: %s", distributionId); - console.log("start block: %s", startBlock); - console.log("end block: %s", endBlock); - console.log("block number: %s", block.number); - console.log("current block: %s", testContract.currentBlock()); - console.log("------------------"); - } - - /***********************/ - /*** View Functions ****/ - /***********************/ - function getDistributionFundsUpdated(uint24 distributionId_) external view returns (bool) { return distributionStates[distributionId_].treasuryUpdated; } @@ -917,6 +824,14 @@ contract StandardHandler is Handler { } } + function sumSlateFundingVotes(bytes32 slateHash_) public view returns (int256 sum_) { + uint256[] memory fundedProposals = _grantFund.getFundedProposalSlate(slateHash_); + for (uint256 i = 0; i < fundedProposals.length; ++i) { + (, , , , int256 fundingVotesReceived, ) = _grantFund.getProposalInfo(fundedProposals[i]); + sum_ += fundingVotesReceived; + } + } + function countNegativeFundingVotes(IGrantFundState.FundingVoteParams[] memory fundingVotes_) public pure returns (uint256 count_) { for (uint256 i = 0; i < fundingVotes_.length; ++i) { if (fundingVotes_[i].votesUsed < 0) { diff --git a/test/invariants/scenarios/FinalizeInvariant.t.sol b/test/invariants/scenarios/FinalizeInvariant.t.sol new file mode 100644 index 00000000..8a13b33a --- /dev/null +++ b/test/invariants/scenarios/FinalizeInvariant.t.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; + +import { StandardTestBase } from "../base/StandardTestBase.sol"; +import { StandardHandler } from "../handlers/StandardHandler.sol"; +import { Handler } from "../handlers/Handler.sol"; + +contract FinalizeInvariant is StandardTestBase { + + // override setup to start tests in the challenge stage with proposals that have already been screened and funded + function setUp() public override { + super.setUp(); + + startDistributionPeriod(); + + // create 15 proposals + _standardHandler.createProposals(15); + + // cast screening votes on proposals + _standardHandler.screeningVoteProposals(); + + // skip time into the funding stage + uint24 distributionId = _grantFund.getDistributionId(); + (, uint256 startBlock, uint256 endBlock, , , ) = _grantFund.getDistributionPeriodInfo(distributionId); + uint256 fundingStageStartBlock = _grantFund.getScreeningStageEndBlock(startBlock) + 1; + vm.roll(fundingStageStartBlock + 100); + currentBlock = fundingStageStartBlock + 100; + + // cast funding votes on proposals + _standardHandler.fundingVoteProposals(); + + _standardHandler.setCurrentScenarioType(Handler.ScenarioType.Medium); + + // skip time into the challenge stage + uint256 challengeStageStartBlock = _grantFund.getChallengeStageStartBlock(endBlock); + vm.roll(challengeStageStartBlock + 100); + currentBlock = challengeStageStartBlock + 100; + + // set the list of function selectors to run + bytes4[] memory selectors = new bytes4[](5); + selectors[0] = _standardHandler.fundingVote.selector; + selectors[1] = _standardHandler.updateSlate.selector; + selectors[2] = _standardHandler.execute.selector; + selectors[3] = _standardHandler.claimDelegateReward.selector; + selectors[4] = _standardHandler.roll.selector; + + // ensure utility functions are excluded from the invariant runs + targetSelector(FuzzSelector({ + addr: address(_standardHandler), + selectors: selectors + })); + + // check test setup + uint256[] memory topTenProposals = _grantFund.getTopTenProposals(distributionId); + assertTrue(topTenProposals.length > 0); + } + + function invariant_finalize() external useCurrentBlock { + _invariant_CS1_CS2_CS3_CS4_CS5_CS6_CS7(_grantFund, _standardHandler); + _invariant_ES1_ES2_ES3_ES4_ES5(_grantFund, _standardHandler); + _invariant_DR1_DR2_DR3_DR4_DR5(_grantFund, _standardHandler); + } + + function invariant_call_summary() external useCurrentBlock { + uint24 distributionId = _grantFund.getDistributionId(); + + _logger.logCallSummary(); + _logger.logTimeSummary(); + _logger.logFinalizeSummary(distributionId); + _logger.logActorSummary(distributionId, false, false); + _logger.logProposalSummary(); + _logger.logActorDelegationRewards(distributionId); + } + +} diff --git a/test/invariants/scenarios/FundingInvariant.t.sol b/test/invariants/scenarios/FundingInvariant.t.sol new file mode 100644 index 00000000..3ddd9183 --- /dev/null +++ b/test/invariants/scenarios/FundingInvariant.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; +import { SafeCast } from "@oz/utils/math/SafeCast.sol"; + +import { IGrantFund } from "../../../src/grants/interfaces/IGrantFund.sol"; + +import { StandardTestBase } from "../base/StandardTestBase.sol"; +import { StandardHandler } from "../handlers/StandardHandler.sol"; + +contract FundingInvariant is StandardTestBase { + + // override setup to start tests in the funding stage with already screened proposals + function setUp() public override { + super.setUp(); + + startDistributionPeriod(); + + // create 15 proposals + _standardHandler.createProposals(15); + + // cast screening votes on proposals + _standardHandler.screeningVoteProposals(); + + // skip time into the funding stage + uint24 distributionId = _grantFund.getDistributionId(); + (, uint256 startBlock, , , , ) = _grantFund.getDistributionPeriodInfo(distributionId); + uint256 fundingStageStartBlock = _grantFund.getScreeningStageEndBlock(startBlock) + 1; + vm.roll(fundingStageStartBlock + 100); + currentBlock = fundingStageStartBlock + 100; + + // set the list of function selectors to run + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = _standardHandler.fundingVote.selector; + selectors[1] = _standardHandler.updateSlate.selector; + + // ensure utility functions are excluded from the invariant runs + targetSelector(FuzzSelector({ + addr: address(_standardHandler), + selectors: selectors + })); + + uint256[] memory initialTopTenProposals = _grantFund.getTopTenProposals(_grantFund.getDistributionId()); + initialTopTenHash = keccak256(abi.encode(initialTopTenProposals)); + assertTrue(initialTopTenProposals.length > 0); + } + + function invariant_funding_stage() external useCurrentBlock { + _invariant_FS1_FS2_FS3(_grantFund, _standardHandler); + _invariant_FS4_FS5_FS6_FS7_FS8(_grantFund, _standardHandler); + } + + function invariant_call_summary() external useCurrentBlock { + uint24 distributionId = _grantFund.getDistributionId(); + + _logger.logCallSummary(); + _logger.logProposalSummary(); + _logger.logActorSummary(distributionId, true, false); + _logger.logFundingSummary(distributionId); + } + +} diff --git a/test/invariants/scenarios/MultipleDistributionInvariant.t.sol b/test/invariants/scenarios/MultipleDistributionInvariant.t.sol new file mode 100644 index 00000000..8fcdb7cc --- /dev/null +++ b/test/invariants/scenarios/MultipleDistributionInvariant.t.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; +import { SafeCast } from "@oz/utils/math/SafeCast.sol"; + +import { Maths } from "../../../src/grants/libraries/Maths.sol"; + +import { StandardTestBase } from "../base/StandardTestBase.sol"; +import { StandardHandler } from "../handlers/StandardHandler.sol"; +import { Handler } from "../handlers/Handler.sol"; + +contract MultipleDistributionInvariant is StandardTestBase { + + // run tests against all functions, having just started a distribution period + function setUp() public override { + super.setUp(); + + // set the list of function selectors to run + bytes4[] memory selectors = new bytes4[](8); + selectors[0] = _standardHandler.startNewDistributionPeriod.selector; + selectors[1] = _standardHandler.propose.selector; + selectors[2] = _standardHandler.screeningVote.selector; + selectors[3] = _standardHandler.fundingVote.selector; + selectors[4] = _standardHandler.updateSlate.selector; + selectors[5] = _standardHandler.execute.selector; + selectors[6] = _standardHandler.claimDelegateReward.selector; + selectors[7] = _standardHandler.roll.selector; + + // ensure utility functions are excluded from the invariant runs + targetSelector(FuzzSelector({ + addr: address(_standardHandler), + selectors: selectors + })); + + // update scenarioType to fast to have larger rolls + _standardHandler.setCurrentScenarioType(Handler.ScenarioType.Fast); + + vm.roll(block.number + 100); + currentBlock = block.number; + } + + function invariant_all() external useCurrentBlock { + // screening invariants + _invariant_SS1_SS3_SS4_SS5_SS6_SS7_SS8_SS10_SS11_P1_P2(_grantFund, _standardHandler); + _invariant_SS2_SS4_SS9(_grantFund, _standardHandler); + + // funding invariants + _invariant_FS1_FS2_FS3(_grantFund, _standardHandler); + _invariant_FS4_FS5_FS6_FS7_FS8(_grantFund, _standardHandler); + + // finalize invariants + _invariant_CS1_CS2_CS3_CS4_CS5_CS6_CS7(_grantFund, _standardHandler); + _invariant_ES1_ES2_ES3_ES4_ES5(_grantFund, _standardHandler); + _invariant_DR1_DR2_DR3_DR4_DR5(_grantFund, _standardHandler); + + // distribution period invariants + _invariant_DP1_DP2_DP3_DP4_DP5(_grantFund, _standardHandler); + _invariant_DP6(_grantFund, _standardHandler); + _invariant_T1_T2(_grantFund); + } + + function invariant_call_summary() external useCurrentBlock { + uint24 distributionId = _grantFund.getDistributionId(); + + _logger.logCallSummary(); + _logger.logTimeSummary(); + _logger.logProposalSummary(); + console.log("scenario type", uint8(_standardHandler.getCurrentScenarioType())); + + while (distributionId > 0) { + + _logger.logFundingSummary(distributionId); + _logger.logFinalizeSummary(distributionId); + _logger.logActorSummary(distributionId, true, true); + _logger.logActorDelegationRewards(distributionId); + + --distributionId; + } + } +} diff --git a/test/invariants/scenarios/ScreeningInvariant.t.sol b/test/invariants/scenarios/ScreeningInvariant.t.sol new file mode 100644 index 00000000..c7a100cf --- /dev/null +++ b/test/invariants/scenarios/ScreeningInvariant.t.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { console } from "@std/console.sol"; + +import { IGrantFund } from "../../../src/grants/interfaces/IGrantFund.sol"; + +import { StandardTestBase } from "../base/StandardTestBase.sol"; +import { StandardHandler } from "../handlers/StandardHandler.sol"; + +contract ScreeningInvariant is StandardTestBase { + + function setUp() public override { + super.setUp(); + + startDistributionPeriod(); + + // set the list of function selectors to run + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = _standardHandler.startNewDistributionPeriod.selector; + selectors[1] = _standardHandler.propose.selector; + selectors[2] = _standardHandler.screeningVote.selector; + + // ensure utility functions are excluded from the invariant runs + targetSelector(FuzzSelector({ + addr: address(_standardHandler), + selectors: selectors + })); + + } + + function invariant_screening_stage() external useCurrentBlock { + _invariant_SS1_SS3_SS4_SS5_SS6_SS7_SS8_SS10_SS11_P1_P2(_grantFund, _standardHandler); + _invariant_SS2_SS4_SS9(_grantFund, _standardHandler); + } + + function invariant_call_summary() external useCurrentBlock { + uint24 distributionId = _grantFund.getDistributionId(); + + _logger.logCallSummary(); + _logger.logProposalSummary(); + _logger.logActorSummary(distributionId, false, true); + } + +} diff --git a/test/invariants/test-invariant.sh b/test/invariants/test-invariant.sh new file mode 100755 index 00000000..b2515ef8 --- /dev/null +++ b/test/invariants/test-invariant.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -ex + +echo "Exporting environment variables" + +# export environment variables retrieved from user input +export SCENARIO=${1:-${SCENARIO}} +export LOGS_VERBOSITY=${2:-${LOGS_VERBOSITY}} +export NUM_ACTORS=${3:-${NUM_ACTORS}} +export NUM_PROPOSALS=${4:-${NUM_PROPOSALS}} +export PER_ADDRESS_TOKEN_REQ_CAP=${5:-${PER_ADDRESS_TOKEN_REQ_CAP}} + +echo "Running invariant test" + +# run invariant test +forge t --mc $SCENARIO diff --git a/test/unit/StandardFunding.t.sol b/test/unit/StandardFunding.t.sol index 4ddf6054..afbc1e10 100644 --- a/test/unit/StandardFunding.t.sol +++ b/test/unit/StandardFunding.t.sol @@ -2140,6 +2140,205 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { assertGe(gbc / 10, totalDelegationReward); } + function testFuzzScreeningStage(uint256 noOfVoters_, uint256 noOfProposals_, uint256 noOfScreeningVoteCast_) external { + + /******************************/ + /*** Screening stage fuzz ***/ + /******************************/ + + noOfVoters_ = bound(noOfVoters_, 1, 500); + noOfProposals_ = bound(noOfProposals_, 1, 50); + noOfScreeningVoteCast_ = bound(noOfScreeningVoteCast_, 200, 500); + + vm.roll(_startBlock + 20); + + // Initialize N voter addresses + address[] memory voters = _getVoters(noOfVoters_); + assertEq(voters.length, noOfVoters_); + + // Transfer random ajna tokens to all voters and self delegate + uint256[] memory votes = _setVotingPower(noOfVoters_, voters, _token, _tokenDeployer); + assertEq(votes.length, noOfVoters_); + + vm.roll(block.number + 100); + + _startDistributionPeriod(_grantFund); + + vm.roll(block.number + 100); + + // ensure user gets the vote for screening stage + for(uint i = 0; i < noOfVoters_; i++) { + assertEq(votes[i], _getScreeningVotes(_grantFund, voters[i])); + } + + uint24 distributionId = _grantFund.getDistributionId(); + + // submit N proposals + TestProposal[] memory proposals = _getProposals(noOfProposals_, _grantFund, _tokenHolder1, _token); + + // Random voter votes on a random proposal from all Proposals for random number of times + for(uint i = 0; i < noOfScreeningVoteCast_; i++) { + + // get random voter + uint256 randomVoterIndex = bound(i, 0, noOfVoters_ - 1); + address randomVoter = voters[randomVoterIndex]; + + // get random proposal to vote on + uint256 randomProposalIndex = _getRandomProposal(noOfProposals_); + uint256 randomProposalId = proposals[randomProposalIndex].proposalId; + + uint256 previousVoteCast = _grantFund.getScreeningVotesCast(distributionId, randomVoter); + + uint256 totalVoteAvailable = _getScreeningVotes(_grantFund, randomVoter) - previousVoteCast; + + // get random vote to cast based on available voting power + uint256 voteToCast = bound(totalVoteAvailable, 0, totalVoteAvailable); + + noOfVotesOnProposal[randomProposalId] += voteToCast; + + (,, uint256 beforeVoteReceived,,,) = _grantFund.getProposalInfo(randomProposalId); + + // vote on proposal if voteToCast is more than 0 + if (voteToCast > 0) _screeningVote(_grantFund, randomVoter, randomProposalId, voteToCast); + + // ensure that votes are added into screeningVotesCast accumulator + assertEq(_grantFund.getScreeningVotesCast(distributionId, randomVoter), previousVoteCast + voteToCast); + + (,, uint256 afterVoteReceived,,,) = _grantFund.getProposalInfo(randomProposalId); + + // ensure that votes are added into votesReceived accumulator + assertEq(afterVoteReceived, beforeVoteReceived + voteToCast); + } + + // calculate top 10 proposals based on total vote casted on each proposal + for(uint i = 0; i < noOfProposals_; i++) { + uint256 currentProposalId = proposals[i].proposalId; + uint256 votesOnCurrentProposal = noOfVotesOnProposal[currentProposalId]; + uint256 lengthOfArray = topTenProposalIds.length; + + // only add proposals having atleast a vote + if (votesOnCurrentProposal > 0) { + + // if there are less than 10 proposals in topTenProposalIds , add current proposals and sort topTenProposalIds based on Votes + if (lengthOfArray < 10) { + topTenProposalIds.push(currentProposalId); + + // ensure if there are more than 1 proposalId in topTenProposalIds to sort + if(topTenProposalIds.length > 1) { + _insertionSortProposalsByVotes(topTenProposalIds); + } + } + + // if there are 10 proposals in topTenProposalIds, check new proposal has more votes than the last proposal in topTenProposalIds + else if(noOfVotesOnProposal[topTenProposalIds[lengthOfArray - 1]] < votesOnCurrentProposal) { + + // remove last proposal with least no of vote in topTenProposalIds + topTenProposalIds.pop(); + + // add new proposal with more votes than last + topTenProposalIds.push(currentProposalId); + + // sort topTenProposalIds + _insertionSortProposalsByVotes(topTenProposalIds); + } + } + } + + // get top ten proposals from contract + uint256[] memory topTenProposalIdsFromContract = _grantFund.getTopTenProposals(distributionId); + + // ensure the no of proposals are correct + assertEq(topTenProposalIds.length, topTenProposalIdsFromContract.length); + + for (uint i = 0; i < topTenProposalIds.length; i++) { + // ensure that each proposal in topTenProposalIdsFromContract is correct + assertEq(topTenProposalIds[i], topTenProposalIdsFromContract[i]); + } + + } + + function testFuzzFundingStage(uint256 noOfVoters_, uint256 noOfProposals_, uint256 noOfFundingVoteCast_) external { + + noOfVoters_ = bound(noOfVoters_, 1, 500); + noOfProposals_ = bound(noOfProposals_, 1, 50); + noOfFundingVoteCast_ = bound(noOfFundingVoteCast_, 200, 500); + + vm.roll(_startBlock + 20); + + // Initialize N voter addresses + address[] memory voters = _getVoters(noOfVoters_); + + // Transfer random ajna tokens to all voters and self delegate + _setVotingPower(noOfVoters_, voters, _token, _tokenDeployer); + + vm.roll(block.number + 100); + + _startDistributionPeriod(_grantFund); + + vm.roll(block.number + 100); + + uint24 distributionId = _grantFund.getDistributionId(); + + // submit N proposals + TestProposal[] memory proposals = _getProposals(noOfProposals_, _grantFund, _tokenHolder1, _token); + + // Each voter votes on a random proposal from all Proposals + for(uint i = 0; i < noOfVoters_; i++) { + uint256 randomProposalIndex = _getRandomProposal(noOfProposals_); + + uint256 randomProposalId = proposals[randomProposalIndex].proposalId; + + _screeningVote(_grantFund, voters[i], randomProposalId, _getScreeningVotes(_grantFund, voters[i])); + } + + /******************************/ + /*** Funding stage fuzz ***/ + /******************************/ + + // skip to funding stage + vm.roll(block.number + 550_000); + + // get top ten proposals from contract + topTenProposalIds = _grantFund.getTopTenProposals(distributionId); + + // random voter votes random number of votes on random proposal + for(uint i = 0; i < noOfFundingVoteCast_; i++) { + + // get random voter + uint256 randomVoterIndex = bound(i, 0, noOfVoters_ - 1); + address randomVoter = voters[randomVoterIndex]; + + // get random proposal to vote on + uint256 randomProposalIndex = _getRandomProposal(topTenProposalIds.length); + uint256 randomProposalId = topTenProposalIds[randomProposalIndex]; + + (, uint256 beforeRemainingVotingPower,) = _grantFund.getVoterInfo(distributionId, randomVoter); + + // get random vote to cast based on available voting power + uint256 voteToCast = bound(beforeRemainingVotingPower, 0, beforeRemainingVotingPower); + + // get random support + uint8 support = (voteToCast % 2 == 0) ? voteYes: voteNo; + + (,,,, int256 beforeVoteReceived,) = _grantFund.getProposalInfo(randomProposalId); + + // vote on proposal if voteToCast is more than 0 + if (voteToCast != 0) _fundingVote(_grantFund, randomVoter, randomProposalId, support, int256(voteToCast)); + + (, uint256 afterRemainingVotingPower,) = _grantFund.getVoterInfo(distributionId, randomVoter); + + // ensure that voter voting power decreased + assertEq(afterRemainingVotingPower, beforeRemainingVotingPower - voteToCast); + + (,,,, int256 afterVoteReceived,) = _grantFund.getProposalInfo(randomProposalId); + + int256 expectedVoteReceived = (support == 1 ? beforeVoteReceived + int256(voteToCast) : beforeVoteReceived - int256(voteToCast)); + + // ensure quadratic vote received on proposal is updated + assertEq(afterVoteReceived, expectedVoteReceived); + } + } + // helper method that sort proposals based on votes on them function _insertionSortProposalsByVotes(uint256[] storage arr) internal { for (uint i = 1; i < arr.length; i++) { diff --git a/test/utils/GrantFundTestHelper.sol b/test/utils/GrantFundTestHelper.sol index bc4eb405..d3682cd2 100644 --- a/test/utils/GrantFundTestHelper.sol +++ b/test/utils/GrantFundTestHelper.sol @@ -67,6 +67,7 @@ abstract contract GrantFundTestHelper is Test { string description; uint256 totalTokensRequested; uint256 blockAtCreation; // block number of test proposal creation + uint256 blockAtExecution; // block number of proposal execution GeneratedTestProposalParams[] params; } @@ -235,7 +236,7 @@ abstract contract GrantFundTestHelper is Test { // return a TestProposal struct containing the state of a created proposal function _createTestProposal(uint24 distributionId_, uint256 proposalId_, address[] memory targets_, uint256[] memory values_, bytes[] memory calldatas_, string memory description) internal view returns (TestProposal memory proposal_) { (GeneratedTestProposalParams[] memory params, uint256 totalTokensRequested) = _getGeneratedTestProposalParamsFromParams(targets_, values_, calldatas_); - proposal_ = TestProposal(proposalId_, distributionId_, description, totalTokensRequested, block.number, params); + proposal_ = TestProposal(proposalId_, distributionId_, description, totalTokensRequested, block.number, 0, params); } /**