From fcad35c81a62d2805c6859c45e940d8bcaab0aaa Mon Sep 17 00:00:00 2001 From: David Laprade Date: Tue, 10 Dec 2024 17:15:31 -0500 Subject: [PATCH] Add FlexVotingClient Invariant Tests (#72) Add infrastructure to run invariant tests against the FlexVotingClient to ensure that accounting invariants hold. --- .github/workflows/ci.yml | 2 + foundry.toml | 2 +- test/FlexVotingClient.invariants.t.sol | 141 ++++++ test/FractionalGovernor.sol | 2 +- test/GovernorCountingFractional.t.sol | 8 +- test/handlers/FlexVotingClientHandler.sol | 370 ++++++++++++++ test/handlers/FlexVotingClientHandler.t.sol | 511 ++++++++++++++++++++ 7 files changed, 1030 insertions(+), 6 deletions(-) create mode 100644 test/FlexVotingClient.invariants.t.sol create mode 100644 test/handlers/FlexVotingClientHandler.sol create mode 100644 test/handlers/FlexVotingClientHandler.t.sol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39e3430..8224f9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: - name: Run coverage run: forge coverage --report summary --report lcov + env: + FOUNDRY_PROFILE: lite # override to reduce runtime # To ignore coverage for certain directories modify the paths in this step as needed. The # below default ignores coverage results for the test and script directories. Alternatively, diff --git a/foundry.toml b/foundry.toml index f1c8113..b85e8db 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,7 +7,7 @@ [profile.ci] fuzz = { runs = 5000 } - invariant = { runs = 1000 } + invariant = { runs = 500 } [profile.lite] fuzz = { runs = 50 } diff --git a/test/FlexVotingClient.invariants.t.sol b/test/FlexVotingClient.invariants.t.sol new file mode 100644 index 0000000..b78308e --- /dev/null +++ b/test/FlexVotingClient.invariants.t.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; +import {IGovernor} from "@openzeppelin/contracts/governance/Governor.sol"; +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; + +import {GovernorCountingFractional as GCF} from "src/GovernorCountingFractional.sol"; +import {IVotingToken} from "src/interfaces/IVotingToken.sol"; +import {IFractionalGovernor} from "src/interfaces/IFractionalGovernor.sol"; +import {MockFlexVotingClient} from "test/MockFlexVotingClient.sol"; +import {GovToken} from "test/GovToken.sol"; +import {FractionalGovernor} from "test/FractionalGovernor.sol"; +import {ProposalReceiverMock} from "test/ProposalReceiverMock.sol"; +import {FlexVotingClientHandler} from "test/handlers/FlexVotingClientHandler.sol"; + +contract FlexVotingInvariantSetup is Test { + MockFlexVotingClient flexClient; + GovToken token; + FractionalGovernor governor; + ProposalReceiverMock receiver; + FlexVotingClientHandler handler; + + function setUp() public { + token = new GovToken(); + vm.label(address(token), "token"); + + governor = new FractionalGovernor("Governor", IVotes(token)); + vm.label(address(governor), "governor"); + + flexClient = new MockFlexVotingClient(address(governor)); + vm.label(address(flexClient), "flexClient"); + + receiver = new ProposalReceiverMock(); + vm.label(address(receiver), "receiver"); + + handler = new FlexVotingClientHandler(token, governor, flexClient, receiver); + + // Proposal will underflow if we're on the zero block. + if (block.number == 0) vm.roll(1); + + bytes4[] memory selectors = new bytes4[](6); + selectors[0] = FlexVotingClientHandler.deposit.selector; + selectors[1] = FlexVotingClientHandler.propose.selector; + selectors[2] = FlexVotingClientHandler.expressVote.selector; + selectors[3] = FlexVotingClientHandler.castVote.selector; + selectors[4] = FlexVotingClientHandler.withdraw.selector; + selectors[5] = FlexVotingClientHandler.roll.selector; + + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + targetContract(address(handler)); + } +} + +contract FlexVotingInvariantTest is FlexVotingInvariantSetup { + // We want to make sure that things like this cannot happen: + // - user A deposits X + // - user A expresses FOR on proposal P + // - castVote is called for P, user A's votes are cast + // - stuff we can't imagine happens... + // - user A expresses again on proposal P + // - castVote is called for P, user A gets more votes through + function invariant_OneVotePerActorPerProposal() public view { + handler.callSummary(); + + uint256[] memory _proposals = handler.getProposals(); + address[] memory _voters = handler.getVoters(); + for (uint256 p; p < _proposals.length; p++) { + for (uint256 v; v < _voters.length; v++) { + address _voter = _voters[v]; + uint256 _proposal = _proposals[p]; + assertTrue(handler.ghost_actorExpressedVotes(_voter, _proposal) <= 1); + } + } + } + + // Flex client should not allow anyone to increase effective voting + // weight, i.e. cast voteWeight <= deposit amount. Example: + // - user A deposits 70 + // - user B deposits 30 + // - user A expresses FOR + // - user B does NOT express + // - castVote is called + // - 100 votes are cast FOR proposal + // - user A's effective vote weight increased from 70 to 100 + function invariant_VoteWeightCannotIncrease() public view { + handler.callSummary(); + + for (uint256 i; i < handler.proposalLength(); i++) { + uint256 _id = handler.proposal(i); + assert(handler.ghost_votesCast(_id) <= handler.ghost_depositsCast(_id)); + } + } + + // The flex client should not lend out more than it recieves. + function invariant_WithdrawalsDontExceedDepoists() public view { + handler.callSummary(); + + assertTrue(handler.ghost_depositSum() >= handler.ghost_withdrawSum()); + } + + function invariant_SumOfDepositsEqualsTotalBalanceCheckpoint() public { + handler.callSummary(); + + uint256 _checkpoint = block.number; + vm.roll(_checkpoint + 1); + assertEq( + flexClient.getPastTotalBalance(_checkpoint), + handler.ghost_depositSum() - handler.ghost_withdrawSum() + ); + + uint256 _sum; + address[] memory _depositors = handler.getActors(); + for (uint256 d; d < _depositors.length; d++) { + address _depositor = _depositors[d]; + _sum += flexClient.getPastRawBalance(_depositor, _checkpoint); + } + assertEq(flexClient.getPastTotalBalance(_checkpoint), _sum); + } + + function invariant_SumOfDepositsIsGTEProposalVotes() public view { + handler.callSummary(); + + uint256[] memory _proposals = handler.getProposals(); + for (uint256 p; p < _proposals.length; p++) { + uint256 _proposalId = _proposals[p]; + + (uint256 _againstVotes, uint256 _forVotes, uint256 _abstainVotes) = + governor.proposalVotes(_proposalId); + uint256 _totalVotesGov = _againstVotes + _forVotes + _abstainVotes; + + (_againstVotes, _forVotes, _abstainVotes) = flexClient.proposalVotes(_proposalId); + uint256 _totalVotesClient = _againstVotes + _forVotes + _abstainVotes; + + // The votes recorded in the governor and those in the client waiting to + // be cast should never exceed the total amount deposited. + assertTrue(handler.ghost_depositSum() >= _totalVotesClient + _totalVotesGov); + } + } +} diff --git a/test/FractionalGovernor.sol b/test/FractionalGovernor.sol index 54d26a0..1ab62b3 100644 --- a/test/FractionalGovernor.sol +++ b/test/FractionalGovernor.sol @@ -18,7 +18,7 @@ contract FractionalGovernor is GovernorVotes, GovernorCountingFractional { } function votingPeriod() public pure override returns (uint256) { - return 50_400; // 7 days assuming 12 second block times + return 50_400; // 50k blocks = 7 days assuming 12 second block times. } function exposed_quorumReached(uint256 _proposalId) public view returns (bool) { diff --git a/test/GovernorCountingFractional.t.sol b/test/GovernorCountingFractional.t.sol index 2c9dd40..fff1e07 100644 --- a/test/GovernorCountingFractional.t.sol +++ b/test/GovernorCountingFractional.t.sol @@ -723,14 +723,14 @@ contract GovernorCountingFractionalTest is Test { // These are the percentages of the total weight that will be cast with each // sequential vote, i.e. if _votePercentage1 is 25% then the first vote will // cast 25% of the voter's weight. - _votePercentage1 = bound(_votePercentage1, 0.0e18, 1.0e18); - _votePercentage2 = bound(_votePercentage2, 0.0e18, 1e18 - _votePercentage1); - _votePercentage3 = bound(_votePercentage3, 0.0e18, 1e18 - _votePercentage1 - _votePercentage2); + _votePercentage1 = _bound(_votePercentage1, 0.0e18, 1.0e18); + _votePercentage2 = _bound(_votePercentage2, 0.0e18, 1e18 - _votePercentage1); + _votePercentage3 = _bound(_votePercentage3, 0.0e18, 1e18 - _votePercentage1 - _votePercentage2); // Build the voter. Voter memory _voter; _voter.addr = _assumeAndLabelFuzzedVoter(_voterAddr); - _voter.weight = bound(_weight, MIN_VOTE_WEIGHT, MAX_VOTE_WEIGHT); + _voter.weight = _bound(_weight, MIN_VOTE_WEIGHT, MAX_VOTE_WEIGHT); _voter.voteSplit = _voteSplit; // Mint, delegate, and propose. diff --git a/test/handlers/FlexVotingClientHandler.sol b/test/handlers/FlexVotingClientHandler.sol new file mode 100644 index 0000000..87fe16d --- /dev/null +++ b/test/handlers/FlexVotingClientHandler.sol @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; +import {IGovernor} from "@openzeppelin/contracts/governance/Governor.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {GovernorCountingFractional as GCF} from "src/GovernorCountingFractional.sol"; +import {IVotingToken} from "src/interfaces/IVotingToken.sol"; +import {IFractionalGovernor} from "src/interfaces/IFractionalGovernor.sol"; +import {MockFlexVotingClient} from "test/MockFlexVotingClient.sol"; +import {GovToken} from "test/GovToken.sol"; +import {FractionalGovernor} from "test/FractionalGovernor.sol"; +import {ProposalReceiverMock} from "test/ProposalReceiverMock.sol"; + +contract FlexVotingClientHandler is Test { + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.UintSet; + + MockFlexVotingClient flexClient; + GovToken token; + FractionalGovernor governor; + ProposalReceiverMock receiver; + + uint128 public MAX_TOKENS = type(uint128).max; + + // The number of actors that must exist before we allow a proposal to be + // created. + uint8 public PROPOSAL_THRESHOLD = 70; + + EnumerableSet.UintSet internal proposals; + EnumerableSet.AddressSet internal voters; + EnumerableSet.AddressSet internal actors; + address internal currentActor; + + // Maps proposalIds to sets of users that have expressed votes but not yet had + // them cast. + mapping(uint256 => EnumerableSet.AddressSet) internal pendingVotes; + + // Maps proposalId to votes cast by this contract on the proposal. + mapping(uint256 => uint256) public ghost_votesCast; + + // Maps proposalId to aggregate deposit weight held by users whose expressed + // votes were cast. + mapping(uint256 => uint256) public ghost_depositsCast; + + struct CallCounts { + uint256 count; + } + + mapping(bytes32 => CallCounts) public calls; + + uint256 public ghost_depositSum; + uint256 public ghost_withdrawSum; + uint128 public ghost_mintedTokens; + mapping(address => uint128) public ghost_accountDeposits; + + // Maps actors to proposal ids to number of times they've expressed votes on the proposal. + // E.g. actorExpressedVotes[0xBEEF][42] == the number of times 0xBEEF + // expressed a voting preference on proposal 42. + mapping(address => mapping(uint256 => uint256)) public ghost_actorExpressedVotes; + + constructor( + GovToken _token, + FractionalGovernor _governor, + MockFlexVotingClient _client, + ProposalReceiverMock _receiver + ) { + token = _token; + flexClient = _client; + governor = _governor; + receiver = _receiver; + } + + modifier countCall(bytes32 key) { + calls[key].count++; + _; + } + + modifier maybeCreateActor(uint256 _seed) { + vm.assume(_validActorAddress(msg.sender)); + + if (actors.length() > 0 && _seed % 9 == 0) { + // Use an existing actor 10% of the time. + currentActor = _randAddress(actors, _seed); + } else { + // Create a new actor 90% of the time. + currentActor = msg.sender; + actors.add(msg.sender); + } + + _; + } + + modifier maybeCreateVoter() { + if (proposals.length() == 0) voters.add(currentActor); + _; + } + + modifier useActor(uint256 actorIndexSeed) { + currentActor = _randAddress(actors, actorIndexSeed); + _; + } + + modifier useVoter(uint256 _voterSeed) { + currentActor = _randAddress(voters, _voterSeed); + _; + } + + function hasPendingVotes(address _user, uint256 _proposalId) external view returns (bool) { + return pendingVotes[_proposalId].contains(_user); + } + + function _randAddress(EnumerableSet.AddressSet storage _addressSet, uint256 _seed) + internal + view + returns (address) + { + uint256 len = _addressSet.length(); + return len > 0 ? _addressSet.at(_seed % len) : address(0); + } + + function getProposals() external view returns (uint256[] memory) { + return proposals.values(); + } + + function getVoters() external view returns (address[] memory) { + return voters.values(); + } + + function getActors() external view returns (address[] memory) { + return actors.values(); + } + + function lastProposal() external view returns (uint256) { + if (proposals.length() == 0) return 0; + return proposals.at(proposals.length() - 1); + } + + function lastActor() external view returns (address) { + if (actors.length() == 0) return address(0); + return actors.at(actors.length() - 1); + } + + function lastVoter() external view returns (address) { + if (voters.length() == 0) return address(0); + return voters.at(voters.length() - 1); + } + + function _randProposal(uint256 _seed) internal view returns (uint256) { + uint256 len = proposals.length(); + return len > 0 ? proposals.at(_seed % len) : 0; + } + + // forgefmt: disable-start + function _validActorAddress(address _user) internal view returns (bool) { + return _user != address(0) && + _user != address(flexClient) && + _user != address(governor) && + _user != address(receiver) && + _user != address(token); + } + // forgefmt: disable-end + + function remainingTokens() public view returns (uint128) { + return MAX_TOKENS - ghost_mintedTokens; + } + + function proposal(uint256 _index) public view returns (uint256) { + return proposals.at(_index); + } + + function proposalLength() public view returns (uint256) { + return proposals.length(); + } + + function actorsLength() public view returns (uint256) { + return actors.length(); + } + + // Simulate the passage of time, which materially changes governance behavior. + function roll(uint256 _seed) external countCall("roll") { + vm.assume(proposals.length() > 0); + uint256 _blocks = _bound(_seed, 1, governor.votingPeriod() / 10); + vm.roll(block.number + _blocks); + } + + // NOTE: This always creates a new actor. + function deposit(uint208 _amount) + external + maybeCreateActor(_amount) + maybeCreateVoter + countCall("deposit") + { + vm.assume(remainingTokens() > 0); + _amount = uint208(_bound(_amount, 0, remainingTokens())); + + // Some actors won't have the tokens they need. This is deliberate. + if (_amount <= remainingTokens()) { + token.exposed_mint(currentActor, _amount); + ghost_mintedTokens += uint128(_amount); + } + + vm.startPrank(currentActor); + // NOTE: We're pre-approving every deposit. + token.approve(address(flexClient), uint256(_amount)); + flexClient.deposit(_amount); + vm.stopPrank(); + + ghost_depositSum += _amount; + ghost_accountDeposits[currentActor] += uint128(_amount); + } + + // NOTE: We restrict withdrawals to addresses that have balances. + function withdraw(uint256 _userSeed, uint208 _amount) + external + useActor(_userSeed) + countCall("withdraw") + { + // NOTE: We limit withdrawals to the account's deposit balance. + _amount = uint208(_bound(_amount, 0, ghost_accountDeposits[currentActor])); + + vm.startPrank(currentActor); + flexClient.withdraw(_amount); + vm.stopPrank(); + + ghost_withdrawSum += _amount; + ghost_accountDeposits[currentActor] -= uint128(_amount); + } + + function propose(string memory _proposalName) + external + countCall("propose") + returns (uint256 _proposalId) + { + // Require there to be depositors. + if (actors.length() < PROPOSAL_THRESHOLD) return 0; + + // Don't dilute the pool of proposals. + if (this.proposalLength() > 4) return 0; + + // Create a proposal + bytes memory receiverCallData = abi.encodeWithSignature("mockReceiverFunction()"); + address[] memory targets = new address[](1); + uint256[] memory values = new uint256[](1); + bytes[] memory calldatas = new bytes[](1); + targets[0] = address(receiver); + values[0] = 0; // No ETH will be sent. + calldatas[0] = receiverCallData; + + // Submit the proposal. + vm.prank(msg.sender); + _proposalId = governor.propose(targets, values, calldatas, _proposalName); + proposals.add(_proposalId); + + // Roll the clock to get voting started. + vm.roll(governor.proposalSnapshot(_proposalId) + 1); + } + + // We only allow users to express on proposals created after they had + // deposits. Should we let other users try to express too? + function expressVote(uint256 _proposalSeed, uint8 _support, uint256 _userSeed) + external + useVoter(_userSeed) + countCall("expressVote") + returns (address _actor) + { + _actor = currentActor; + if (proposals.length() == 0) return (_actor); + + // NOTE: We don't allow people to try to vote with bogus support types. + _support = uint8( + _bound(uint256(_support), uint256(type(GCF.VoteType).min), uint256(type(GCF.VoteType).max)) + ); + uint256 _proposalId = _randProposal(_proposalSeed); + vm.startPrank(currentActor); + flexClient.expressVote(_proposalId, _support); + vm.stopPrank(); + + pendingVotes[_proposalId].add(currentActor); + + ghost_actorExpressedVotes[currentActor][_proposalId] += 1; + } + + struct CastVoteVars { + uint256 initAgainstVotes; + uint256 initForVotes; + uint256 initAbstainVotes; + uint256 newAgainstVotes; + uint256 newForVotes; + uint256 newAbstainVotes; + uint256 voteDelta; + uint256 aggDepositWeight; + } + + function castVote(uint256 _proposalId) external countCall("castVote") { + // If someone tries to castVotes when there is no proposal it just reverts. + if (proposals.length() == 0) return; + + CastVoteVars memory _vars; + + _proposalId = _randProposal(_proposalId); + + (_vars.initAgainstVotes, _vars.initForVotes, _vars.initAbstainVotes) = + governor.proposalVotes(_proposalId); + + vm.startPrank(msg.sender); + flexClient.castVote(_proposalId); + vm.stopPrank(); + + (_vars.newAgainstVotes, _vars.newForVotes, _vars.newAbstainVotes) = + governor.proposalVotes(_proposalId); + + // The voters who just had votes cast for them. + EnumerableSet.AddressSet storage _voters = pendingVotes[_proposalId]; + + // The aggregate voting weight just cast. + _vars.voteDelta = (_vars.newAgainstVotes + _vars.newForVotes + _vars.newAbstainVotes) + - (_vars.initAgainstVotes + _vars.initForVotes + _vars.initAbstainVotes); + ghost_votesCast[_proposalId] += _vars.voteDelta; + + // The aggregate deposit weight just cast. + for (uint256 i; i < _voters.length(); i++) { + address _voter = _voters.at(i); + // We need deposits less withdrawals for the user AT proposal time. + _vars.aggDepositWeight += + flexClient.getPastRawBalance(_voter, governor.proposalSnapshot(_proposalId)); + } + ghost_depositsCast[_proposalId] += _vars.aggDepositWeight; + + // Delete the pending votes. + EnumerableSet.AddressSet storage set = pendingVotes[_proposalId]; + // We need to iterate backwards b/c set.remove changes order. + for (uint256 i = set.length(); i > 0; i--) { + set.remove(set.at(i - 1)); + } + } + + function callSummary() external view { + console2.log("\nCall summary:"); + console2.log("-------------------"); + console2.log("deposit:", calls["deposit"].count); + console2.log("withdraw:", calls["withdraw"].count); + console2.log("expressVote:", calls["expressVote"].count); + console2.log("castVote:", calls["castVote"].count); + console2.log("propose:", calls["propose"].count); + console2.log("roll:", calls["roll"].count); + console2.log("-------------------"); + console2.log("actor count:", actors.length()); + console2.log("voter count:", voters.length()); + console2.log("proposal count:", proposals.length()); + console2.log("amount deposited:", ghost_depositSum); + console2.log("amount withdrawn:", ghost_withdrawSum); + console2.log("amount remaining:", remainingTokens()); + console2.log("-------------------"); + for (uint256 i; i < proposals.length(); i++) { + uint256 _proposalId = proposals.at(i); + (uint256 _againstVotes, uint256 _forVotes, uint256 _abstainVotes) = + governor.proposalVotes(_proposalId); + console2.log("proposal", i); + console2.log(" forVotes ", _forVotes); + console2.log(" againstVotes", _againstVotes); + console2.log(" abstainVotes", _abstainVotes); + console2.log(" votesCast ", ghost_votesCast[_proposalId]); + console2.log(" depositsCast", ghost_depositsCast[_proposalId]); + } + console2.log("-------------------"); + } +} diff --git a/test/handlers/FlexVotingClientHandler.t.sol b/test/handlers/FlexVotingClientHandler.t.sol new file mode 100644 index 0000000..5616659 --- /dev/null +++ b/test/handlers/FlexVotingClientHandler.t.sol @@ -0,0 +1,511 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {FlexVotingInvariantSetup} from "test/FlexVotingClient.invariants.t.sol"; +import {FlexVotingClient as FVC} from "src/FlexVotingClient.sol"; +import {GovernorCountingFractional as GCF} from "src/GovernorCountingFractional.sol"; + +contract FlexVotingClientHandlerTest is FlexVotingInvariantSetup { + // Amounts evenly divisible by 9 do not create new users. + uint256 MAGIC_NUMBER = 9; + + function _bytesToUser(bytes memory _entropy) internal pure returns (address) { + return address(uint160(uint256(keccak256(_entropy)))); + } + + function _makeActors(uint256 _seed, uint256 _n) internal { + for (uint256 i; i < _n; i++) { + address _randUser = _bytesToUser(abi.encodePacked(_seed, _n, i)); + uint208 _amount = uint208(bound(_seed, 1, handler.remainingTokens() / _n)); + // We want to create new users. + if (_amount % MAGIC_NUMBER == 0) _amount += 1; + vm.startPrank(_randUser); + handler.deposit(_amount); + vm.stopPrank(); + } + } + + function _validVoteType(uint8 _seed) internal pure returns (uint8) { + return uint8( + _bound(uint256(_seed), uint256(type(GCF.VoteType).min), uint256(type(GCF.VoteType).max)) + ); + } +} + +contract Propose is FlexVotingClientHandlerTest { + function testFuzz_multipleProposals(uint256 _seed) public { + // No proposal is created if there are no actors. + assertEq(handler.proposalLength(), 0); + handler.propose("capital idea 'ol chap"); + assertEq(handler.proposalLength(), 0); + + // A critical mass of actors is required. + _makeActors(_seed, handler.PROPOSAL_THRESHOLD()); + handler.propose("capital idea 'ol chap"); + assertEq(handler.proposalLength(), 1); + + // We cap the number of proposals. + handler.propose("we should do dis"); + assertEq(handler.proposalLength(), 2); + handler.propose("yuge, beautiful proposal"); + assertEq(handler.proposalLength(), 3); + handler.propose("a modest proposal"); + assertEq(handler.proposalLength(), 4); + handler.propose("yessiree bob"); + assertEq(handler.proposalLength(), 5); + + // After 5 proposals we stop adding new ones. + // The call doesn't revert. + handler.propose("this will be a no-op"); + assertEq(handler.proposalLength(), 5); + } +} + +contract Deposit is FlexVotingClientHandlerTest { + function testFuzz_passesDepositsToClient(uint128 _amount) public { + address _user = _bytesToUser(abi.encodePacked(_amount)); + assertEq(token.balanceOf(address(flexClient)), 0); + assertEq(flexClient.deposits(_user), 0); + + vm.startPrank(_user); + vm.expectCall(address(flexClient), abi.encodeCall(flexClient.deposit, _amount)); + vm.expectCall(address(token), abi.encodeCall(token.approve, (address(flexClient), _amount))); + handler.deposit(_amount); + vm.stopPrank(); + + assertEq(flexClient.deposits(_user), _amount); + assertEq(handler.ghost_depositSum(), _amount); + assertEq(token.balanceOf(address(flexClient)), _amount); + } + + function testFuzz_mintsNeededTokens(uint128 _amount) public { + address _user = _bytesToUser(abi.encodePacked(_amount)); + + assertEq(handler.ghost_mintedTokens(), 0); + vm.expectCall(address(token), abi.encodeCall(token.exposed_mint, (_user, _amount))); + + vm.startPrank(_user); + handler.deposit(_amount); + vm.stopPrank(); + + assertEq(handler.ghost_mintedTokens(), _amount); + } + + function testFuzz_tracksTheCaller(uint128 _amount) public { + address _user = _bytesToUser(abi.encodePacked(_amount)); + + assertEq(handler.lastActor(), address(0)); + + vm.startPrank(_user); + handler.deposit(_amount); + vm.stopPrank(); + + assertEq(handler.lastActor(), _user); + } + + function testFuzz_tracksVoters(uint128 _amountA, uint128 _amountB) public { + address _userA = makeAddr("userA"); + uint128 _reservedForOtherActors = 1e24; + uint128 _remaining = handler.MAX_TOKENS() - _reservedForOtherActors; + _amountA = uint128(bound(_amountA, 1, _remaining - 1)); + _amountB = uint128(bound(_amountB, 1, _remaining - _amountA)); + if (_amountA % MAGIC_NUMBER == 0) _amountA -= 1; + if (_amountB % MAGIC_NUMBER == 0) _amountB -= 1; + + assertEq(handler.lastProposal(), 0); + assertEq(handler.lastVoter(), address(0)); + + vm.startPrank(_userA); + handler.deposit(_amountA); + vm.stopPrank(); + + // Pre-proposal + assertEq(handler.lastActor(), _userA); + assertEq(handler.lastVoter(), _userA); + + // Create a proposal. + _makeActors(_remaining / handler.PROPOSAL_THRESHOLD(), handler.PROPOSAL_THRESHOLD()); + uint256 _proposalId = handler.propose("jolly good idea"); + assertEq(handler.lastProposal(), _proposalId); + + // New depositors are no longer considered "voters". + address _userB = makeAddr("userB"); + vm.startPrank(_userB); + handler.deposit(_amountB); + vm.stopPrank(); + assertEq(handler.lastActor(), _userB); + assertNotEq(handler.lastVoter(), _userB); + } + + function testFuzz_incrementsDepositSum(uint128 _amount) public { + address _user = _bytesToUser(abi.encodePacked(_amount)); + vm.assume(flexClient.deposits(_user) == 0); + assertEq(handler.ghost_depositSum(), 0); + + vm.startPrank(_user); + handler.deposit(_amount); + assertEq(handler.ghost_depositSum(), _amount); + assertEq(handler.ghost_accountDeposits(_user), _amount); + vm.stopPrank(); + } + + function testFuzz_capsDepositsAtTokenMax(uint208 _amount) public { + address _user = _bytesToUser(abi.encodePacked(_amount)); + vm.assume(flexClient.deposits(_user) == 0); + assertEq(handler.ghost_depositSum(), 0); + + vm.startPrank(_user); + handler.deposit(_amount); + vm.stopPrank(); + + if (_amount > handler.MAX_TOKENS()) assert(flexClient.deposits(_user) < _amount); + + assert(handler.ghost_mintedTokens() <= handler.MAX_TOKENS()); + } +} + +contract Withdraw is FlexVotingClientHandlerTest { + function testFuzz_withdraw(uint208 _amount) public { + address _user = _bytesToUser(abi.encodePacked(_amount)); + _amount = uint208(bound(_amount, 1, handler.MAX_TOKENS())); + + // There's only one actor, so seed doesn't matter. + uint256 _userSeed = uint256(_amount); + + vm.startPrank(_user); + handler.deposit(_amount); + vm.stopPrank(); + + assertEq(token.balanceOf(_user), 0); + uint208 _initAmount = _amount / 3; + + // Deposits can be withdrawn from the flexClient through the handler. + vm.startPrank(_user); + vm.expectCall(address(flexClient), abi.encodeCall(flexClient.withdraw, _initAmount)); + handler.withdraw(_userSeed, _initAmount); + vm.stopPrank(); + + assertEq(handler.ghost_depositSum(), _amount); + assertEq(handler.ghost_withdrawSum(), _initAmount); + assertEq(handler.ghost_accountDeposits(_user), _amount - _initAmount); + assertEq(flexClient.deposits(_user), _amount - _initAmount); + assertEq(token.balanceOf(_user), _initAmount); + + vm.startPrank(_user); + handler.withdraw(_userSeed, _amount - _initAmount); + vm.stopPrank(); + + assertEq(handler.ghost_withdrawSum(), _amount); + assertEq(handler.ghost_accountDeposits(_user), 0); + assertEq(token.balanceOf(_user), _amount); + assertEq(flexClient.deposits(_user), 0); + } + + function testFuzz_amountIsBounded(uint208 _amount) public { + address _user = _bytesToUser(abi.encodePacked(_amount)); + // There's only one actor, so seed doesn't matter. + uint256 _userSeed = uint256(_amount); + + // Try to withdraw a crazy amount, it won't revert. + vm.startPrank(_user); + handler.deposit(_amount); + handler.withdraw(_userSeed, type(uint208).max); + vm.stopPrank(); + + assert(token.balanceOf(_user) <= _amount); + assertTrue(flexClient.deposits(_user) <= _amount); + } +} + +contract ExpressVote is FlexVotingClientHandlerTest { + function testFuzz_hasInternalAccounting( + uint256 _userSeed, + uint256 _proposalId, + uint8 _voteType, + uint128 _amount + ) public { + address _user = _bytesToUser(abi.encodePacked(_userSeed)); + // We need actors to cross the proposal threshold on expressVote. + uint128 _actorCount = handler.PROPOSAL_THRESHOLD() - 1; + uint128 _reserved = _actorCount * 1e24; // Tokens for other actors. + _amount = uint128(bound(_amount, 1, handler.MAX_TOKENS() - _reserved)); + if (_amount % MAGIC_NUMBER == 0) _amount -= 1; + _voteType = _validVoteType(_voteType); + + _makeActors(_reserved / _actorCount, _actorCount); + + vm.startPrank(_user); + handler.deposit(_amount); + _actorCount += 1; // Deposit adds an actor/voter. + + // There's no proposal, so this should be a no-op. + handler.expressVote(_proposalId, _voteType, _userSeed); + assertFalse(handler.hasPendingVotes(_user, _proposalId)); + assertEq(handler.ghost_actorExpressedVotes(_user, _proposalId), 0); + (uint256 _againstVotes, uint256 _forVotes, uint256 _abstainVotes) = + flexClient.proposalVotes(_proposalId); + assertEq(_againstVotes, 0); + assertEq(_forVotes, 0); + assertEq(_abstainVotes, 0); + + _proposalId = handler.propose("a beautiful proposal"); + + // This seed that allows us to force use of the voter we want. + uint256 _seedForVoter = _actorCount - 1; // e.g. 89 % 90 = 89 + + // Finally, we can call expressVote. + vm.expectCall( + address(flexClient), abi.encodeCall(flexClient.expressVote, (_proposalId, _voteType)) + ); + handler.expressVote(_proposalId, _voteType, _seedForVoter); + assertTrue(handler.hasPendingVotes(_user, _proposalId)); + assertEq(handler.ghost_actorExpressedVotes(_user, _proposalId), 1); + + // The vote preference should have been recorded by the client. + (_againstVotes, _forVotes, _abstainVotes) = flexClient.proposalVotes(_proposalId); + if (_voteType == uint8(GCF.VoteType.Against)) assertEq(_amount, _againstVotes); + if (_voteType == uint8(GCF.VoteType.For)) assertEq(_amount, _forVotes); + if (_voteType == uint8(GCF.VoteType.Abstain)) assertEq(_amount, _abstainVotes); + + // The user should not be able to vote again. + vm.expectRevert(FVC.FlexVotingClient__AlreadyVoted.selector); + handler.expressVote(_proposalId, _voteType, _seedForVoter); + + vm.stopPrank(); + } +} + +contract CastVote is FlexVotingClientHandlerTest { + function testFuzz_doesNotRequireProposalToExist(uint256 _proposalSeed) public { + assertEq(handler.lastProposal(), 0); + // Won't revert even with no votes cast. + // This avoids uninteresting reverts during invariant runs. + handler.castVote(_proposalSeed); + } + + function testFuzz_passesThroughToFlexClient( + uint256 _proposalSeed, + uint256 _userSeed, + uint8 _voteType + ) public { + _voteType = _validVoteType(_voteType); + // We need actors to cross the proposal threshold on expressVote. + uint128 _actorCount = handler.PROPOSAL_THRESHOLD(); + uint128 _voteSize = 1e24; + uint128 _reserved = _actorCount * _voteSize; // Tokens for actors. + _makeActors(_reserved / _actorCount, _actorCount); + + uint256 _proposalId = handler.propose("a preposterous proposal"); + + assertFalse(handler.hasPendingVotes(makeAddr("joe"), _proposalId)); + + address _actor = handler.expressVote(_proposalSeed, _voteType, _userSeed); + assertTrue(handler.hasPendingVotes(_actor, _proposalId)); + + vm.expectCall(address(flexClient), abi.encodeCall(flexClient.castVote, _proposalId)); + handler.castVote(_proposalSeed); + + // The actor should no longer have pending votes. + assertFalse(handler.hasPendingVotes(_actor, _proposalId)); + + // The vote preference should have been sent to the Governor. + (uint256 _againstVotes, uint256 _forVotes, uint256 _abstainVotes) = + governor.proposalVotes(_proposalId); + if (_voteType == uint8(GCF.VoteType.Against)) assertEq(_voteSize, _againstVotes); + if (_voteType == uint8(GCF.VoteType.For)) assertEq(_voteSize, _forVotes); + if (_voteType == uint8(GCF.VoteType.Abstain)) assertEq(_voteSize, _abstainVotes); + } + + function testFuzz_aggregatesVotes( + uint256 _proposalSeed, + uint128 _weightA, + uint128 _weightB, + uint8 _voteTypeA, + uint8 _voteTypeB + ) public { + // We need actors to cross the proposal threshold on expressVote. + uint128 _actorCount = handler.PROPOSAL_THRESHOLD(); + uint128 _voteSize = 1e24; + uint128 _reserved = _actorCount * _voteSize; // Tokens for actors. + _makeActors(_voteSize, _actorCount); + + _weightA = uint128(bound(_weightA, 1, handler.MAX_TOKENS() - _reserved - 1)); + _weightB = uint128(bound(_weightB, 1, handler.MAX_TOKENS() - _reserved - _weightA)); + if (_weightA % MAGIC_NUMBER == 0) _weightA -= 1; + if (_weightB % MAGIC_NUMBER == 0) _weightB -= 1; + + address _alice = makeAddr("alice"); + vm.startPrank(_alice); + handler.deposit(_weightA); + vm.stopPrank(); + + address _bob = makeAddr("bob"); + vm.startPrank(_bob); + handler.deposit(_weightB); + vm.stopPrank(); + + uint256 _proposalId = handler.propose("a preposterous proposal"); + + assertFalse(handler.hasPendingVotes(_alice, _proposalId)); + assertFalse(handler.hasPendingVotes(_bob, _proposalId)); + + // The seeds that allow us to force use of the voter we want. + uint256 _totalActors = _actorCount + 2; // Plus alice and bob. + uint256 _seedForBob = _totalActors - 1; // Bob was added last. + uint256 _seedForAlice = _totalActors - 2; // Alice is second to last. + + // _proposalSeed doesn't matter because there's only one proposal. + _voteTypeA = _validVoteType(_voteTypeA); + handler.expressVote(_proposalSeed, _voteTypeA, _seedForAlice); + assertTrue(handler.hasPendingVotes(_alice, _proposalId)); + + _voteTypeB = _validVoteType(_voteTypeB); + handler.expressVote(_proposalSeed, _voteTypeB, _seedForBob); + assertTrue(handler.hasPendingVotes(_bob, _proposalId)); + + // No votes have been cast yet. + assertEq(handler.ghost_votesCast(_proposalId), 0); + + // _proposalSeed doesn't matter because there's only one proposal. + handler.castVote(_proposalSeed); + + // The actors should no longer have pending votes. + assertFalse(handler.hasPendingVotes(_alice, _proposalId)); + assertFalse(handler.hasPendingVotes(_bob, _proposalId)); + + assertEq(handler.ghost_votesCast(_proposalId), _weightA + _weightB); + assertEq(handler.ghost_depositsCast(_proposalId), _weightA + _weightB); + } + + function testFuzz_aggregatesVotesAcrossCasts( + uint256 _proposalSeed, + uint128 _weightA, + uint128 _weightB, + uint8 _voteTypeA, + uint8 _voteTypeB + ) public { + // We need actors to cross the proposal threshold on expressVote. + uint128 _actorCount = handler.PROPOSAL_THRESHOLD(); + uint128 _voteSize = 1e24; + uint128 _reserved = _actorCount * _voteSize; // Tokens for actors. + _makeActors(_reserved / _actorCount, _actorCount); + + _weightA = uint128(bound(_weightA, 1, handler.MAX_TOKENS() - _reserved - 1)); + _weightB = uint128(bound(_weightB, 1, handler.MAX_TOKENS() - _reserved - _weightA)); + if (_weightA % MAGIC_NUMBER == 0) _weightA -= 1; + if (_weightB % MAGIC_NUMBER == 0) _weightB -= 1; + + address _alice = makeAddr("alice"); + vm.startPrank(_alice); + handler.deposit(_weightA); + vm.stopPrank(); + + address _bob = makeAddr("bob"); + vm.startPrank(_bob); + handler.deposit(_weightB); + vm.stopPrank(); + + uint256 _proposalId = handler.propose("a preposterous proposal"); + + assertFalse(handler.hasPendingVotes(_alice, _proposalId)); + assertFalse(handler.hasPendingVotes(_bob, _proposalId)); + + // The seeds that allow us to force use of the voter we want. + uint256 _totalActors = _actorCount + 2; // Plus alice and bob. + uint256 _seedForBob = _totalActors - 1; // Bob was added last. + uint256 _seedForAlice = _totalActors - 2; // Alice is second to last. + + // Now alice expresses her voting preference. + _voteTypeA = _validVoteType(_voteTypeA); + handler.expressVote(_proposalSeed, _voteTypeA, _seedForAlice); + assertTrue(handler.hasPendingVotes(_alice, _proposalId)); + + handler.castVote(_proposalSeed); + + assertEq(handler.ghost_votesCast(_proposalId), _weightA); + assertEq(handler.ghost_depositsCast(_proposalId), _weightA); + assertFalse(handler.hasPendingVotes(_alice, _proposalId)); + + // Now bob expresses his voting preference. + _voteTypeB = _validVoteType(_voteTypeB); + handler.expressVote(_proposalSeed, _voteTypeB, _seedForBob); + assertTrue(handler.hasPendingVotes(_bob, _proposalId)); + + handler.castVote(_proposalSeed); + + assertEq(handler.ghost_votesCast(_proposalId), _weightA + _weightB); + assertEq(handler.ghost_depositsCast(_proposalId), _weightA + _weightB); + assertFalse(handler.hasPendingVotes(_bob, _proposalId)); + } + // Aggregates deposit weight via ghost_depositsCast. + // - user A deposits 70 + // - user B deposits 30 + // - user A withdraws 30 + // - proposal is made + // - user A expressesVote + // - user B does NOT express + // - the contract has 70 weight to vote with, but we want to make sure it + // doesn't vote with all of it + // - castVote is called + // - ghost_VotesCast should = 40 <-- checks at the governor level + // - ghost_DepositsCast should = 40 <-- checks at the client level + + function testFuzz_tracksDepositsCast( + uint256 _proposalSeed, + uint128 _weightA, + uint128 _weightB, + uint8 _voteTypeA + ) public { + // We need actors to cross the proposal threshold on expressVote. + uint128 _actorCount = handler.PROPOSAL_THRESHOLD(); + uint128 _voteSize = 1e24; + uint128 _reserved = _actorCount * _voteSize; // Tokens for actors. + _makeActors(_reserved / _actorCount, _actorCount); + + // The seeds that allow us to force use of the voter we want. + uint256 _totalActors = _actorCount + 2; // Plus alice and bob. + uint256 _seedForAlice = _totalActors - 2; // Alice is second to last. + + // User B needs to have less weight than User A. + uint128 _remainingTokens = handler.MAX_TOKENS() - _reserved; + _weightA = uint128(bound(_weightA, (_remainingTokens / 2) + 1, _remainingTokens - 1)); + _weightB = uint128(bound(_weightB, 1, _remainingTokens - _weightA)); + if (_weightA % MAGIC_NUMBER == 0) _weightA -= 1; + if (_weightB % MAGIC_NUMBER == 0) _weightB -= 1; + + address _alice = makeAddr("alice"); + vm.startPrank(_alice); + handler.deposit(_weightA); + vm.stopPrank(); + + address _bob = makeAddr("bob"); + vm.startPrank(_bob); + handler.deposit(_weightB); + vm.stopPrank(); + + // Before anything is proposed, Alice withdraws equal to *Bob's* balance. + vm.startPrank(_alice); + handler.withdraw(_seedForAlice, _weightB); + vm.stopPrank(); + + uint256 _proposalId = handler.propose("a party"); + + // Now Alice expresses her voting preference. + // Bob does not express a preference. + _voteTypeA = _validVoteType(_voteTypeA); + handler.expressVote(_proposalSeed, _voteTypeA, _seedForAlice); + assertTrue(handler.hasPendingVotes(_alice, _proposalId)); + + // Votes are cast. + handler.castVote(_proposalSeed); + + // Bob's weight should not have been used by Alice to vote.. + assertEq(handler.ghost_votesCast(_proposalId), _weightA - _weightB); + assertEq(handler.ghost_depositsCast(_proposalId), _weightA - _weightB); + + // TODO There is no way to make ghost_votesCast come apart from + // ghost_depositsCast in tests, so it's not clear they are tracking + // something different. + } +}