From bcbfbfa16c2ec98e64cd1f2f2f55a134baf3dbf6 Mon Sep 17 00:00:00 2001 From: Elena Date: Wed, 22 Mar 2023 15:03:05 +0200 Subject: [PATCH] Decentralised governance of 0x protocol and treasury (#641) * Install open zeppelin contracts * Init foundry in governance * Add wrapped ZRX token * Add governance contracts testing to CI * Set optimizer runs to default * Upgrade to patched version of openzeppelin/contracts * Test stakingakng / unwrapping ZRX * Init npm package * Lint fix, removing lib from gitignore * Add openzeppelin contracts git submodule for foundry * Add vanilla governor contract * Fix reference paths to imported packages * Temporarily switch to using a mocked version of ZRX * Ignore foundry's lib in link checker * Fix a conflict in gitignore between forge lib adn built lib * Upload governance code coverage report to coveralls * Flesh out test scenarios for wrapping/unwrapping * Add basic ERC20 name and symbol tests * Wire in basic timelock controller and governor test setup * Test basic governor properties * Add basic voting power delegation tests * Add proposal execution happy path test * Split ERC20Votes logic between wrapped token and ZeroExVotes contracts * Exclude BaseTest from coverage in coveralls * Add protocol specific governor with produciton governance settings * Add a dedicated instance for the treasury governor This is currently using the default 1 token 1 vote mechanism but will be migrated * Add test for updating governance settings for voting delay, voting period and proposal threshold * Create seperate timelock contract instance for treasury and protocol * Test updating the timlock min delay * Set timelock delay to 2 days for protocol and 1 sec for treasury * Remove timelock from treasury governor * Refactor _checkpointsLookup to return entire Checkpoint instad of just number of votes * Update the totalSupply checkpoints updating logic * Quadratic voting power transfers and delegations * Fix workflow yaml * Initialise ZeroExVotes behind a ERC1967Proxy Test it cannot be reinitialised * Remove obsoleted console.logs from test * Storage pack Checkpoint enum * Remove keeping track of total balances for voting * Switch to using the foundry artifact in test * Fix rebase issue * Add timelock control over the treasury governor * Add test for wrapped token transfer * Emit separate events for changing linear and quadratic voting power * Add the ability to cancel a proposal * Limit the governors' cancel function to security council only * Eject security council after a proposal is cancelled * Add ability for governance to set the security council * Merge the governors test suites into one reusable set of tests * Add an empty test function to base test contract to remove it from coverage reports. Fudge but no other way to ignore it in report * Security council can rollback protocol upgrades * Upgrade to solidity 0.8.19 * Move IZeroExGovernor to src * Abstract Security council interface into its own * Emit events when assigning and ejecting the security council * Use a cast to bytes4 instead of LibBytes Co-authored-by: duncancmt <1207590+duncancmt@users.noreply.github.com> * Writing total supply checkpoints and setup of quorum percentage of quadratic total supply for treasure governor * Add test for transferring tokens when delegating * Rename IZeroExSecurityCouncil to ISecurityCouncil * Add security council restrictions to governors * Remove obsolete overflow check * Improve test coverage * Upgrade open-zeppelin contracts to 4.8.2 * Test delegation by signature * Test non security council requests to rollback protocol changes cannot be executed * Better revert messages * Test correct interfaces are supported * Remove obsoleted funciton * Further test delegation by signature scenario * Split the delegation functionality tests * Add test for initialisation of voting contract * Add test for reading checkpoints * Update code comments * Fix compilation warnings * Run smt checker * Add checkpoint tests * Rename parameter in moveEntireVotingPower to match the one in movePartialVotingPower * Switch moveEntireVotingPower to a more generic moveVotingPower implementation as in the open-zeppelin contracts * Install foundry earlier in CI * Switch movePartialVotingPower to the generic moveVotingPower implementation * Write totalSupplyCheckpoints via the generic _writeCheckpoint * Add threshold for quadratic voting power * Remove autoinserted code by OZ * Add openzeppelin/contracts-upgradable * Add initializable base to Voting contract * Fix terminogy error in natspec * Fix code comment * Remove obsoleted overrides and add a missing modifier to moveVotingPower * Remove amount check Co-authored-by: duncancmt <1207590+duncancmt@users.noreply.github.com> * Fix a calculation error and clean tests * Update thresholds for treasury governor * Fix testShouldNotBeAbleToDelegateWithSignatureAfterExpiry * Update from @duncancmt without "memory-safe" the IR optimizer produces significantly worse code and it disables the stack limit evader Co-authored-by: duncancmt <1207590+duncancmt@users.noreply.github.com> * Add onlyProxy to initializer * Fix quadratic voting weight base * Rename voting parameter for clarity * Make addresses immutable (#680) * Make addresses immutable * Fix linting issues --------- Co-authored-by: elenadimitrova * Prevent griefing by a malicious ZeroExVotes upgrade (#681) * Gas optimization * Minimal change to prevent malicious ZeroExVotes from griefing * Add demonstration of griefing upgrade * Fix rebase issues with tests * Fix prettier issues * Add checks to test --------- Co-authored-by: elenadimitrova * Rename SecurityCouncil contract * Add timestamp to delegator balance updates * Make quadraticThreshold `immutable` for gas efficiency * Remove the logic for ejecting security council * Switch balance timestamp to be a block number * Test votes migration for adding a new vote weight mechanism (#674) * Add Emacs files to .gitignore * Make some functions unproected to demonstrate a migration * Add example (broken) migration * Add migration test for voting logic * Try to simplify tests * Fix compilation errors * Fix underflow test with new logic * Flesh out migration test for voting * Replace cube root library * Fix stack too deep in coverage --------- Co-authored-by: elenadimitrova * Change test case to testFail * Update contracts/governance/test/ZeroExVotesMigration.sol Co-authored-by: duncancmt <1207590+duncancmt@users.noreply.github.com> --------- Co-authored-by: duncancmt <1207590+duncancmt@users.noreply.github.com> Co-authored-by: Duncan Townsend --- .github/workflows/ci.yml | 33 +- .gitignore | 18 +- .gitmodules | 11 + contracts/governance/ZRXToken.json | 505 ++++++++++++++++++ contracts/governance/foundry.toml | 25 + contracts/governance/lib/forge-std | 1 + .../governance/lib/openzeppelin-contracts | 1 + .../lib/openzeppelin-contracts-upgradeable | 1 + contracts/governance/package.json | 21 + contracts/governance/src/CallWithGas.sol | 169 ++++++ contracts/governance/src/IZeroExGovernor.sol | 39 ++ contracts/governance/src/IZeroExVotes.sol | 132 +++++ contracts/governance/src/SecurityCouncil.sol | 81 +++ contracts/governance/src/ZRXWrappedToken.sol | 164 ++++++ .../governance/src/ZeroExProtocolGovernor.sol | 147 +++++ contracts/governance/src/ZeroExTimelock.sol | 69 +++ .../governance/src/ZeroExTreasuryGovernor.sol | 154 ++++++ contracts/governance/src/ZeroExVotes.sol | 329 ++++++++++++ contracts/governance/test/BaseTest.t.sol | 116 ++++ contracts/governance/test/CubeRoot.sol | 21 + contracts/governance/test/ZRXMock.sol | 31 ++ .../governance/test/ZRXWrappedTokenTest.t.sol | 314 +++++++++++ .../test/ZeroExGovernorBaseTest.t.sol | 460 ++++++++++++++++ contracts/governance/test/ZeroExMock.sol | 11 + .../test/ZeroExProtocolGovernor.t.sol | 167 ++++++ .../test/ZeroExTreasuryGovernor.t.sol | 96 ++++ .../governance/test/ZeroExVotesMalicious.sol | 32 ++ .../governance/test/ZeroExVotesMigration.sol | 215 ++++++++ .../governance/test/ZeroExVotesTest.t.sol | 433 +++++++++++++++ package.json | 6 +- yarn.lock | 5 + 31 files changed, 3799 insertions(+), 8 deletions(-) create mode 100644 contracts/governance/ZRXToken.json create mode 100644 contracts/governance/foundry.toml create mode 160000 contracts/governance/lib/forge-std create mode 160000 contracts/governance/lib/openzeppelin-contracts create mode 160000 contracts/governance/lib/openzeppelin-contracts-upgradeable create mode 100644 contracts/governance/package.json create mode 100644 contracts/governance/src/CallWithGas.sol create mode 100644 contracts/governance/src/IZeroExGovernor.sol create mode 100644 contracts/governance/src/IZeroExVotes.sol create mode 100644 contracts/governance/src/SecurityCouncil.sol create mode 100644 contracts/governance/src/ZRXWrappedToken.sol create mode 100644 contracts/governance/src/ZeroExProtocolGovernor.sol create mode 100644 contracts/governance/src/ZeroExTimelock.sol create mode 100644 contracts/governance/src/ZeroExTreasuryGovernor.sol create mode 100644 contracts/governance/src/ZeroExVotes.sol create mode 100644 contracts/governance/test/BaseTest.t.sol create mode 100644 contracts/governance/test/CubeRoot.sol create mode 100644 contracts/governance/test/ZRXMock.sol create mode 100644 contracts/governance/test/ZRXWrappedTokenTest.t.sol create mode 100644 contracts/governance/test/ZeroExGovernorBaseTest.t.sol create mode 100644 contracts/governance/test/ZeroExMock.sol create mode 100644 contracts/governance/test/ZeroExProtocolGovernor.t.sol create mode 100644 contracts/governance/test/ZeroExTreasuryGovernor.t.sol create mode 100644 contracts/governance/test/ZeroExVotesMalicious.sol create mode 100644 contracts/governance/test/ZeroExVotesMigration.sol create mode 100644 contracts/governance/test/ZeroExVotesTest.t.sol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 030f179d78..cae81c2a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,11 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + - name: Add foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + - name: Build solution run: yarn build @@ -78,11 +83,6 @@ jobs: -p @0x/order-utils \ -m --serial -c test:ci - - name: Add foundry - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly - - name: Run Forge build for erc20 working-directory: contracts/erc20 run: | @@ -135,3 +135,26 @@ jobs: path: ./contracts/zero-ex/lcov.info min_coverage: 6.98 exclude: '**/tests' + + - name: Run Forge build on governance contracts + working-directory: ./contracts/governance + run: | + forge --version + forge build --sizes + + - name: Run Forge tests on governance contracts + working-directory: ./contracts/governance + run: | + forge test -vvv --gas-report + + - name: Run Forge coverage on governance contracts + working-directory: ./contracts/governance + run: | + forge coverage --report lcov + + - name: Upload the coverage report to Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + base-path: ./contracts/governance/ + path-to-lcov: ./contracts/governance/lcov.info diff --git a/.gitignore b/.gitignore index 30ad99d0bd..565b6f4f58 100644 --- a/.gitignore +++ b/.gitignore @@ -63,7 +63,16 @@ typings/ .env # built library using in commonjs module syntax -lib/ +contracts/erc20/lib/ +contracts/test-utils/lib/ +contracts/treasury/lib/ +contracts/utils/lib/ +contracts/zero-ex/lib/ +packages/contract-addresses/lib/ +packages/contract-artifacts/lib/ +packages/contract-wrappers/lib/ +packages/protocol-utils/lib/ + # UMD bundles that export the global variable _bundles @@ -97,10 +106,17 @@ out/ # typechain wrappers contracts/zero-ex/typechain-wrappers/ +# foundry packages +contracts/governance/cache +contracts/governance/out + # Doc README copy packages/*/docs/README.md .DS_Store +*~ +\#*\# +.\#* # the snapshot that gets built for migrations sure does have a ton of files packages/migrations/0x_ganache_snapshot* diff --git a/.gitmodules b/.gitmodules index 0e1391b9ff..eec23ed020 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,15 @@ url = https://github.com/foundry-rs/forge-std [submodule "contracts/erc20/lib/forge-std"] path = contracts/erc20/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "contracts/governance/lib/forge-std"] + path = contracts/governance/lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "contracts/governance/lib/openzeppelin-contracts"] + path = contracts/governance/lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "contracts/governance/lib/openzeppelin-contracts-upgradeable"] + path = contracts/governance/lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/openzeppelin-contracts-upgradeable"] + branch = v4.8.2 diff --git a/contracts/governance/ZRXToken.json b/contracts/governance/ZRXToken.json new file mode 100644 index 0000000000..c115c40cef --- /dev/null +++ b/contracts/governance/ZRXToken.json @@ -0,0 +1,505 @@ +{ + "abi": [ + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_spender", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_from", + "type": "address" + }, + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [ + { + "name": "", + "type": "uint8" + } + ], + "payable": false, + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + }, + { + "name": "_spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "type": "function" + }, + { + "inputs": [], + "payable": false, + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "_from", + "type": "address" + }, + { + "indexed": true, + "name": "_to", + "type": "address" + }, + { + "indexed": false, + "name": "_value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "_owner", + "type": "address" + }, + { + "indexed": true, + "name": "_spender", + "type": "address" + }, + { + "indexed": false, + "name": "_value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + } + ], + "bytecode": { + "object": "0x60606040526b033b2e3c9fd0803ce8000000600355341561001c57fe5b5b600354600160a060020a0333166000908152602081905260409020555b5b61078d8061004a6000396000f300606060405236156100965763ffffffff7c010000000000000000000000000000000000000000000000000000000060003504166306fdde038114610098578063095ea7b31461014657806318160ddd1461018657806323b872dd146101a8578063313ce567146101ee57806370a082311461021457806395d89b411461024f578063a9059cbb146102fd578063dd62ed3e1461033d575bfe5b34156100a057fe5b6100a861037e565b60408051602080825283518183015283519192839290830191850190808383821561010c575b80518252602083111561010c577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090920191602091820191016100ce565b505050905090810190601f1680156101385780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b341561014e57fe5b61017273ffffffffffffffffffffffffffffffffffffffff600435166024356103b5565b604080519115158252519081900360200190f35b341561018e57fe5b61019661042d565b60408051918252519081900360200190f35b34156101b057fe5b61017273ffffffffffffffffffffffffffffffffffffffff60043581169060243516604435610433565b604080519115158252519081900360200190f35b34156101f657fe5b6101fe6105d4565b6040805160ff9092168252519081900360200190f35b341561021c57fe5b61019673ffffffffffffffffffffffffffffffffffffffff600435166105d9565b60408051918252519081900360200190f35b341561025757fe5b6100a8610605565b60408051602080825283518183015283519192839290830191850190808383821561010c575b80518252602083111561010c577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090920191602091820191016100ce565b505050905090810190601f1680156101385780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b341561030557fe5b61017273ffffffffffffffffffffffffffffffffffffffff6004351660243561063c565b604080519115158252519081900360200190f35b341561034557fe5b61019673ffffffffffffffffffffffffffffffffffffffff60043581169060243516610727565b60408051918252519081900360200190f35b60408051808201909152601181527f30782050726f746f636f6c20546f6b656e000000000000000000000000000000602082015281565b73ffffffffffffffffffffffffffffffffffffffff338116600081815260016020908152604080832094871680845294825280832086905580518681529051929493927f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925929181900390910190a35060015b92915050565b60035481565b73ffffffffffffffffffffffffffffffffffffffff808416600081815260016020908152604080832033909516835293815283822054928252819052918220548390108015906104835750828110155b80156104b6575073ffffffffffffffffffffffffffffffffffffffff841660009081526020819052604090205483810110155b156105c65773ffffffffffffffffffffffffffffffffffffffff808516600090815260208190526040808220805487019055918716815220805484900390557fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8110156105585773ffffffffffffffffffffffffffffffffffffffff808616600090815260016020908152604080832033909416835292905220805484900390555b8373ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef856040518082815260200191505060405180910390a3600191506105cb565b600091505b5b509392505050565b601281565b73ffffffffffffffffffffffffffffffffffffffff81166000908152602081905260409020545b919050565b60408051808201909152600381527f5a52580000000000000000000000000000000000000000000000000000000000602082015281565b73ffffffffffffffffffffffffffffffffffffffff3316600090815260208190526040812054829010801590610699575073ffffffffffffffffffffffffffffffffffffffff831660009081526020819052604090205482810110155b156107185773ffffffffffffffffffffffffffffffffffffffff33811660008181526020818152604080832080548890039055938716808352918490208054870190558351868152935191937fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef929081900390910190a3506001610427565b506000610427565b5b92915050565b73ffffffffffffffffffffffffffffffffffffffff8083166000908152600160209081526040808320938516835292905220545b929150505600a165627a7a723058202dbef854545f38e5b78ec251d65db5fa0f12b6f2f0a0039063735c2dc416d6310029", + "sourceMap": "4935:353:0:-;;;5056:8;5027:37;;5208:78;;;;;;;5268:11;;-1:-1:-1;;;;;5254:10:0;5245:20;:8;:20;;;;;;;;;;:34;5208:78;4935:353;;;;;;;", + "linkReferences": {} + }, + "deployedBytecode": { + "object": "0x606060405236156100965763ffffffff7c010000000000000000000000000000000000000000000000000000000060003504166306fdde038114610098578063095ea7b31461014657806318160ddd1461018657806323b872dd146101a8578063313ce567146101ee57806370a082311461021457806395d89b411461024f578063a9059cbb146102fd578063dd62ed3e1461033d575bfe5b34156100a057fe5b6100a861037e565b60408051602080825283518183015283519192839290830191850190808383821561010c575b80518252602083111561010c577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090920191602091820191016100ce565b505050905090810190601f1680156101385780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b341561014e57fe5b61017273ffffffffffffffffffffffffffffffffffffffff600435166024356103b5565b604080519115158252519081900360200190f35b341561018e57fe5b61019661042d565b60408051918252519081900360200190f35b34156101b057fe5b61017273ffffffffffffffffffffffffffffffffffffffff60043581169060243516604435610433565b604080519115158252519081900360200190f35b34156101f657fe5b6101fe6105d4565b6040805160ff9092168252519081900360200190f35b341561021c57fe5b61019673ffffffffffffffffffffffffffffffffffffffff600435166105d9565b60408051918252519081900360200190f35b341561025757fe5b6100a8610605565b60408051602080825283518183015283519192839290830191850190808383821561010c575b80518252602083111561010c577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090920191602091820191016100ce565b505050905090810190601f1680156101385780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b341561030557fe5b61017273ffffffffffffffffffffffffffffffffffffffff6004351660243561063c565b604080519115158252519081900360200190f35b341561034557fe5b61019673ffffffffffffffffffffffffffffffffffffffff60043581169060243516610727565b60408051918252519081900360200190f35b60408051808201909152601181527f30782050726f746f636f6c20546f6b656e000000000000000000000000000000602082015281565b73ffffffffffffffffffffffffffffffffffffffff338116600081815260016020908152604080832094871680845294825280832086905580518681529051929493927f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925929181900390910190a35060015b92915050565b60035481565b73ffffffffffffffffffffffffffffffffffffffff808416600081815260016020908152604080832033909516835293815283822054928252819052918220548390108015906104835750828110155b80156104b6575073ffffffffffffffffffffffffffffffffffffffff841660009081526020819052604090205483810110155b156105c65773ffffffffffffffffffffffffffffffffffffffff808516600090815260208190526040808220805487019055918716815220805484900390557fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8110156105585773ffffffffffffffffffffffffffffffffffffffff808616600090815260016020908152604080832033909416835292905220805484900390555b8373ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef856040518082815260200191505060405180910390a3600191506105cb565b600091505b5b509392505050565b601281565b73ffffffffffffffffffffffffffffffffffffffff81166000908152602081905260409020545b919050565b60408051808201909152600381527f5a52580000000000000000000000000000000000000000000000000000000000602082015281565b73ffffffffffffffffffffffffffffffffffffffff3316600090815260208190526040812054829010801590610699575073ffffffffffffffffffffffffffffffffffffffff831660009081526020819052604090205482810110155b156107185773ffffffffffffffffffffffffffffffffffffffff33811660008181526020818152604080832080548890039055938716808352918490208054870190558351868152935191937fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef929081900390910190a3506001610427565b506000610427565b5b92915050565b73ffffffffffffffffffffffffffffffffffffffff8083166000908152600160209081526040808320938516835292905220545b929150505600a165627a7a723058202dbef854545f38e5b78ec251d65db5fa0f12b6f2f0a0039063735c2dc416d6310029", + "sourceMap": "4935:353:0:-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;5109:49;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;18:2:-1;;13:3;7:5;32;59:3;53:5;48:3;41:6;93:2;88:3;85:2;78:6;73:3;67:5;152:3;;;;;117:2;108:3;;;;130;172:5;167:4;181:3;3:186;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;3523:190:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;5027:37;;;;;;;;;;;;;;;;;;;;;;;;;;4369:562;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4986:35;;;;;;;;;;;;;;;;;;;;;;;;;;;;;3415:102;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;5164:37;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;18:2:-1;;13:3;7:5;32;59:3;53:5;48:3;41:6;93:2;88:3;85:2;78:6;73:3;67:5;152:3;;;;;117:2;108:3;;;;130;172:5;167:4;181:3;3:186;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2490:433:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;3719:129;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;5109:49;;;;;;;;;;;;;;;;;;;:::o;3523:190::-;3599:19;3607:10;3599:19;;3583:4;3599:19;;;:7;:19;;;;;;;;:29;;;;;;;;;;;;:38;;;3647;;;;;;;3583:4;;3599:29;:19;3647:38;;;;;;;;;;;-1:-1:-1;3702:4:0;3523:190;;;;;:::o;5027:37::-;;;;:::o;4369:562::-;4487:14;;;;4451:4;4487:14;;;:7;:14;;;;;;;;4502:10;4487:26;;;;;;;;;;;;4527:15;;;;;;;;;;:25;;;;;;:48;;;4569:6;4556:9;:19;;4527:48;:91;;;;-1:-1:-1;4605:13:0;;;:8;:13;;;;;;;;;;;4579:22;;;:39;;4527:91;4523:402;;;4634:13;;;;:8;:13;;;;;;;;;;;:23;;;;;;4671:15;;;;;;:25;;;;;;;4069:12;4714:20;;4710:95;;;4754:14;;;;;;;;:7;:14;;;;;;;;4769:10;4754:26;;;;;;;;;:36;;;;;;;4710:95;4834:3;4818:28;;4827:5;4818:28;;;4839:6;4818:28;;;;;;;;;;;;;;;;;;4867:4;4860:11;;;;4523:402;4909:5;4902:12;;4523:402;4369:562;;;;;;;:::o;4986:35::-;5019:2;4986:35;:::o;3415:102::-;3494:16;;;3468:7;3494:16;;;;;;;;;;;3415:102;;;;:::o;5164:37::-;;;;;;;;;;;;;;;;;;;:::o;2490:433::-;2635:20;2644:10;2635:20;2546:4;2635:20;;;;;;;;;;;:30;;;;;;:73;;-1:-1:-1;2695:13:0;;;:8;:13;;;;;;;;;;;2669:22;;;:39;;2635:73;2631:286;;;2724:20;2733:10;2724:20;;:8;:20;;;;;;;;;;;:30;;;;;;;2768:13;;;;;;;;;;:23;;;;;;2805:33;;;;;;;2768:13;;2805:33;;;;;;;;;;;-1:-1:-1;2859:4:0;2852:11;;2631:286;-1:-1:-1;2901:5:0;2894:12;;2631:286;2490:433;;;;;:::o;3719:129::-;3816:15;;;;3790:7;3816:15;;;:7;:15;;;;;;;;:25;;;;;;;;;;3719:129;;;;;:::o", + "linkReferences": {} + }, + "methodIdentifiers": { + "allowance(address,address)": "dd62ed3e", + "approve(address,uint256)": "095ea7b3", + "balanceOf(address)": "70a08231", + "decimals()": "313ce567", + "name()": "06fdde03", + "symbol()": "95d89b41", + "totalSupply()": "18160ddd", + "transfer(address,uint256)": "a9059cbb", + "transferFrom(address,address,uint256)": "23b872dd" + }, + "rawMetadata": "{\"compiler\":{\"version\":\"0.4.11+commit.68ef5810\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"constant\":true,\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"name\":\"\",\"type\":\"string\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_spender\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"name\":\"\",\"type\":\"bool\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_from\",\"type\":\"address\"},{\"name\":\"_to\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"name\":\"\",\"type\":\"bool\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"name\":\"\",\"type\":\"uint8\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"_owner\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"symbol\",\"outputs\":[{\"name\":\"\",\"type\":\"string\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_to\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"name\":\"\",\"type\":\"bool\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"_owner\",\"type\":\"address\"},{\"name\":\"_spender\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"payable\":false,\"type\":\"function\"},{\"inputs\":[],\"payable\":false,\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"_from\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"_to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"_owner\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"_spender\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"}],\"devdoc\":{\"methods\":{\"transferFrom(address,address,uint256)\":{\"details\":\"ERC20 transferFrom, modified such that an allowance of MAX_UINT represents an unlimited allowance.\",\"params\":{\"_from\":\"Address to transfer from.\",\"_to\":\"Address to transfer to.\",\"_value\":\"Amount to transfer.\"},\"return\":\"Success of transfer.\"}}},\"userdoc\":{\"methods\":{}}},\"settings\":{\"compilationTarget\":{\"contracts/erc20/src/ZRXToken.sol\":\"ZRXToken\"},\"libraries\":{},\"optimizer\":{\"enabled\":true,\"runs\":1000000},\"remappings\":[\":@0x/contracts-erc20/=contracts/erc20/\",\":@0x/contracts-utils/=contracts/utils/\",\":ds-test/=contracts/erc20/lib/forge-std/lib/ds-test/src/\",\":forge-std/=contracts/erc20/lib/forge-std/src/\"]},\"sources\":{\"contracts/erc20/src/ZRXToken.sol\":{\"keccak256\":\"0x8582c06b20f8b7d3d603b485b5d26f840e01d1986381b334a856833edcff0d47\",\"urls\":[\"bzzr://ef728dddbaa1e26baa6cc9fe0f83de5055bc0b17dfe488018f4ee59d68ccb5dd\"]}},\"version\":1}", + "metadata": { + "compiler": { + "version": "0.4.11+commit.68ef5810" + }, + "language": "Solidity", + "output": { + "abi": [ + { + "inputs": [], + "type": "function", + "name": "name", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "string" + } + ] + }, + { + "inputs": [ + { + "internalType": null, + "name": "_spender", + "type": "address" + }, + { + "internalType": null, + "name": "_value", + "type": "uint256" + } + ], + "type": "function", + "name": "approve", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "bool" + } + ] + }, + { + "inputs": [], + "type": "function", + "name": "totalSupply", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "uint256" + } + ] + }, + { + "inputs": [ + { + "internalType": null, + "name": "_from", + "type": "address" + }, + { + "internalType": null, + "name": "_to", + "type": "address" + }, + { + "internalType": null, + "name": "_value", + "type": "uint256" + } + ], + "type": "function", + "name": "transferFrom", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "bool" + } + ] + }, + { + "inputs": [], + "type": "function", + "name": "decimals", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "uint8" + } + ] + }, + { + "inputs": [ + { + "internalType": null, + "name": "_owner", + "type": "address" + } + ], + "type": "function", + "name": "balanceOf", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "uint256" + } + ] + }, + { + "inputs": [], + "type": "function", + "name": "symbol", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "string" + } + ] + }, + { + "inputs": [ + { + "internalType": null, + "name": "_to", + "type": "address" + }, + { + "internalType": null, + "name": "_value", + "type": "uint256" + } + ], + "type": "function", + "name": "transfer", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "bool" + } + ] + }, + { + "inputs": [ + { + "internalType": null, + "name": "_owner", + "type": "address" + }, + { + "internalType": null, + "name": "_spender", + "type": "address" + } + ], + "type": "function", + "name": "allowance", + "outputs": [ + { + "internalType": null, + "name": "", + "type": "uint256" + } + ] + }, + { + "inputs": [], + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": null, + "name": "_from", + "type": "address", + "indexed": true + }, + { + "internalType": null, + "name": "_to", + "type": "address", + "indexed": true + }, + { + "internalType": null, + "name": "_value", + "type": "uint256", + "indexed": false + } + ], + "type": "event", + "name": "Transfer", + "anonymous": false + }, + { + "inputs": [ + { + "internalType": null, + "name": "_owner", + "type": "address", + "indexed": true + }, + { + "internalType": null, + "name": "_spender", + "type": "address", + "indexed": true + }, + { + "internalType": null, + "name": "_value", + "type": "uint256", + "indexed": false + } + ], + "type": "event", + "name": "Approval", + "anonymous": false + } + ], + "devdoc": { + "methods": { + "transferFrom(address,address,uint256)": { + "details": "ERC20 transferFrom, modified such that an allowance of MAX_UINT represents an unlimited allowance.", + "params": { + "_from": "Address to transfer from.", + "_to": "Address to transfer to.", + "_value": "Amount to transfer." + }, + "return": "Success of transfer." + } + } + }, + "userdoc": { + "methods": {} + } + }, + "settings": { + "remappings": [ + ":@0x/contracts-erc20/=contracts/erc20/", + ":@0x/contracts-utils/=contracts/utils/", + ":ds-test/=contracts/erc20/lib/forge-std/lib/ds-test/src/", + ":forge-std/=contracts/erc20/lib/forge-std/src/" + ], + "optimizer": { + "enabled": true, + "runs": 1000000 + }, + "compilationTarget": { + "contracts/erc20/src/ZRXToken.sol": "ZRXToken" + }, + "libraries": {} + }, + "sources": { + "contracts/erc20/src/ZRXToken.sol": { + "keccak256": "0x8582c06b20f8b7d3d603b485b5d26f840e01d1986381b334a856833edcff0d47", + "urls": ["bzzr://ef728dddbaa1e26baa6cc9fe0f83de5055bc0b17dfe488018f4ee59d68ccb5dd"], + "license": null + } + }, + "version": 1 + }, + "id": 0 +} diff --git a/contracts/governance/foundry.toml b/contracts/governance/foundry.toml new file mode 100644 index 0000000000..d53b247d63 --- /dev/null +++ b/contracts/governance/foundry.toml @@ -0,0 +1,25 @@ +[profile.default] +src = 'src' +out = 'out' +libs = ['lib', "../utils/contracts/src/"] +fs_permissions = [{ access = "read", path = "./" }] +remappings = [ + '@openzeppelin/=./lib/openzeppelin-contracts/contracts/', + '@openzeppelin-contracts-upgradeable/=./lib/openzeppelin-contracts-upgradeable/contracts/', + '@0x/contracts-utils/=../utils/', +] +solc = '0.8.19' +optimizer_runs = 20_000 +via_ir = true + +[profile.smt.model_checker] +engine = 'chc' +timeout = 10_000 +targets = [ + 'assert', + 'constantCondition', + 'divByZero', + 'outOfBounds', + 'underflow' +] +contracts = { 'src/ZeroExProtocolGovernor.sol' = [ 'ZeroExProtocolGovernor' ] } diff --git a/contracts/governance/lib/forge-std b/contracts/governance/lib/forge-std new file mode 160000 index 0000000000..eb980e1d4f --- /dev/null +++ b/contracts/governance/lib/forge-std @@ -0,0 +1 @@ +Subproject commit eb980e1d4f0e8173ec27da77297ae411840c8ccb diff --git a/contracts/governance/lib/openzeppelin-contracts b/contracts/governance/lib/openzeppelin-contracts new file mode 160000 index 0000000000..d00acef405 --- /dev/null +++ b/contracts/governance/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit d00acef4059807535af0bd0dd0ddf619747a044b diff --git a/contracts/governance/lib/openzeppelin-contracts-upgradeable b/contracts/governance/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000000..f6c4c9c4ec --- /dev/null +++ b/contracts/governance/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit f6c4c9c4ec601665ca74d2c9dddf547fc425658c diff --git a/contracts/governance/package.json b/contracts/governance/package.json new file mode 100644 index 0000000000..ad4b5c78f6 --- /dev/null +++ b/contracts/governance/package.json @@ -0,0 +1,21 @@ +{ + "name": "@0x/governance", + "version": "1.0.0", + "description": "Governance implementation for the 0x protocol and treasury", + "main": "index.js", + "directories": { + "lib": "lib", + "test": "test" + }, + "scripts": { + "test": "forge test", + "build": "forge build", + "build:smt": "FOUNDRY_PROFILE=smt forge build" + }, + "repository": { + "type": "git", + "url": "https://github.com/0xProject/protocol.git" + }, + "license": "Apache-2.0", + "dependencies": {} +} diff --git a/contracts/governance/src/CallWithGas.sol b/contracts/governance/src/CallWithGas.sol new file mode 100644 index 0000000000..20441bb8cf --- /dev/null +++ b/contracts/governance/src/CallWithGas.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +library CallWithGas { + /** + * @notice `staticcall` another contract forwarding a precomputed amount of + * gas. + * @dev contains protections against EIP-150-induced insufficient gas + * griefing + * @dev reverts iff the target is not a contract or we encounter an + * out-of-gas + * @return success true iff the call succeded and returned no more than + * `maxReturnBytes` of return data + * @return returnData the return data or revert reason of the call + * @param target the contract (reverts if non-contract) on which to make the + * `staticcall` + * @param data the calldata to pass + * @param callGas the gas to pass for the call. If the call requires more than + * the specified amount of gas and the caller didn't provide at + * least `callGas`, triggers an out-of-gas in the caller. + * @param maxReturnBytes Only this many bytes of return data are read back + * from the call. This prevents griefing the caller. If + * more bytes are returned or the revert reason is + * longer, success will be false and returnData will be + * `abi.encodeWithSignature("Error(string)", "CallWithGas: returnData too long")` + */ + function functionStaticCallWithGas( + address target, + bytes memory data, + uint256 callGas, + uint256 maxReturnBytes + ) internal view returns (bool success, bytes memory returnData) { + assembly ("memory-safe") { + returnData := mload(0x40) + success := staticcall(callGas, target, add(data, 0x20), mload(data), add(returnData, 0x20), maxReturnBytes) + + // As of the time this contract was written, `verbatim` doesn't work in + // inline assembly. Assignment of a value to a variable costs gas + // (although how much is unpredictable because it depends on the Yul/IR + // optimizer), as does the `GAS` opcode itself. Also solc tends to reorder + // the call to `gas()` with preparing the arguments for `div`. Therefore, + // the `gas()` below returns less than the actual amount of gas available + // for computation at the end of the call. That makes this check slightly + // too conservative. However, we do not correct for this because the + // correction would become outdated (possibly too permissive) if the + // opcodes are repriced. + + // https://eips.ethereum.org/EIPS/eip-150 + // https://ronan.eth.link/blog/ethereum-gas-dangers/ + if iszero(or(success, or(returndatasize(), lt(div(callGas, 63), gas())))) { + // The call failed due to not enough gas left. We deliberately consume + // all remaining gas with `invalid` (instead of `revert`) to make this + // failure distinguishable to our caller. + invalid() + } + + switch gt(returndatasize(), maxReturnBytes) + case 0 { + switch returndatasize() + case 0 { + returnData := 0x60 + success := and(success, iszero(iszero(extcodesize(target)))) + } + default { + mstore(returnData, returndatasize()) + mstore(0x40, add(returnData, add(0x20, returndatasize()))) + } + } + default { + // returnData = abi.encodeWithSignature("Error(string)", "CallWithGas: returnData too long") + success := 0 + mstore(returnData, 0) // clear potentially dirty bits + mstore(add(returnData, 0x04), 0x6408c379a0) // length and selector + mstore(add(returnData, 0x24), 0x20) + mstore(add(returnData, 0x44), 0x20) + mstore(add(returnData, 0x64), "CallWithGas: returnData too long") + mstore(0x40, add(returnData, 0x84)) + } + } + } + + /// See `functionCallWithGasAndValue` + function functionCallWithGas( + address target, + bytes memory data, + uint256 callGas, + uint256 maxReturnBytes + ) internal returns (bool success, bytes memory returnData) { + return functionCallWithGasAndValue(payable(target), data, callGas, 0, maxReturnBytes); + } + + /** + * @notice `call` another contract forwarding a precomputed amount of gas. + * @notice Unlike `functionStaticCallWithGas`, a failure is not signaled if + * there is too much return data. Instead, it is simply truncated. + * @dev contains protections against EIP-150-induced insufficient gas griefing + * @dev reverts iff caller doesn't have enough native asset balance, the + * target is not a contract, or due to out-of-gas + * @return success true iff the call succeded + * @return returnData the return data or revert reason of the call + * @param target the contract (reverts if non-contract) on which to make the + * `call` + * @param data the calldata to pass + * @param callGas the gas to pass for the call. If the call requires more than + * the specified amount of gas and the caller didn't provide at + * least `callGas`, triggers an out-of-gas in the caller. + * @param value the amount of the native asset in wei to pass to the callee + * with the call + * @param maxReturnBytes Only this many bytes of return data/revert reason are + * read back from the call. This prevents griefing the + * caller. If more bytes are returned or the revert + * reason is longer, returnData will be truncated + */ + function functionCallWithGasAndValue( + address payable target, + bytes memory data, + uint256 callGas, + uint256 value, + uint256 maxReturnBytes + ) internal returns (bool success, bytes memory returnData) { + if (value > 0 && (address(this).balance < value || target.code.length == 0)) { + return (success, returnData); + } + + assembly ("memory-safe") { + returnData := mload(0x40) + success := call(callGas, target, value, add(data, 0x20), mload(data), add(returnData, 0x20), maxReturnBytes) + + // As of the time this contract was written, `verbatim` doesn't work in + // inline assembly. Assignment of a value to a variable costs gas + // (although how much is unpredictable because it depends on the Yul/IR + // optimizer), as does the `GAS` opcode itself. Also solc tends to reorder + // the call to `gas()` with preparing the arguments for `div`. Therefore, + // the `gas()` below returns less than the actual amount of gas available + // for computation at the end of the call. That makes this check slightly + // too conservative. However, we do not correct for this because the + // correction would become outdated (possibly too permissive) if the + // opcodes are repriced. + + // https://eips.ethereum.org/EIPS/eip-150 + // https://ronan.eth.link/blog/ethereum-gas-dangers/ + if iszero(or(success, or(returndatasize(), lt(div(callGas, 63), gas())))) { + // The call failed due to not enough gas left. We deliberately consume + // all remaining gas with `invalid` (instead of `revert`) to make this + // failure distinguishable to our caller. + invalid() + } + + switch gt(returndatasize(), maxReturnBytes) + case 0 { + switch returndatasize() + case 0 { + returnData := 0x60 + if iszero(value) { + success := and(success, iszero(iszero(extcodesize(target)))) + } + } + default { + mstore(returnData, returndatasize()) + mstore(0x40, add(returnData, add(0x20, returndatasize()))) + } + } + default { + mstore(returnData, maxReturnBytes) + mstore(0x40, add(returnData, add(0x20, maxReturnBytes))) + } + } + } +} diff --git a/contracts/governance/src/IZeroExGovernor.sol b/contracts/governance/src/IZeroExGovernor.sol new file mode 100644 index 0000000000..bd27dcfcaf --- /dev/null +++ b/contracts/governance/src/IZeroExGovernor.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "./SecurityCouncil.sol"; +import "@openzeppelin/governance/IGovernor.sol"; +import "@openzeppelin/governance/extensions/IGovernorTimelock.sol"; + +abstract contract IZeroExGovernor is SecurityCouncil, IGovernor, IGovernorTimelock { + function token() public virtual returns (address); + + function proposalThreshold() public view virtual returns (uint256); + + function setVotingDelay(uint256 newVotingDelay) public virtual; + + function setVotingPeriod(uint256 newVotingPeriod) public virtual; + + function setProposalThreshold(uint256 newProposalThreshold) public virtual; + + function proposalVotes( + uint256 proposalId + ) public view virtual returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes); +} diff --git a/contracts/governance/src/IZeroExVotes.sol b/contracts/governance/src/IZeroExVotes.sol new file mode 100644 index 0000000000..46242b1ed8 --- /dev/null +++ b/contracts/governance/src/IZeroExVotes.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +interface IZeroExVotes { + struct Checkpoint { + uint32 fromBlock; + uint96 votes; + uint96 quadraticVotes; + } + + /** + * @dev Emitted when a token transfer or delegate change, + * results in changes to a delegate's quadratic number of votes. + */ + event DelegateQuadraticVotesChanged( + address indexed delegate, + uint256 previousQuadraticBalance, + uint256 newQuadraticBalance + ); + + /** + * @dev Emitted when a token transfer or delegate change, results in changes to a delegate's number of votes. + */ + event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); + + /** + * @dev Emitted when the total supply of the token is changed due to minting and burning which results in + * the total supply checkpoint being writtenor updated. + */ + event TotalSupplyChanged(uint256 totalSupplyVotes, uint256 totalSupplyQuadraticVotes); + + /** + * @dev Get the `pos`-th checkpoint for `account`. + */ + function checkpoints(address account, uint32 pos) external view returns (Checkpoint memory); + + /** + * @dev Get number of checkpoints for `account`. + */ + function numCheckpoints(address account) external view returns (uint32); + + /** + * @dev Gets the current votes balance for `account` + */ + function getVotes(address account) external view returns (uint256); + + /** + * @dev Gets the current quadratic votes balance for `account` + */ + function getQuadraticVotes(address account) external view returns (uint256); + + /** + * @dev Retrieve the number of votes for `account` at the end of `blockNumber`. + * + * Requirements: + * + * - `blockNumber` must have been already mined + */ + function getPastVotes(address account, uint256 blockNumber) external view returns (uint256); + + /** + * @dev Retrieve the number of quadratic votes for `account` at the end of `blockNumber`. + * + * Requirements: + * + * - `blockNumber` must have been already mined + */ + function getPastQuadraticVotes(address account, uint256 blockNumber) external view returns (uint256); + + /** + * @dev Retrieve the `totalSupply` at the end of `blockNumber`. Note, this value is the sum of all balances. + * It is but NOT the sum of all the delegated votes! + * + * Requirements: + * + * - `blockNumber` must have been already mined + */ + function getPastTotalSupply(uint256 blockNumber) external view returns (uint256); + + /** + * @dev Retrieve the sqrt of `totalSupply` at the end of `blockNumber`. Note, this value is the square root of the + * sum of all balances. + * It is but NOT the sum of all the sqrt of the delegated votes! + * + * Requirements: + * + * - `blockNumber` must have been already mined + */ + function getPastQuadraticTotalSupply(uint256 blockNumber) external view returns (uint256); + + /** + * @dev Moves the voting power corresponding to `amount` number of tokens from `src` to `dst`. + * Note that if the delegator isn't delegating to anyone before the function call `src` = address(0) + * @param src the delegatee we are moving voting power away from + * @param dst the delegatee we are moving voting power to + * @param srcBalance balance of the delegator whose delegatee is `src`. This is value _after_ the transfer. + * @param dstBalance balance of the delegator whose delegatee is `dst`. This is value _after_ the transfer. + * @param srcBalanceLastUpdated block number when balance of `src` was last updated. + * @param dstBalanceLastUpdated block number when balance of `dst` was last updated. + * @param amount The amount of tokens transferred from the source delegate to destination delegate. + */ + function moveVotingPower( + address src, + address dst, + uint256 srcBalance, + uint256 dstBalance, + uint96 srcBalanceLastUpdated, + uint96 dstBalanceLastUpdated, + uint256 amount + ) external returns (bool); + + function writeCheckpointTotalSupplyMint(uint256 accountBalance, uint256 amount) external returns (bool); + + function writeCheckpointTotalSupplyBurn(uint256 accountBalance, uint256 amount) external returns (bool); +} diff --git a/contracts/governance/src/SecurityCouncil.sol b/contracts/governance/src/SecurityCouncil.sol new file mode 100644 index 0000000000..df62ef6269 --- /dev/null +++ b/contracts/governance/src/SecurityCouncil.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +abstract contract SecurityCouncil { + address public securityCouncil; + + event SecurityCouncilAssigned(address securityCouncil); + + event SecurityCouncilEjected(); + + modifier onlySecurityCouncil() { + require(msg.sender == securityCouncil, "ZeroExProtocolGovernor: only security council allowed"); + _; + } + + /** + * @dev Checks that either a security council is assigned or the payloads array is a council assignment call. + */ + modifier securityCouncilAssigned(bytes[] memory payloads) { + if (securityCouncil == address(0) && !_payloadIsAssignSecurityCouncil(payloads)) { + revert("SecurityCouncil: security council not assigned and this is not an assignment call"); + } + _; + } + + /** + * @dev Assigns new security council. + */ + function assignSecurityCouncil(address _securityCouncil) public virtual { + securityCouncil = _securityCouncil; + + emit SecurityCouncilAssigned(securityCouncil); + } + + /** + * @dev Ejects the current security council via setting the security council address to 0. + * Security council is ejected after they either cancel a proposal or execute a protocol rollback. + */ + function ejectSecurityCouncil() internal { + securityCouncil = address(0); + emit SecurityCouncilEjected(); + } + + /** + * @dev Cancel existing proposal with the submitted `targets`, `values`, `calldatas` and `descriptionHash`. + */ + function cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public virtual; + + function _payloadIsAssignSecurityCouncil(bytes[] memory payloads) private pure returns (bool) { + require(payloads.length == 1, "SecurityCouncil: more than 1 transaction in proposal"); + bytes memory payload = payloads[0]; + // Check this is as assignSecurityCouncil(address) transaction + // function signature for assignSecurityCouncil(address) + // = bytes4(keccak256("assignSecurityCouncil(address)")) + // = 0x2761c3cd + if (bytes4(payload) == bytes4(0x2761c3cd)) return true; + else return false; + } +} diff --git a/contracts/governance/src/ZRXWrappedToken.sol b/contracts/governance/src/ZRXWrappedToken.sol new file mode 100644 index 0000000000..35731599f6 --- /dev/null +++ b/contracts/governance/src/ZRXWrappedToken.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "@openzeppelin/token/ERC20/ERC20.sol"; +import "@openzeppelin/token/ERC20/extensions/draft-ERC20Permit.sol"; +import "@openzeppelin/token/ERC20/extensions/ERC20Wrapper.sol"; +import "@openzeppelin/governance/utils/IVotes.sol"; +import "@openzeppelin/utils/math/SafeCast.sol"; +import "./IZeroExVotes.sol"; +import "./CallWithGas.sol"; + +contract ZRXWrappedToken is ERC20, ERC20Permit, ERC20Wrapper { + using CallWithGas for address; + + struct DelegateInfo { + address delegate; + uint96 balanceLastUpdated; + } + + constructor( + IERC20 wrappedToken, + IZeroExVotes _zeroExVotes + ) ERC20("Wrapped ZRX", "wZRX") ERC20Permit("Wrapped ZRX") ERC20Wrapper(wrappedToken) { + zeroExVotes = _zeroExVotes; + } + + IZeroExVotes public immutable zeroExVotes; + mapping(address => DelegateInfo) private _delegates; + + bytes32 private constant _DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + + /** + * @dev Emitted when an account changes their delegate. + */ + event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate); + + // The functions below are the required overrides from the base contracts + + function decimals() public pure override(ERC20, ERC20Wrapper) returns (uint8) { + return 18; + } + + function _afterTokenTransfer(address from, address to, uint256 amount) internal override(ERC20) { + super._afterTokenTransfer(from, to, amount); + + DelegateInfo memory fromDelegate = delegateInfo(from); + DelegateInfo memory toDelegate = delegateInfo(to); + + uint256 fromBalance = fromDelegate.delegate == address(0) ? 0 : balanceOf(from) + amount; + uint256 toBalance = toDelegate.delegate == address(0) ? 0 : balanceOf(to) - amount; + + if (fromDelegate.delegate != address(0)) _delegates[from].balanceLastUpdated = SafeCast.toUint96(block.number); + + if (toDelegate.delegate != address(0)) _delegates[to].balanceLastUpdated = SafeCast.toUint96(block.number); + + zeroExVotes.moveVotingPower( + fromDelegate.delegate, + toDelegate.delegate, + fromBalance, + toBalance, + fromDelegate.balanceLastUpdated, + toDelegate.balanceLastUpdated, + amount + ); + } + + function _mint(address account, uint256 amount) internal override(ERC20) { + super._mint(account, amount); + + zeroExVotes.writeCheckpointTotalSupplyMint(balanceOf(account) - amount, amount); + } + + function _burn(address account, uint256 amount) internal override(ERC20) { + super._burn(account, amount); + + address(zeroExVotes).functionCallWithGas( + abi.encodeCall(zeroExVotes.writeCheckpointTotalSupplyBurn, (balanceOf(account) + amount, amount)), + 500_000, + 32 + ); + } + + /** + * @dev Get the address `account` is currently delegating to. + */ + function delegates(address account) public view returns (address) { + return _delegates[account].delegate; + } + + /** + * @dev Get the last block number when `account`'s balance changed. + */ + function delegatorBalanceLastUpdated(address account) public view returns (uint96) { + return _delegates[account].balanceLastUpdated; + } + + function delegateInfo(address account) public view returns (DelegateInfo memory) { + return _delegates[account]; + } + + /** + * @dev Delegate votes from the sender to `delegatee`. + */ + function delegate(address delegatee) public { + _delegate(_msgSender(), delegatee); + } + + /** + * @dev Delegates votes from signer to `delegatee` + */ + function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) public { + require(block.timestamp <= expiry, "ERC20Votes: signature expired"); + address signer = ECDSA.recover( + _hashTypedDataV4(keccak256(abi.encode(_DELEGATION_TYPEHASH, delegatee, nonce, expiry))), + v, + r, + s + ); + require(nonce == _useNonce(signer), "ERC20Votes: invalid nonce"); + _delegate(signer, delegatee); + } + + /** + * @dev Change delegation for `delegator` to `delegatee`. + * + * Emits events {DelegateChanged} and {IZeroExVotes-DelegateVotesChanged}. + */ + function _delegate(address delegator, address delegatee) internal virtual { + DelegateInfo memory delegateInfo = delegateInfo(delegator); + uint256 delegatorBalance = balanceOf(delegator); + + _delegates[delegator] = DelegateInfo(delegatee, SafeCast.toUint96(block.timestamp)); + + emit DelegateChanged(delegator, delegateInfo.delegate, delegatee); + + zeroExVotes.moveVotingPower( + delegateInfo.delegate, + delegatee, + delegatorBalance, + 0, + delegateInfo.balanceLastUpdated, + 0, + delegatorBalance + ); + } +} diff --git a/contracts/governance/src/ZeroExProtocolGovernor.sol b/contracts/governance/src/ZeroExProtocolGovernor.sol new file mode 100644 index 0000000000..7fe283b5ed --- /dev/null +++ b/contracts/governance/src/ZeroExProtocolGovernor.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "./SecurityCouncil.sol"; +import "./ZeroExTimelock.sol"; +import "@openzeppelin/governance/Governor.sol"; +import "@openzeppelin/governance/extensions/GovernorSettings.sol"; +import "@openzeppelin/governance/extensions/GovernorCountingSimple.sol"; +import "@openzeppelin/governance/extensions/GovernorVotes.sol"; +import "@openzeppelin/governance/extensions/GovernorTimelockControl.sol"; + +contract ZeroExProtocolGovernor is + SecurityCouncil, + Governor, + GovernorSettings, + GovernorCountingSimple, + GovernorVotes, + GovernorTimelockControl +{ + constructor( + IVotes _votes, + ZeroExTimelock _timelock, + address _securityCouncil + ) + Governor("ZeroExProtocolGovernor") + GovernorSettings(14400 /* 2 days */, 50400 /* 7 days */, 1000000e18) + GovernorVotes(_votes) + GovernorTimelockControl(TimelockController(payable(_timelock))) + { + securityCouncil = _securityCouncil; + } + + function quorum(uint256 blockNumber) public pure override returns (uint256) { + return 10000000e18; + } + + // The following functions are overrides required by Solidity. + + function votingDelay() public view override(IGovernor, GovernorSettings) returns (uint256) { + return super.votingDelay(); + } + + function votingPeriod() public view override(IGovernor, GovernorSettings) returns (uint256) { + return super.votingPeriod(); + } + + function state(uint256 proposalId) public view override(Governor, GovernorTimelockControl) returns (ProposalState) { + return super.state(proposalId); + } + + function propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) public override(Governor, IGovernor) securityCouncilAssigned(calldatas) returns (uint256) { + return super.propose(targets, values, calldatas, description); + } + + function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + return super.proposalThreshold(); + } + + function cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public override onlySecurityCouncil { + _cancel(targets, values, calldatas, descriptionHash); + } + + // Like the GovernorTimelockControl.queue function but without the proposal checks, + // (as there's effectively no proposal). + // And also using a delay of 0 as opposed to the minimum delay of the timelock + function executeRollback( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public { + require(msg.sender == securityCouncil, "ZeroExProtocolGovernor: only security council allowed"); + + // Execute the batch of rollbacks via the timelock controller + ZeroExTimelock timelockController = ZeroExTimelock(payable(timelock())); + timelockController.executeRollbackBatch(targets, values, calldatas, 0, descriptionHash); + } + + function assignSecurityCouncil(address _securityCouncil) public override onlyGovernance { + super.assignSecurityCouncil(_securityCouncil); + } + + function queue( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public override securityCouncilAssigned(calldatas) returns (uint256) { + return super.queue(targets, values, calldatas, descriptionHash); + } + + function _execute( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) { + super._execute(proposalId, targets, values, calldatas, descriptionHash); + } + + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); + } + + function _executor() internal view override(Governor, GovernorTimelockControl) returns (address) { + return super._executor(); + } + + function supportsInterface( + bytes4 interfaceId + ) public view override(Governor, GovernorTimelockControl) returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/contracts/governance/src/ZeroExTimelock.sol b/contracts/governance/src/ZeroExTimelock.sol new file mode 100644 index 0000000000..a7c5c6d273 --- /dev/null +++ b/contracts/governance/src/ZeroExTimelock.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "@openzeppelin/governance/TimelockController.sol"; + +contract ZeroExTimelock is TimelockController { + // minDelay is how long you have to wait before executing + // proposers is the list of addresses that can propose + // executors is the list of addresses that can execute + constructor( + uint256 minDelay, + address[] memory proposers, + address[] memory executors, + address admin + ) TimelockController(minDelay, proposers, executors, admin) {} + + /** + * @dev Execute a batch of rollback transactions. Similar to TimelockController.executeBatch function but without + * the timelock checks. + * Emits one {CallExecuted} event per transaction in the batch. + * + * Requirements: + * + * - the caller must have the 'executor' role. + */ + function executeRollbackBatch( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata payloads, + bytes32 predecessor, + bytes32 salt + ) public payable onlyRoleOrOpenRole(EXECUTOR_ROLE) { + require(targets.length == values.length, "ZeroExTimelock: length mismatch"); + require(targets.length == payloads.length, "ZeroExTimelock: length mismatch"); + + bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt); + + for (uint256 i = 0; i < targets.length; ++i) { + address target = targets[i]; + uint256 value = values[i]; + bytes calldata payload = payloads[i]; + // Check this is a rollback transaction + // function signature for rollback(bytes4,address) + // = bytes4(keccak256("rollback(bytes4,address)")) + // = 0x9db64a40 + require(bytes4(payload) == bytes4(0x9db64a40), "ZeroExTimelock: not rollback"); + + _execute(target, value, payload); + emit CallExecuted(id, i, target, value, payload); + } + } +} diff --git a/contracts/governance/src/ZeroExTreasuryGovernor.sol b/contracts/governance/src/ZeroExTreasuryGovernor.sol new file mode 100644 index 0000000000..fca907e898 --- /dev/null +++ b/contracts/governance/src/ZeroExTreasuryGovernor.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "@openzeppelin/governance/Governor.sol"; +import "@openzeppelin/governance/extensions/GovernorSettings.sol"; +import "@openzeppelin/governance/extensions/GovernorCountingSimple.sol"; +import "@openzeppelin/governance/extensions/GovernorVotes.sol"; +import "@openzeppelin/governance/extensions/GovernorVotesQuorumFraction.sol"; +import "@openzeppelin/governance/extensions/GovernorTimelockControl.sol"; + +import "./IZeroExVotes.sol"; +import "./SecurityCouncil.sol"; + +contract ZeroExTreasuryGovernor is + SecurityCouncil, + Governor, + GovernorSettings, + GovernorCountingSimple, + GovernorVotes, + GovernorVotesQuorumFraction, + GovernorTimelockControl +{ + constructor( + IVotes votes, + TimelockController _timelock, + address _securityCouncil + ) + Governor("ZeroExTreasuryGovernor") + GovernorSettings(14400 /* 2 days */, 50400 /* 7 days */, 250000e18) + GovernorVotes(votes) + GovernorVotesQuorumFraction(10) + GovernorTimelockControl(_timelock) + { + securityCouncil = _securityCouncil; + } + + /** + * @dev Returns the "quadratic" quorum for a block number, in terms of number of votes: + * `quadratic total supply * numerator / denominator` + */ + function quorum( + uint256 blockNumber + ) public view override(IGovernor, GovernorVotesQuorumFraction) returns (uint256) { + IZeroExVotes votes = IZeroExVotes(address(token)); + return (votes.getPastQuadraticTotalSupply(blockNumber) * quorumNumerator(blockNumber)) / quorumDenominator(); + } + + // The following functions are overrides required by Solidity. + + function votingDelay() public view override(IGovernor, GovernorSettings) returns (uint256) { + return super.votingDelay(); + } + + function votingPeriod() public view override(IGovernor, GovernorSettings) returns (uint256) { + return super.votingPeriod(); + } + + function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + return super.proposalThreshold(); + } + + /** + * Overwritten GovernorVotes implementation + * Read the quadratic voting weight from the token's built in snapshot mechanism (see {Governor-_getVotes}). + */ + function _getVotes( + address account, + uint256 blockNumber, + bytes memory /*params*/ + ) internal view virtual override(Governor, GovernorVotes) returns (uint256) { + return IZeroExVotes(address(token)).getPastQuadraticVotes(account, blockNumber); + } + + function state(uint256 proposalId) public view override(Governor, GovernorTimelockControl) returns (ProposalState) { + return super.state(proposalId); + } + + function propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) public override(Governor, IGovernor) securityCouncilAssigned(calldatas) returns (uint256) { + return super.propose(targets, values, calldatas, description); + } + + function cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public override onlySecurityCouncil { + _cancel(targets, values, calldatas, descriptionHash); + } + + function assignSecurityCouncil(address _securityCouncil) public override onlyGovernance { + super.assignSecurityCouncil(_securityCouncil); + } + + function queue( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public override securityCouncilAssigned(calldatas) returns (uint256) { + return super.queue(targets, values, calldatas, descriptionHash); + } + + function _execute( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) { + super._execute(proposalId, targets, values, calldatas, descriptionHash); + } + + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); + } + + function _executor() internal view override(Governor, GovernorTimelockControl) returns (address) { + return super._executor(); + } + + function supportsInterface( + bytes4 interfaceId + ) public view override(Governor, GovernorTimelockControl) returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/contracts/governance/src/ZeroExVotes.sol b/contracts/governance/src/ZeroExVotes.sol new file mode 100644 index 0000000000..52689d836d --- /dev/null +++ b/contracts/governance/src/ZeroExVotes.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "@openzeppelin/utils/math/SafeCast.sol"; +import "@openzeppelin/utils/math/Math.sol"; +import "@openzeppelin/token/ERC20/ERC20.sol"; +import "@openzeppelin/governance/utils/IVotes.sol"; +import "@openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin-contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol"; +import "./IZeroExVotes.sol"; + +contract ZeroExVotes is IZeroExVotes, Initializable, OwnableUpgradeable, UUPSUpgradeable { + address public immutable token; + uint256 public immutable quadraticThreshold; + + mapping(address => Checkpoint[]) internal _checkpoints; + Checkpoint[] private _totalSupplyCheckpoints; + + constructor(address _token, uint256 _quadraticThreshold) { + require(_token != address(0), "ZeroExVotes: token cannot be 0"); + token = _token; + quadraticThreshold = _quadraticThreshold; + _disableInitializers(); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + modifier onlyToken() { + require(msg.sender == token, "ZeroExVotes: only token allowed"); + _; + } + + function initialize() public virtual onlyProxy initializer { + __Ownable_init(); + __UUPSUpgradeable_init(); + } + + /** + * @inheritdoc IZeroExVotes + */ + function checkpoints(address account, uint32 pos) public view returns (Checkpoint memory) { + return _checkpoints[account][pos]; + } + + /** + * @inheritdoc IZeroExVotes + */ + function numCheckpoints(address account) public view returns (uint32) { + return SafeCast.toUint32(_checkpoints[account].length); + } + + /** + * @inheritdoc IZeroExVotes + */ + function getVotes(address account) public view returns (uint256) { + uint256 pos = _checkpoints[account].length; + return pos == 0 ? 0 : _checkpoints[account][pos - 1].votes; + } + + /** + * @inheritdoc IZeroExVotes + */ + function getQuadraticVotes(address account) public view returns (uint256) { + uint256 pos = _checkpoints[account].length; + return pos == 0 ? 0 : _checkpoints[account][pos - 1].quadraticVotes; + } + + /** + * @inheritdoc IZeroExVotes + */ + function getPastVotes(address account, uint256 blockNumber) public view returns (uint256) { + require(blockNumber < block.number, "ZeroExVotes: block not yet mined"); + + Checkpoint memory checkpoint = _checkpointsLookup(_checkpoints[account], blockNumber); + return checkpoint.votes; + } + + /** + * @inheritdoc IZeroExVotes + */ + function getPastQuadraticVotes(address account, uint256 blockNumber) public view returns (uint256) { + require(blockNumber < block.number, "ZeroExVotes: block not yet mined"); + + Checkpoint memory checkpoint = _checkpointsLookup(_checkpoints[account], blockNumber); + return checkpoint.quadraticVotes; + } + + /** + * @inheritdoc IZeroExVotes + */ + function getPastTotalSupply(uint256 blockNumber) public view returns (uint256) { + require(blockNumber < block.number, "ZeroExVotes: block not yet mined"); + + // Note that due to the disabled updates of `_totalSupplyCheckpoints` in `writeCheckpointTotalSupply` function + // this always returns 0. + Checkpoint memory checkpoint = _checkpointsLookup(_totalSupplyCheckpoints, blockNumber); + return checkpoint.votes; + } + + /** + * @inheritdoc IZeroExVotes + */ + function getPastQuadraticTotalSupply(uint256 blockNumber) public view returns (uint256) { + require(blockNumber < block.number, "ZeroExVotes: block not yet mined"); + + // Note that due to the disabled updates of `_totalSupplyCheckpoints` in `writeCheckpointTotalSupply` function + // this always returns 0. + Checkpoint memory checkpoint = _checkpointsLookup(_totalSupplyCheckpoints, blockNumber); + return checkpoint.quadraticVotes; + } + + /** + * @inheritdoc IZeroExVotes + */ + function moveVotingPower( + address src, + address dst, + uint256 srcBalance, + uint256 dstBalance, + uint96 srcBalanceLastUpdated, + uint96 dstBalanceLastUpdated, + uint256 amount + ) public virtual onlyToken returns (bool) { + if (src != dst) { + if (src != address(0)) { + ( + uint256 oldWeight, + uint256 newWeight, + uint256 oldQuadraticWeight, + uint256 newQuadraticWeight + ) = _writeCheckpoint(_checkpoints[src], _subtract, srcBalance, srcBalanceLastUpdated, amount); + + emit DelegateVotesChanged(src, oldWeight, newWeight); + emit DelegateQuadraticVotesChanged(src, oldQuadraticWeight, newQuadraticWeight); + } + + if (dst != address(0)) { + ( + uint256 oldWeight, + uint256 newWeight, + uint256 oldQuadraticWeight, + uint256 newQuadraticWeight + ) = _writeCheckpoint(_checkpoints[dst], _add, dstBalance, dstBalanceLastUpdated, amount); + + emit DelegateVotesChanged(dst, oldWeight, newWeight); + emit DelegateQuadraticVotesChanged(dst, oldQuadraticWeight, newQuadraticWeight); + } + } + return true; + } + + /** + * @inheritdoc IZeroExVotes + */ + function writeCheckpointTotalSupplyMint( + uint256 accountBalance, + uint256 amount + ) public virtual onlyToken returns (bool) { + (, uint256 newWeight, , uint256 newQuadraticWeight) = _writeCheckpoint( + _totalSupplyCheckpoints, + _add, + accountBalance, + 0, + amount + ); + + emit TotalSupplyChanged(newWeight, newQuadraticWeight); + return true; + } + + /** + * @inheritdoc IZeroExVotes + */ + function writeCheckpointTotalSupplyBurn( + uint256 accountBalance, + uint256 amount + ) public virtual onlyToken returns (bool) { + (, uint256 newWeight, , uint256 newQuadraticWeight) = _writeCheckpoint( + _totalSupplyCheckpoints, + _subtract, + accountBalance, + 0, + amount + ); + + emit TotalSupplyChanged(newWeight, newQuadraticWeight); + return true; + } + + /** + * @dev Lookup a value in a list of (sorted) checkpoints. + * Implementation as in openzeppelin/token/ERC20/extensions/ERC20Votes.sol except here we return the entire + * checkpoint rather than part of it + */ + function _checkpointsLookup( + Checkpoint[] storage ckpts, + uint256 blockNumber + ) internal view returns (Checkpoint memory) { + // We run a binary search to look for the earliest checkpoint taken after `blockNumber`. + // + // Initially we check if the block is recent to narrow the search range. + // During the loop, the index of the wanted checkpoint remains in the range [low-1, high). + // With each iteration, either `low` or `high` is moved towards the middle of the range to maintain the + // invariant. + // - If the middle checkpoint is after `blockNumber`, we look in [low, mid) + // - If the middle checkpoint is before or equal to `blockNumber`, we look in [mid+1, high) + // Once we reach a single value (when low == high), we've found the right checkpoint at the index high-1, if not + // out of bounds (in which case we're looking too far in the past and the result is 0). + // Note that if the latest checkpoint available is exactly for `blockNumber`, we end up with an index that is + // past the end of the array, so we technically don't find a checkpoint after `blockNumber`, but it works out + // the same. + uint256 length = ckpts.length; + + uint256 low = 0; + uint256 high = length; + + if (length > 5) { + uint256 mid = length - Math.sqrt(length); + if (_unsafeAccess(ckpts, mid).fromBlock > blockNumber) { + high = mid; + } else { + low = mid + 1; + } + } + + while (low < high) { + uint256 mid = Math.average(low, high); + if (_unsafeAccess(ckpts, mid).fromBlock > blockNumber) { + high = mid; + } else { + low = mid + 1; + } + } + + // Leaving here for posterity this is the original OZ implementation which we've replaced + // return high == 0 ? 0 : _unsafeAccess(ckpts, high - 1).votes; + Checkpoint memory checkpoint = high == 0 ? Checkpoint(0, 0, 0) : _unsafeAccess(ckpts, high - 1); + return checkpoint; + } + + function _writeCheckpoint( + Checkpoint[] storage ckpts, + function(uint256, uint256) view returns (uint256) op, + uint256 userBalance, + uint96 balanceLastUpdated, + uint256 delta + ) + internal + virtual + returns (uint256 oldWeight, uint256 newWeight, uint256 oldQuadraticWeight, uint256 newQuadraticWeight) + { + uint256 pos = ckpts.length; + + Checkpoint memory oldCkpt = pos == 0 ? Checkpoint(0, 0, 0) : _unsafeAccess(ckpts, pos - 1); + + oldWeight = oldCkpt.votes; + newWeight = op(oldWeight, delta); + + oldQuadraticWeight = oldCkpt.quadraticVotes; + + // Remove the entire sqrt userBalance from quadratic voting power. + // Note that `userBalance` is value _after_ transfer. + if (pos > 0) { + uint256 oldQuadraticVotingPower = userBalance <= quadraticThreshold + ? userBalance + : quadraticThreshold + Math.sqrt((userBalance - quadraticThreshold) * 1e18); + oldCkpt.quadraticVotes -= SafeCast.toUint96(oldQuadraticVotingPower); + } + + // if wallet > threshold, calculate quadratic power over the treshold only, below threshold is linear + uint256 newBalance = op(userBalance, delta); + uint256 newQuadraticBalance = newBalance <= quadraticThreshold + ? newBalance + : quadraticThreshold + Math.sqrt((newBalance - quadraticThreshold) * 1e18); + newQuadraticWeight = oldCkpt.quadraticVotes + newQuadraticBalance; + + if (pos > 0 && oldCkpt.fromBlock == block.number) { + Checkpoint storage chpt = _unsafeAccess(ckpts, pos - 1); + chpt.votes = SafeCast.toUint96(newWeight); + chpt.quadraticVotes = SafeCast.toUint96(newQuadraticWeight); + } else { + ckpts.push( + Checkpoint({ + fromBlock: SafeCast.toUint32(block.number), + votes: SafeCast.toUint96(newWeight), + quadraticVotes: SafeCast.toUint96(newQuadraticWeight) + }) + ); + } + } + + function _add(uint256 a, uint256 b) private pure returns (uint256) { + return a + b; + } + + function _subtract(uint256 a, uint256 b) private pure returns (uint256) { + return a - b; + } + + /** + * @dev Access an element of the array without performing bounds check. The position is assumed to be within bounds. + * Implementation from openzeppelin/token/ERC20/extensions/ERC20Votes.sol + * https://github.com/ethereum/solidity/issues/9117 + */ + function _unsafeAccess(Checkpoint[] storage ckpts, uint256 pos) internal pure returns (Checkpoint storage result) { + assembly ("memory-safe") { + mstore(0, ckpts.slot) + result.slot := add(keccak256(0, 0x20), pos) + } + } +} diff --git a/contracts/governance/test/BaseTest.t.sol b/contracts/governance/test/BaseTest.t.sol new file mode 100644 index 0000000000..05644bf8cf --- /dev/null +++ b/contracts/governance/test/BaseTest.t.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "@openzeppelin/token/ERC20/ERC20.sol"; +import "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol"; +import "./ZRXMock.sol"; +import "../src/ZRXWrappedToken.sol"; +import "../src/ZeroExVotes.sol"; +import "../src/ZeroExTimelock.sol"; +import "../src/ZeroExProtocolGovernor.sol"; +import "../src/ZeroExTreasuryGovernor.sol"; + +function predict(address deployer, uint256 nonce) pure returns (address) { + require(nonce > 0 && nonce < 128); + return address(uint160(uint256(keccak256(abi.encodePacked(bytes2(0xd694), deployer, bytes1(uint8(nonce))))))); +} + +contract BaseTest is Test { + address payable internal account1 = payable(vm.addr(1)); + address payable internal account2 = payable(vm.addr(2)); + address payable internal account3 = payable(vm.addr(3)); + address payable internal account4 = payable(vm.addr(4)); + address payable internal securityCouncil = payable(vm.addr(5)); + uint256 internal quadraticThreshold = 1000000e18; + + bytes32 internal constant DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + + constructor() { + vm.deal(account1, 1e20); + vm.deal(account2, 1e20); + vm.deal(account3, 1e20); + vm.deal(account4, 1e20); + vm.deal(securityCouncil, 1e20); + } + + function setupGovernance() + internal + returns (IERC20, ZRXWrappedToken, ZeroExVotes, ZeroExTimelock, ZeroExTimelock, address, address) + { + (IERC20 zrxToken, ZRXWrappedToken token, ZeroExVotes votes) = setupZRXWrappedToken(); + + vm.startPrank(account1); + address[] memory proposers = new address[](0); + address[] memory executors = new address[](0); + + ZeroExTimelock protocolTimelock = new ZeroExTimelock(3 days, proposers, executors, account1); + ZeroExProtocolGovernor protocolGovernor = new ZeroExProtocolGovernor( + IVotes(address(votes)), + protocolTimelock, + securityCouncil + ); + protocolTimelock.grantRole(protocolTimelock.PROPOSER_ROLE(), address(protocolGovernor)); + protocolTimelock.grantRole(protocolTimelock.EXECUTOR_ROLE(), address(protocolGovernor)); + protocolTimelock.grantRole(protocolTimelock.CANCELLER_ROLE(), address(protocolGovernor)); + + ZeroExTimelock treasuryTimelock = new ZeroExTimelock(2 days, proposers, executors, account1); + ZeroExTreasuryGovernor treasuryGovernor = new ZeroExTreasuryGovernor( + IVotes(address(votes)), + treasuryTimelock, + securityCouncil + ); + + treasuryTimelock.grantRole(treasuryTimelock.PROPOSER_ROLE(), address(treasuryGovernor)); + treasuryTimelock.grantRole(treasuryTimelock.EXECUTOR_ROLE(), address(treasuryGovernor)); + treasuryTimelock.grantRole(treasuryTimelock.CANCELLER_ROLE(), address(treasuryGovernor)); + vm.stopPrank(); + + return ( + zrxToken, + token, + votes, + protocolTimelock, + treasuryTimelock, + address(protocolGovernor), + address(treasuryGovernor) + ); + } + + function setupZRXWrappedToken() internal returns (IERC20, ZRXWrappedToken, ZeroExVotes) { + vm.startPrank(account1); + bytes memory _bytecode = vm.getCode("./ZRXToken.json"); + IERC20 zrxToken; + assembly { + zrxToken := create(0, add(_bytecode, 0x20), mload(_bytecode)) + } + address wTokenPrediction = predict(account1, vm.getNonce(account1) + 2); + ZeroExVotes votesImpl = new ZeroExVotes(wTokenPrediction, quadraticThreshold); + ERC1967Proxy votesProxy = new ERC1967Proxy(address(votesImpl), abi.encodeCall(votesImpl.initialize, ())); + ZRXWrappedToken wToken = new ZRXWrappedToken(zrxToken, ZeroExVotes(address(votesProxy))); + vm.stopPrank(); + + assert(address(wToken) == wTokenPrediction); + return (zrxToken, wToken, ZeroExVotes(address(votesProxy))); + } +} diff --git a/contracts/governance/test/CubeRoot.sol b/contracts/governance/test/CubeRoot.sol new file mode 100644 index 0000000000..34ba9a8d3d --- /dev/null +++ b/contracts/governance/test/CubeRoot.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +library CubeRoot { + /// @dev Returns the cube root of `x`. + /// Credit to pleasemarkdarkly under MIT license + // Originaly from https://github.com/pleasemarkdarkly/fei-protocol-core-hh/blob/main/contracts/utils/Roots.sol + function cbrt(uint y) internal pure returns (uint z) { + // Newton's method https://en.wikipedia.org/wiki/Cube_root#Numerical_methods + if (y > 7) { + z = y; + uint x = y / 3 + 1; + while (x < z) { + z = x; + x = (y / (x * x) + (2 * x)) / 3; + } + } else if (y != 0) { + z = 1; + } + } +} diff --git a/contracts/governance/test/ZRXMock.sol b/contracts/governance/test/ZRXMock.sol new file mode 100644 index 0000000000..9e9f71fc77 --- /dev/null +++ b/contracts/governance/test/ZRXMock.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "@openzeppelin/token/ERC20/ERC20.sol"; + +// TODO remove this contract and work with an instance of ZRX compiled with 0.4 +// when the following is resolved https://linear.app/0xproject/issue/PRO-44/zrx-artifact-is-incompatible-with-foundry +contract ZRXMock is ERC20 { + constructor() ERC20("0x Protocol Token", "ZRX") { + _mint(msg.sender, 10 ** 27); + } +} diff --git a/contracts/governance/test/ZRXWrappedTokenTest.t.sol b/contracts/governance/test/ZRXWrappedTokenTest.t.sol new file mode 100644 index 0000000000..a39dff9c31 --- /dev/null +++ b/contracts/governance/test/ZRXWrappedTokenTest.t.sol @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "./BaseTest.t.sol"; +import "../src/ZRXWrappedToken.sol"; +import "@openzeppelin/token/ERC20/ERC20.sol"; + +contract ZRXWrappedTokenTest is BaseTest { + IERC20 private token; + ZRXWrappedToken private wToken; + ZeroExVotes private votes; + + function setUp() public { + (token, wToken, votes, , , , ) = setupGovernance(); + vm.startPrank(account1); + token.transfer(account2, 100e18); + token.transfer(account3, 200e18); + vm.stopPrank(); + } + + function testShouldReturnCorrectSymbol() public { + string memory wZRXSymbol = wToken.symbol(); + assertEq(wZRXSymbol, "wZRX"); + } + + function testShouldReturnCorrectName() public { + string memory wZRXName = wToken.name(); + assertEq(wZRXName, "Wrapped ZRX"); + } + + function testShouldReturnCorrectNumberOfDecimals() public { + uint8 wZRXDecimals = wToken.decimals(); + assertEq(wZRXDecimals, 18); + } + + function testShouldBeAbleToWrapZRX() public { + vm.startPrank(account2); + + // Approve the wrapped token and deposit 1e18 ZRX + token.approve(address(wToken), 1e18); + wToken.depositFor(account2, 1e18); + + // Check the token balances even out + uint256 wTokenBalance = wToken.balanceOf(account2); + assertEq(wTokenBalance, 1e18); + uint256 tokenBalance = token.balanceOf(account2); + assertEq(tokenBalance, 100e18 - wTokenBalance); + } + + function testShouldBeAbleToUnwrapToZRX() public { + vm.startPrank(account2); + + // Approve the wrapped token and deposit 1e18 ZRX + token.approve(address(wToken), 1e18); + wToken.depositFor(account2, 1e18); + + // Withdraw 1e6 wZRX back to ZRX to own account + wToken.withdrawTo(account2, 1e6); + + // Check token balances even out + uint256 wTokenBalance = wToken.balanceOf(account2); + assertEq(wTokenBalance, 1e18 - 1e6); + uint256 tokenBalance = token.balanceOf(account2); + assertEq(tokenBalance, 100e18 - wTokenBalance); + } + + function testShouldBeAbleToUnwrapToZRXToAnotherAccount() public { + vm.startPrank(account2); + + // Approve the wrapped token and deposit 1e18 ZRX + token.approve(address(wToken), 1e18); + wToken.depositFor(account2, 1e18); + + // Withdraw 1e7 wZRX back to ZRX to account4 (which owns no tokens to start with) + wToken.withdrawTo(account4, 1e7); + + // Check token balances even out + uint256 wTokenBalance2 = wToken.balanceOf(account2); + assertEq(wTokenBalance2, 1e18 - 1e7); + + uint256 tokenBalance4 = token.balanceOf(account4); + assertEq(tokenBalance4, 1e7); + + uint256 tokenBalance2 = token.balanceOf(account2); + assertEq(tokenBalance2, 100e18 - wTokenBalance2 - tokenBalance4); + } + + function testWrappedZRXTotalsAreCorrect() public { + // Wrap 1e18 and check total supply is correct + vm.startPrank(account2); + token.approve(address(wToken), 1e18); + wToken.depositFor(account2, 1e18); + vm.stopPrank(); + uint256 wTokenBalance = wToken.totalSupply(); + assertEq(wTokenBalance, 1e18); + + // Wrap 2e18 more and check total supply is correct + vm.startPrank(account3); + token.approve(address(wToken), 2e18); + wToken.depositFor(account3, 2e18); + vm.stopPrank(); + wTokenBalance = wToken.totalSupply(); + assertEq(wTokenBalance, 1e18 + 2e18); + + // Unwrap 1e7 and check total supply is correct + vm.startPrank(account2); + wToken.withdrawTo(account2, 1e7); + vm.stopPrank(); + wTokenBalance = wToken.totalSupply(); + assertEq(wTokenBalance, 3e18 - 1e7); + + // Unwrap 8e17 and check total supply is correct + vm.startPrank(account2); + wToken.withdrawTo(account2, 8e17); + vm.stopPrank(); + wTokenBalance = wToken.totalSupply(); + assertEq(wTokenBalance, 3e18 - 1e7 - 8e17); + + // We are not keeping record of total balances so check they are zero + assertEq(votes.getPastTotalSupply(0), 0); + assertEq(votes.getPastQuadraticTotalSupply(0), 0); + } + + function testWhenMintingFirstTimeForAccountTotalSupplyCheckpointsAreCorrect() public { + vm.startPrank(account2); + + // Approve the wrapped token and deposit 1e18 ZRX + token.approve(address(wToken), 1e18); + vm.roll(2); + wToken.depositFor(account2, 1e18); + vm.roll(3); + + // Check the totals are correct + uint256 totalSupplyVotes = votes.getPastTotalSupply(2); + uint256 totalSupplyQuadraticVotes = votes.getPastQuadraticTotalSupply(2); + assertEq(totalSupplyVotes, 1e18); + assertEq(totalSupplyQuadraticVotes, 1e18); + } + + function testWhenMintingForAccountWithExistingBalanceTotalSupplyCheckpointsAreCorrect() public { + vm.startPrank(account2); + + // Approve the wrapped token and deposit 1e18 ZRX + token.approve(address(wToken), 5e18); + wToken.depositFor(account2, 1e18); + + vm.roll(2); + // Depost 3e18 more for the same account + wToken.depositFor(account2, 3e18); + vm.roll(3); + + // Check the totals are correct + uint256 totalSupplyVotes = votes.getPastTotalSupply(2); + uint256 totalSupplyQuadraticVotes = votes.getPastQuadraticTotalSupply(2); + assertEq(totalSupplyVotes, 4e18); + assertEq(totalSupplyQuadraticVotes, 4e18); + } + + function testWhenMintingForMultipleAccountsTotalSupplyCheckpointsAreCorrect() public { + // Deposit 1e18 ZRX by account2 + vm.startPrank(account2); + token.approve(address(wToken), 5e18); + wToken.depositFor(account2, 1e18); + vm.stopPrank(); + + // Deposit 2e18 ZRX by account3 + vm.startPrank(account3); + token.approve(address(wToken), 2e18); + wToken.depositFor(account3, 2e18); + vm.stopPrank(); + + // Deposit 4e18 ZRX by account2 + vm.startPrank(account2); + vm.roll(2); + wToken.depositFor(account2, 4e18); + vm.stopPrank(); + vm.roll(3); + + // Check the totals are correct + uint256 totalSupplyVotes = votes.getPastTotalSupply(2); + uint256 totalSupplyQuadraticVotes = votes.getPastQuadraticTotalSupply(2); + assertEq(totalSupplyVotes, 7e18); + assertEq(totalSupplyQuadraticVotes, 5e18 + 2e18); + } + + function testWhenBurningForMultipleAccountsTotalSupplyCheckpointsAreCorrect() public { + // Deposit 5e18 ZRX by account2 + vm.startPrank(account2); + token.approve(address(wToken), 5e18); + wToken.depositFor(account2, 5e18); + vm.stopPrank(); + + // Deposit 2e18 ZRX by account3 + vm.startPrank(account3); + token.approve(address(wToken), 2e18); + wToken.depositFor(account3, 2e18); + vm.stopPrank(); + + // Burn 4e18 ZRX by account2 + vm.startPrank(account2); + vm.roll(2); + wToken.withdrawTo(account2, 4e18); + vm.stopPrank(); + vm.roll(3); + + // Check the totals are correct + uint256 totalSupplyVotes = votes.getPastTotalSupply(2); + uint256 totalSupplyQuadraticVotes = votes.getPastQuadraticTotalSupply(2); + assertEq(totalSupplyVotes, 3e18); + assertEq(totalSupplyQuadraticVotes, 1e18 + 2e18); + } + + function testShouldBeAbleToTransferCorrectly() public { + assertEq(wToken.balanceOf(account4), 0); + + vm.startPrank(account2); + token.approve(address(wToken), 1e18); + wToken.depositFor(account2, 1e18); + wToken.transfer(account4, 1e17); + vm.stopPrank(); + + assertEq(wToken.balanceOf(account4), 1e17); + } + + function testShouldTransferVotingPowerWhenTransferringTokens() public { + // Account 2 wraps ZRX and delegates voting power to itself + vm.startPrank(account2); + token.approve(address(wToken), 10e18); + wToken.depositFor(account2, 10e18); + wToken.delegate(account2); + + wToken.transfer(account3, 3e18); + + assertEq(wToken.balanceOf(account2), 7e18); + assertEq(wToken.balanceOf(account3), 3e18); + + assertEq(votes.getVotes(account2), 7e18); + assertEq(votes.getQuadraticVotes(account2), 7e18); + + // Since account3 is not delegating to anyone, they should have no voting power + assertEq(votes.getVotes(account3), 0); + assertEq(votes.getQuadraticVotes(account3), 0); + } + + function testShouldUpdateVotingPowerWhenDepositing() public { + // Account 2 wraps ZRX and delegates voting power to itself + vm.startPrank(account2); + token.approve(address(wToken), 10e18); + wToken.depositFor(account2, 7e18); + wToken.delegate(account2); + + assertEq(votes.getVotes(account2), 7e18); + assertEq(votes.getQuadraticVotes(account2), 7e18); + + wToken.depositFor(account2, 2e18); + assertEq(votes.getVotes(account2), 9e18); + assertEq(votes.getQuadraticVotes(account2), 9e18); + } + + function testShouldUpdateVotingPowerWhenWithdrawing() public { + // Account 2 wraps ZRX and delegates voting power to itself + vm.startPrank(account2); + token.approve(address(wToken), 10e18); + wToken.depositFor(account2, 10e18); + wToken.delegate(account2); + + assertEq(votes.getVotes(account2), 10e18); + assertEq(votes.getQuadraticVotes(account2), 10e18); + + wToken.withdrawTo(account2, 2e18); + assertEq(votes.getVotes(account2), 8e18); + assertEq(votes.getQuadraticVotes(account2), 8e18); + } + + function testShouldSetDelegateBalanceLastUpdatedOnTransfer() public { + ZRXWrappedToken.DelegateInfo memory account2DelegateInfo = wToken.delegateInfo(account2); + assertEq(account2DelegateInfo.delegate, address(0)); + assertEq(account2DelegateInfo.balanceLastUpdated, 0); + + // Account 2 wraps ZRX and delegates voting power to account3 + vm.startPrank(account2); + token.approve(address(wToken), 10e18); + wToken.depositFor(account2, 10e18); + wToken.delegate(account3); + + account2DelegateInfo = wToken.delegateInfo(account2); + assertEq(account2DelegateInfo.delegate, account3); + assertEq(account2DelegateInfo.balanceLastUpdated, 1); // Set to the block.number + + vm.roll(3); + wToken.transfer(account3, 3e18); + + account2DelegateInfo = wToken.delegateInfo(account2); + assertEq(account2DelegateInfo.delegate, account3); + assertEq(account2DelegateInfo.balanceLastUpdated, 3); + } +} diff --git a/contracts/governance/test/ZeroExGovernorBaseTest.t.sol b/contracts/governance/test/ZeroExGovernorBaseTest.t.sol new file mode 100644 index 0000000000..05e82b5f17 --- /dev/null +++ b/contracts/governance/test/ZeroExGovernorBaseTest.t.sol @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF IZeroExGovernorANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "./BaseTest.t.sol"; +import "../src/IZeroExGovernor.sol"; +import "../src/ZeroExTimelock.sol"; +import "../src/ZeroExProtocolGovernor.sol"; +import "../src/ZRXWrappedToken.sol"; +import "@openzeppelin/token/ERC20/ERC20.sol"; +import "@openzeppelin/mocks/CallReceiverMock.sol"; + +abstract contract ZeroExGovernorBaseTest is BaseTest { + IERC20 public token; + ZRXWrappedToken internal wToken; + ZeroExVotes internal votes; + ZeroExTimelock internal timelock; + IZeroExGovernor internal governor; + CallReceiverMock internal callReceiverMock; + + string internal governorName; + uint256 internal proposalThreshold; + + event SecurityCouncilAssigned(address securityCouncil); + event SecurityCouncilEjected(); + + function initialiseAccounts() public { + vm.startPrank(account1); + token.transfer(account2, 10000000e18); + token.transfer(account3, 2000000e18); + token.transfer(account4, 3000000e18); + vm.stopPrank(); + + // Setup accounts 2,3 and 4 to vote + vm.startPrank(account2); + token.approve(address(wToken), 10000000e18); + wToken.depositFor(account2, 10000000e18); + wToken.delegate(account2); + vm.stopPrank(); + + vm.startPrank(account3); + token.approve(address(wToken), 2000000e18); + wToken.depositFor(account3, 2000000e18); + wToken.delegate(account3); + vm.stopPrank(); + + vm.startPrank(account4); + token.approve(address(wToken), 3000000e18); + wToken.depositFor(account4, 3000000e18); + wToken.delegate(account4); + vm.stopPrank(); + + callReceiverMock = new CallReceiverMock(); + } + + function setSecurityCouncil(address council) internal { + address[] memory targets = new address[](1); + targets[0] = address(governor); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSelector(governor.assignSecurityCouncil.selector, council); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Assign new security council"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Assign new security council"))); + vm.warp(governor.proposalEta(proposalId) + 1); + + // Execute proposal + governor.execute(targets, values, calldatas, keccak256("Assign new security council")); + + assertEq(governor.securityCouncil(), council); + } + + function testShouldReturnCorrectName() public { + assertEq(governor.name(), governorName); + } + + function testShouldReturnCorrectVotingDelay() public { + assertEq(governor.votingDelay(), 14400); + } + + function testShouldReturnCorrectVotingPeriod() public { + assertEq(governor.votingPeriod(), 50400); + } + + function testShouldReturnCorrectProposalThreshold() public { + assertEq(governor.proposalThreshold(), proposalThreshold); + } + + function testShouldReturnCorrectToken() public { + assertEq(address(governor.token()), address(votes)); + } + + function testShouldReturnCorrectTimelock() public { + assertEq(address(governor.timelock()), address(timelock)); + } + + function testShouldReturnCorrectSecurityCouncil() public { + assertEq(governor.securityCouncil(), securityCouncil); + } + + function testCanAssignSecurityCouncil() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(governor); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSelector(governor.assignSecurityCouncil.selector, account1); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Assign new security council"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Assign new security council"))); + vm.warp(governor.proposalEta(proposalId) + 1); + + // Execute proposal + vm.expectEmit(true, false, false, false); + emit SecurityCouncilAssigned(account1); + governor.execute(targets, values, calldatas, keccak256("Assign new security council")); + + assertEq(governor.securityCouncil(), account1); + } + + function testCannotAssignSecurityCouncilOutsideOfGovernance() public { + vm.expectRevert("Governor: onlyGovernance"); + governor.assignSecurityCouncil(account1); + } + + // This functionality is currently not enabled + // Leaving this test for potential future use. + function testFailSecurityCouncilAreEjectedAfterCancellingAProposal() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(callReceiverMock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature("mockFunction()"); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Proposal description"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + IGovernor.ProposalState state = governor.state(proposalId); + assertEq(uint256(state), uint256(IGovernor.ProposalState.Succeeded)); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Proposal description"))); + + // Cancel the proposal + vm.warp(governor.proposalEta(proposalId)); + + vm.prank(securityCouncil); + + vm.expectEmit(true, false, false, false); + emit SecurityCouncilEjected(); + governor.cancel(targets, values, calldatas, keccak256(bytes("Proposal description"))); + vm.stopPrank(); + + state = governor.state(proposalId); + assertEq(uint256(state), uint256(IGovernor.ProposalState.Canceled)); + + assertEq(governor.securityCouncil(), address(0)); + } + + function testWhenNoSecurityCouncilCannottSubmitProposals() public { + setSecurityCouncil(address(0)); + + address[] memory targets = new address[](1); + targets[0] = address(callReceiverMock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature("mockFunction()"); + + vm.expectRevert("SecurityCouncil: security council not assigned and this is not an assignment call"); + governor.propose(targets, values, calldatas, "Proposal description"); + } + + function testWhenNoSecurityCouncilCannotQueueSuccessfulProposals() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(callReceiverMock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature("mockFunction()"); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Proposal description"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Set security council to address(0) + setSecurityCouncil(address(0)); + + vm.expectRevert("SecurityCouncil: security council not assigned and this is not an assignment call"); + governor.queue(targets, values, calldatas, keccak256(bytes("Proposal description"))); + + IGovernor.ProposalState state = governor.state(proposalId); + assertEq(uint256(state), uint256(IGovernor.ProposalState.Succeeded)); + } + + function testWhenNoSecurityCouncilCanPassProposalToAssignSecurityCouncil() public { + setSecurityCouncil(address(0)); + + setSecurityCouncil(account1); + } + + function testCannotPassABadProposalToSetSecurityCouncil() public { + setSecurityCouncil(address(0)); + + address[] memory targets = new address[](2); + targets[0] = address(governor); + targets[1] = address(callReceiverMock); + + uint256[] memory values = new uint256[](2); + values[0] = 0; + values[1] = 0; + + bytes[] memory calldatas = new bytes[](2); + calldatas[0] = abi.encodeWithSelector(governor.assignSecurityCouncil.selector, account1); + calldatas[1] = abi.encodeWithSignature("mockFunction()"); + + vm.roll(2); + vm.startPrank(account2); + vm.expectRevert("SecurityCouncil: more than 1 transaction in proposal"); + governor.propose(targets, values, calldatas, "Assign new security council"); + } + + function testCanUpdateVotingDelaySetting() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(governor); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSelector(governor.setVotingDelay.selector, 3 days); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Increase voting delay to 3 days"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Increase voting delay to 3 days"))); + vm.warp(governor.proposalEta(proposalId) + 1); + // Execute proposal + governor.execute(targets, values, calldatas, keccak256("Increase voting delay to 3 days")); + + assertEq(governor.votingDelay(), 3 days); + } + + function testCanUpdateVotingPeriodSetting() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(governor); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSelector(governor.setVotingPeriod.selector, 14 days); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Increase voting period to 14 days"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Increase voting period to 14 days"))); + vm.warp(governor.proposalEta(proposalId) + 1); + // Execute proposal + governor.execute(targets, values, calldatas, keccak256("Increase voting period to 14 days")); + + assertEq(governor.votingPeriod(), 14 days); + } + + function testCanUpdateProposalThresholdSetting() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(governor); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSelector(governor.setProposalThreshold.selector, 2000000e18); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Increase proposal threshold to 2000000e18"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Increase proposal threshold to 2000000e18"))); + vm.warp(governor.proposalEta(proposalId) + 1); + // Execute proposal + governor.execute(targets, values, calldatas, keccak256("Increase proposal threshold to 2000000e18")); + + assertEq(governor.proposalThreshold(), 2000000e18); + } + + function testCanUpdateTimelockDelay() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(timelock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSelector(timelock.updateDelay.selector, 7 days); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Increase timelock delay to 7 days"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Increase timelock delay to 7 days"))); + vm.warp(governor.proposalEta(proposalId) + 1); + // Execute proposal + governor.execute(targets, values, calldatas, keccak256("Increase timelock delay to 7 days")); + + assertEq(timelock.getMinDelay(), 7 days); + } + + function testSupportsGovernanceInterfaces() public { + assertTrue(governor.supportsInterface(type(IGovernorTimelock).interfaceId)); + assertTrue(governor.supportsInterface(type(IGovernor).interfaceId)); + assertTrue(governor.supportsInterface(type(IERC1155Receiver).interfaceId)); + } +} diff --git a/contracts/governance/test/ZeroExMock.sol b/contracts/governance/test/ZeroExMock.sol new file mode 100644 index 0000000000..eb5ecf9297 --- /dev/null +++ b/contracts/governance/test/ZeroExMock.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.19; + +contract ZeroExMock { + mapping(bytes4 => address) public implementations; + + function rollback(bytes4 selector, address targetImpl) public { + implementations[selector] = targetImpl; + } +} diff --git a/contracts/governance/test/ZeroExProtocolGovernor.t.sol b/contracts/governance/test/ZeroExProtocolGovernor.t.sol new file mode 100644 index 0000000000..e6c34bcad1 --- /dev/null +++ b/contracts/governance/test/ZeroExProtocolGovernor.t.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "./ZeroExGovernorBaseTest.t.sol"; +import "./ZeroExMock.sol"; +import "../src/ZeroExProtocolGovernor.sol"; + +contract ZeroExProtocolGovernorTest is ZeroExGovernorBaseTest { + ZeroExProtocolGovernor internal protocolGovernor; + ZeroExMock internal zeroExMock; + uint256 internal quorum; + + event CallExecuted(bytes32 indexed id, uint256 indexed index, address target, uint256 value, bytes data); + + function setUp() public { + governorName = "ZeroExProtocolGovernor"; + proposalThreshold = 1000000e18; + quorum = 10000000e18; + + address governorAddress; + (token, wToken, votes, timelock, , governorAddress, ) = setupGovernance(); + governor = IZeroExGovernor(governorAddress); + protocolGovernor = ZeroExProtocolGovernor(payable(governorAddress)); + zeroExMock = new ZeroExMock(); + initialiseAccounts(); + } + + function testShouldReturnCorrectQuorum() public { + assertEq(governor.quorum(block.number), quorum); + } + + function testShouldBeAbleToExecuteASuccessfulProposal() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(callReceiverMock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature("mockFunction()"); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Proposal description"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + vm.prank(account3); + governor.castVote(proposalId, 0); // Vote "against" + vm.stopPrank(); + vm.prank(account4); + governor.castVote(proposalId, 2); // Vote "abstain" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Get vote results + (uint256 votesAgainst, uint256 votesFor, uint256 votesAbstain) = governor.proposalVotes(proposalId); + assertEq(votesFor, 10000000e18); + assertEq(votesAgainst, 2000000e18); + assertEq(votesAbstain, 3000000e18); + + IGovernor.ProposalState state = governor.state(proposalId); + assertEq(uint256(state), uint256(IGovernor.ProposalState.Succeeded)); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Proposal description"))); + vm.warp(governor.proposalEta(proposalId) + 1); + + governor.execute(targets, values, calldatas, keccak256("Proposal description")); + state = governor.state(proposalId); + assertEq(uint256(state), uint256(IGovernor.ProposalState.Executed)); + } + + function testSecurityCouncilShouldBeAbleToExecuteRollback() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(zeroExMock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + bytes4 testFunctionSig = 0xc853c969; + address testFunctionImpl = 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f; + calldatas[0] = abi.encodeWithSignature("rollback(bytes4,address)", testFunctionSig, testFunctionImpl); + + // Security council adds the batch of rollbacks to the queue + vm.startPrank(securityCouncil); + + bytes32 proposalId = timelock.hashOperationBatch( + targets, + values, + calldatas, + 0, + keccak256(bytes("Emergency rollback")) + ); + vm.expectEmit(true, true, true, true); + emit CallExecuted(proposalId, 0, targets[0], values[0], calldatas[0]); + + // This functionality is currently not enabled + // Leaving this test for potential future use. + // vm.expectEmit(true, false, false, false); + // emit SecurityCouncilEjected(); + + protocolGovernor.executeRollback(targets, values, calldatas, keccak256(bytes("Emergency rollback"))); + } + + function testSecurityCouncilShouldNotBeAbleToExecuteArbitraryFunctions() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(callReceiverMock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature("mockFunction()"); + + vm.startPrank(securityCouncil); + vm.expectRevert("ZeroExTimelock: not rollback"); + protocolGovernor.executeRollback(targets, values, calldatas, keccak256(bytes("Proposal description"))); + } + + function testRollbackShouldNotBeExecutableByNonSecurityCouncilAccounts() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(zeroExMock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + bytes4 testFunctionSig = 0xc853c969; + address testFunctionImpl = 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f; + calldatas[0] = abi.encodeWithSignature("rollback(bytes4,address)", testFunctionSig, testFunctionImpl); + + vm.startPrank(account2); + vm.expectRevert("ZeroExProtocolGovernor: only security council allowed"); + protocolGovernor.executeRollback(targets, values, calldatas, keccak256(bytes("Emergency rollback"))); + } +} diff --git a/contracts/governance/test/ZeroExTreasuryGovernor.t.sol b/contracts/governance/test/ZeroExTreasuryGovernor.t.sol new file mode 100644 index 0000000000..2a214c930f --- /dev/null +++ b/contracts/governance/test/ZeroExTreasuryGovernor.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "./ZeroExGovernorBaseTest.t.sol"; + +contract ZeroExTreasuryGovernorTest is ZeroExGovernorBaseTest { + function setUp() public { + governorName = "ZeroExTreasuryGovernor"; + proposalThreshold = 250000e18; + + address governorAddress; + (token, wToken, votes, , timelock, , governorAddress) = setupGovernance(); + governor = IZeroExGovernor(governorAddress); + + initialiseAccounts(); + } + + function testShouldReturnCorrectQuorum() public { + vm.roll(3); + uint256 totalSupplyQuadraticVotes = quadraticThreshold * + 3 + + Math.sqrt((10000000e18 - quadraticThreshold) * 1e18) + + Math.sqrt((2000000e18 - quadraticThreshold) * 1e18) + + Math.sqrt((3000000e18 - quadraticThreshold) * 1e18); + uint256 quorum = (totalSupplyQuadraticVotes * 10) / 100; + assertEq(governor.quorum(2), quorum); + } + + function testShouldBeAbleToExecuteASuccessfulProposal() public { + // Create a proposal + address[] memory targets = new address[](1); + targets[0] = address(callReceiverMock); + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature("mockFunction()"); + + vm.roll(2); + vm.startPrank(account2); + uint256 proposalId = governor.propose(targets, values, calldatas, "Proposal description"); + vm.stopPrank(); + + // Fast forward to after vote start + vm.roll(governor.proposalSnapshot(proposalId) + 1); + + // Vote + vm.prank(account2); + governor.castVote(proposalId, 1); // Vote "for" + vm.stopPrank(); + vm.prank(account3); + governor.castVote(proposalId, 0); // Vote "against" + vm.stopPrank(); + vm.prank(account4); + governor.castVote(proposalId, 2); // Vote "abstain" + vm.stopPrank(); + + // Fast forward to vote end + vm.roll(governor.proposalDeadline(proposalId) + 1); + + // Get vote results + (uint256 votesAgainst, uint256 votesFor, uint256 votesAbstain) = governor.proposalVotes(proposalId); + assertEq(votesFor, (quadraticThreshold + Math.sqrt((10000000e18 - quadraticThreshold) * 1e18))); + assertEq(votesAgainst, quadraticThreshold + Math.sqrt((2000000e18 - quadraticThreshold) * 1e18)); + assertEq(votesAbstain, quadraticThreshold + Math.sqrt((3000000e18 - quadraticThreshold) * 1e18)); + + IGovernor.ProposalState state = governor.state(proposalId); + assertEq(uint256(state), uint256(IGovernor.ProposalState.Succeeded)); + + // Queue proposal + governor.queue(targets, values, calldatas, keccak256(bytes("Proposal description"))); + vm.warp(governor.proposalEta(proposalId) + 1); + + governor.execute(targets, values, calldatas, keccak256("Proposal description")); + state = governor.state(proposalId); + assertEq(uint256(state), uint256(IGovernor.ProposalState.Executed)); + } +} diff --git a/contracts/governance/test/ZeroExVotesMalicious.sol b/contracts/governance/test/ZeroExVotesMalicious.sol new file mode 100644 index 0000000000..bfbcc2b7d1 --- /dev/null +++ b/contracts/governance/test/ZeroExVotesMalicious.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "../src/ZeroExVotes.sol"; + +contract ZeroExVotesMalicious is ZeroExVotes { + constructor(address _token, uint256 _quadraticThreshold) ZeroExVotes(_token, _quadraticThreshold) {} + + function writeCheckpointTotalSupplyBurn( + uint256 amount, + uint256 accountBalance + ) public virtual override onlyToken returns (bool) { + revert("I am evil"); + } +} diff --git a/contracts/governance/test/ZeroExVotesMigration.sol b/contracts/governance/test/ZeroExVotesMigration.sol new file mode 100644 index 0000000000..318078d3fb --- /dev/null +++ b/contracts/governance/test/ZeroExVotesMigration.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import {ZeroExVotes} from "../src/ZeroExVotes.sol"; +import {SafeCast} from "@openzeppelin/utils/math/SafeCast.sol"; +import {Math} from "@openzeppelin/utils/math/Math.sol"; +import {CubeRoot} from "./CubeRoot.sol"; + +contract ZeroExVotesMigration is ZeroExVotes { + uint32 public migrationBlock; + + constructor(address _token, uint256 _quadraticThreshold) ZeroExVotes(_token, _quadraticThreshold) {} + + function initialize() public virtual override onlyProxy reinitializer(2) { + migrationBlock = uint32(block.number); + } + + struct CheckpointMigration { + uint32 fromBlock; + uint96 votes; + uint96 quadraticVotes; + uint32 migratedVotes; + } + + function _toMigration(Checkpoint storage ckpt) internal pure returns (CheckpointMigration storage result) { + assembly { + result.slot := ckpt.slot + } + } + + function _toMigration(Checkpoint[] storage ckpt) internal pure returns (CheckpointMigration[] storage result) { + assembly { + result.slot := ckpt.slot + } + } + + function getMigratedVotes(address account) public view returns (uint256) { + uint256 pos = _checkpoints[account].length; + if (pos == 0) { + return 0; + } + Checkpoint storage ckpt = _unsafeAccess(_checkpoints[account], pos - 1); + if (ckpt.fromBlock <= migrationBlock) { + return 0; + } + return _toMigration(ckpt).migratedVotes; + } + + function getPastMigratedVotes(address account, uint256 blockNumber) public view returns (uint256) { + require(blockNumber < block.number, "ZeroExVotesMigration: block not yet mined"); + if (blockNumber <= migrationBlock) { + return 0; + } + + Checkpoint storage checkpoint = _checkpointsLookupStorage(_checkpoints[account], blockNumber); + if (checkpoint.fromBlock <= migrationBlock) { + return 0; + } + return _toMigration(checkpoint).migratedVotes; + } + + function _checkpointsLookupStorage( + Checkpoint[] storage ckpts, + uint256 blockNumber + ) internal view returns (Checkpoint storage result) { + // We run a binary search to look for the earliest checkpoint taken after `blockNumber`. + // + // Initially we check if the block is recent to narrow the search range. + // During the loop, the index of the wanted checkpoint remains in the range [low-1, high). + // With each iteration, either `low` or `high` is moved towards the middle of the range to maintain the + // invariant. + // - If the middle checkpoint is after `blockNumber`, we look in [low, mid) + // - If the middle checkpoint is before or equal to `blockNumber`, we look in [mid+1, high) + // Once we reach a single value (when low == high), we've found the right checkpoint at the index high-1, if not + // out of bounds (in which case we're looking too far in the past and the result is 0). + // Note that if the latest checkpoint available is exactly for `blockNumber`, we end up with an index that is + // past the end of the array, so we technically don't find a checkpoint after `blockNumber`, but it works out + // the same. + uint256 length = ckpts.length; + + uint256 low = 0; + uint256 high = length; + + if (length > 5) { + uint256 mid = length - Math.sqrt(length); + if (_unsafeAccess(ckpts, mid).fromBlock > blockNumber) { + high = mid; + } else { + low = mid + 1; + } + } + + while (low < high) { + uint256 mid = Math.average(low, high); + if (_unsafeAccess(ckpts, mid).fromBlock > blockNumber) { + high = mid; + } else { + low = mid + 1; + } + } + + // Leaving here for posterity this is the original OZ implementation which we've replaced + // return high == 0 ? 0 : _unsafeAccess(ckpts, high - 1).votes; + // Checkpoint memory checkpoint = high == 0 ? Checkpoint(0, 0, 0) : _unsafeAccess(ckpts, high - 1); + // return checkpoint; + // TODO: bad. very bad. only works on accident + if (high > 0) { + result = _unsafeAccess(ckpts, high - 1); + } else { + // suppress compiler warning, which really shouldn't be suppressed + assembly { + result.slot := 0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF + } + } + } + + // TODO: we're not handling totalSupply + + // TODO: need to return the migrated weight + function _writeCheckpoint( + Checkpoint[] storage ckpts, + function(uint256, uint256) view returns (uint256) op, + uint256 userBalance, + uint96 balanceLastUpdated, + uint256 delta + ) + internal + virtual + override + returns (uint256 oldWeight, uint256 newWeight, uint256 oldQuadraticWeight, uint256 newQuadraticWeight) + { + uint256 pos = ckpts.length; + + CheckpointMigration memory oldCkpt = pos == 0 + ? CheckpointMigration(0, 0, 0, 0) + : _toMigration(_unsafeAccess(ckpts, pos - 1)); + + oldWeight = oldCkpt.votes; + newWeight = op(oldWeight, delta); + + oldQuadraticWeight = oldCkpt.quadraticVotes; + + if (pos > 0) { + deductOldWeightFromCheckpoint(oldCkpt, userBalance, balanceLastUpdated); + } + + // if wallet > threshold, calculate quadratic power over the treshold only, below threshold is linear + uint256 newBalance = op(userBalance, delta); + uint256 newQuadraticBalance = newBalance <= quadraticThreshold + ? newBalance + : quadraticThreshold + Math.sqrt((newBalance - quadraticThreshold) * 1e18); + newQuadraticWeight = oldCkpt.quadraticVotes + newQuadraticBalance; + uint256 newMigratedWeight = oldCkpt.migratedVotes + CubeRoot.cbrt(newBalance); + + if (pos > 0 && oldCkpt.fromBlock == block.number) { + addCheckpoint(ckpts, pos, newWeight, newQuadraticWeight, newMigratedWeight); + } else { + _toMigration(ckpts).push( + CheckpointMigration({ + fromBlock: SafeCast.toUint32(block.number), + votes: SafeCast.toUint96(newWeight), + quadraticVotes: SafeCast.toUint96(newQuadraticWeight), + migratedVotes: SafeCast.toUint32(newMigratedWeight) + }) + ); + } + } + + function deductOldWeightFromCheckpoint( + CheckpointMigration memory oldCkpt, + uint256 userBalance, + uint96 balanceLastUpdated + ) internal { + // Remove the entire sqrt userBalance from quadratic voting power. + // Note that `userBalance` is value _after_ transfer. + uint256 oldQuadraticVotingPower = userBalance <= quadraticThreshold + ? userBalance + : quadraticThreshold + Math.sqrt((userBalance - quadraticThreshold) * 1e18); + oldCkpt.quadraticVotes -= SafeCast.toUint96(oldQuadraticVotingPower); + + if (balanceLastUpdated > migrationBlock) { + oldCkpt.migratedVotes -= SafeCast.toUint32(CubeRoot.cbrt(userBalance)); + } + } + + function addCheckpoint( + Checkpoint[] storage ckpts, + uint256 pos, + uint256 newWeight, + uint256 newQuadraticWeight, + uint256 newMigratedWeight + ) internal { + CheckpointMigration storage chpt = _toMigration(_unsafeAccess(ckpts, pos - 1)); + chpt.votes = SafeCast.toUint96(newWeight); + chpt.quadraticVotes = SafeCast.toUint96(newQuadraticWeight); + chpt.migratedVotes = SafeCast.toUint32(newMigratedWeight); + } +} diff --git a/contracts/governance/test/ZeroExVotesTest.t.sol b/contracts/governance/test/ZeroExVotesTest.t.sol new file mode 100644 index 0000000000..97729f0c46 --- /dev/null +++ b/contracts/governance/test/ZeroExVotesTest.t.sol @@ -0,0 +1,433 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2023 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ +pragma solidity ^0.8.19; + +import "@openzeppelin/token/ERC20/ERC20.sol"; +import "./BaseTest.t.sol"; +import "./ZeroExVotesMalicious.sol"; +import "./ZeroExVotesMigration.sol"; +import "../src/ZRXWrappedToken.sol"; + +contract ZeroExVotesTest is BaseTest { + IERC20 private token; + ZRXWrappedToken private wToken; + ZeroExVotes private votes; + + function setUp() public { + (token, wToken, votes) = setupZRXWrappedToken(); + vm.startPrank(account1); + token.transfer(account2, 1700000e18); + token.transfer(account3, 1600000e18); + token.transfer(account4, 1000000e18); + vm.stopPrank(); + } + + function testShouldCorrectlyInitialiseToken() public { + assertEq(votes.token(), address(wToken)); + } + + function testShouldNotBeAbleToReinitialise() public { + vm.expectRevert("Initializable: contract is already initialized"); + votes.initialize(); + } + + function testShouldBeAbleToMigrate() public { + vm.roll(block.number + 1); + + vm.startPrank(account2); + token.approve(address(wToken), 100e18); + wToken.depositFor(account2, 100e18); + wToken.delegate(account3); + vm.stopPrank(); + + vm.startPrank(account3); + token.approve(address(wToken), 200e18); + wToken.depositFor(account3, 200e18); + wToken.delegate(account3); + vm.stopPrank(); + + assertEq(votes.getVotes(account3), 300e18); + assertEq(votes.getQuadraticVotes(account3), 300e18); + + vm.roll(block.number + 1); + + ZeroExVotesMigration newImpl = new ZeroExVotesMigration(address(wToken), quadraticThreshold); + assertFalse( + address( + uint160( + uint256(vm.load(address(votes), 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc)) + ) + ) == address(newImpl) + ); + vm.prank(account1); + votes.upgradeToAndCall(address(newImpl), abi.encodeWithSignature("initialize()")); + assertEq( + address( + uint160( + uint256(vm.load(address(votes), 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc)) + ) + ), + address(newImpl) + ); + + ZeroExVotesMigration upgradedVotes = ZeroExVotesMigration(address(votes)); + + assertEq(upgradedVotes.getVotes(account3), 300e18); + assertEq(upgradedVotes.getQuadraticVotes(account3), 300e18); + + vm.roll(block.number + 1); + + vm.prank(account2); + wToken.transfer(address(this), 50e18); + + assertEq(upgradedVotes.getVotes(account3), 250e18); + assertEq(upgradedVotes.getQuadraticVotes(account3), 250e18); + assertEq(upgradedVotes.getMigratedVotes(account3), CubeRoot.cbrt(50e18)); + + vm.prank(account3); + wToken.transfer(address(this), 100e18); + assertEq(upgradedVotes.getVotes(account3), 150e18); + assertEq(upgradedVotes.getQuadraticVotes(account3), 150e18); + assertEq(upgradedVotes.getMigratedVotes(account3), CubeRoot.cbrt(50e18) + CubeRoot.cbrt(100e18)); + } + + function testShouldNotBeAbleToStopBurn() public { + // wrap some token + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1700000e18); + vm.stopPrank(); + assertEq(token.balanceOf(account2), 0); + assertEq(wToken.balanceOf(account2), 1700000e18); + + // malicious upgrade + vm.startPrank(account1); + IZeroExVotes maliciousImpl = new ZeroExVotesMalicious(votes.token(), votes.quadraticThreshold()); + votes.upgradeTo(address(maliciousImpl)); + vm.stopPrank(); + + // try to withdraw withdraw + vm.prank(account2); + wToken.withdrawTo(account2, 1700000e18); + assertEq(token.balanceOf(account2), 1700000e18); + assertEq(wToken.balanceOf(account2), 0); + } + + function testShouldBeAbleToReadCheckpoints() public { + // Account 2 wraps ZRX and delegates voting power to account3 + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1700000e18); + vm.roll(2); + wToken.delegate(account3); + + assertEq(votes.numCheckpoints(account3), 1); + + IZeroExVotes.Checkpoint memory checkpoint = votes.checkpoints(account3, 0); + assertEq(checkpoint.fromBlock, 2); + assertEq(checkpoint.votes, 1700000e18); + assertEq(checkpoint.quadraticVotes, quadraticThreshold + Math.sqrt((1700000e18 - quadraticThreshold) * 1e18)); + } + + function testShouldBeAbleToSelfDelegateVotingPower() public { + // Check voting power initially is 0 + assertEq(votes.getVotes(account2), 0); + assertEq(votes.getQuadraticVotes(account2), 0); + + // Wrap ZRX and delegate voting power to themselves + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1700000e18); + wToken.delegate(account2); + + // Check voting power + assertEq(votes.getVotes(account2), 1700000e18); + assertEq( + votes.getQuadraticVotes(account2), + quadraticThreshold + Math.sqrt((1700000e18 - quadraticThreshold) * 1e18) + ); + } + + function testShouldBeAbleToDelegateVotingPowerToAnotherAccount() public { + // Check voting power initially is 0 + assertEq(votes.getVotes(account3), 0); + assertEq(votes.getQuadraticVotes(account3), 0); + + // Account 2 wraps ZRX and delegates voting power to account3 + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1700000e18); + wToken.delegate(account3); + + // Check voting power + assertEq(votes.getVotes(account3), 1700000e18); + assertEq( + votes.getQuadraticVotes(account3), + quadraticThreshold + Math.sqrt((1700000e18 - quadraticThreshold) * 1e18) + ); + } + + function testShouldBeAbleToDelegateVotingPowerToAnotherAccountWithSignature() public { + uint256 nonce = 0; + uint256 expiry = type(uint256).max; + uint256 privateKey = 2; + + // Account 2 wraps ZRX and delegates voting power to account3 + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1700000e18); + vm.stopPrank(); + + assertEq(wToken.delegates(account2), address(0)); + assertEq(votes.getVotes(account3), 0); + assertEq(votes.getQuadraticVotes(account3), 0); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + privateKey, + keccak256( + abi.encodePacked( + "\x19\x01", + wToken.DOMAIN_SEPARATOR(), + keccak256(abi.encode(DELEGATION_TYPEHASH, account3, nonce, expiry)) + ) + ) + ); + wToken.delegateBySig(account3, nonce, expiry, v, r, s); + + assertEq(wToken.delegates(account2), account3); + assertEq(votes.getVotes(account3), 1700000e18); + assertEq( + votes.getQuadraticVotes(account3), + quadraticThreshold + Math.sqrt((1700000e18 - quadraticThreshold) * 1e18) + ); + } + + function testShouldNotBeAbleToDelegateWithSignatureAfterExpiry() public { + uint256 nonce = 0; + uint256 expiry = block.timestamp - 1; + uint256 privateKey = 2; + + // Account 2 wraps ZRX and delegates voting power to account3 + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1700000e18); + vm.stopPrank(); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + privateKey, + keccak256( + abi.encodePacked( + "\x19\x01", + wToken.DOMAIN_SEPARATOR(), + keccak256(abi.encode(DELEGATION_TYPEHASH, account3, nonce, expiry)) + ) + ) + ); + + vm.expectRevert("ERC20Votes: signature expired"); + wToken.delegateBySig(account3, nonce, expiry, v, r, s); + } + + function testMultipleAccountsShouldBeAbleToDelegateVotingPowerToAccountWithNoTokensOnSameBlock() public { + // Check account4 voting power initially is 0 + assertEq(votes.getVotes(account4), 0); + assertEq(votes.getQuadraticVotes(account4), 0); + + // Account 2 wraps ZRX and delegates voting power to account4 + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1700000e18); + wToken.delegate(account4); + vm.stopPrank(); + + // Account 3 also wraps ZRX and delegates voting power to account4 + vm.startPrank(account3); + token.approve(address(wToken), 1600000e18); + wToken.depositFor(account3, 1600000e18); + wToken.delegate(account4); + vm.stopPrank(); + + // Check voting power + assertEq(votes.getVotes(account4), 3300000e18); + assertEq( + votes.getQuadraticVotes(account4), + quadraticThreshold * + 2 + + Math.sqrt((1700000e18 - quadraticThreshold) * 1e18) + + Math.sqrt((1600000e18 - quadraticThreshold) * 1e18) + ); + } + + function testMultipleAccountsShouldBeAbleToDelegateVotingPowerToAccountWithNoTokensOnDifferentBlock() public { + // Check account4 voting power initially is 0 + assertEq(votes.getVotes(account4), 0); + assertEq(votes.getQuadraticVotes(account4), 0); + + // Account 2 wraps ZRX and delegates voting power to account4 + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1700000e18); + wToken.delegate(account4); + vm.stopPrank(); + + // Different block height + vm.roll(2); + // Account 3 also wraps ZRX and delegates voting power to account4 + vm.startPrank(account3); + token.approve(address(wToken), 1600000e18); + wToken.depositFor(account3, 1600000e18); + wToken.delegate(account4); + vm.stopPrank(); + + // Check voting power + assertEq(votes.getVotes(account4), 3300000e18); + assertEq( + votes.getQuadraticVotes(account4), + quadraticThreshold * + 2 + + Math.sqrt((1700000e18 - quadraticThreshold) * 1e18) + + Math.sqrt((1600000e18 - quadraticThreshold) * 1e18) + ); + } + + function testComplexDelegationScenario() public { + // Account 2 wraps ZRX and delegates to itself + vm.startPrank(account2); + token.approve(address(wToken), 1700000e18); + wToken.depositFor(account2, 1000000e18); + wToken.delegate(account2); + vm.stopPrank(); + + assertEq(votes.getVotes(account2), 1000000e18); + assertEq(votes.getQuadraticVotes(account2), 1000000e18); + + // Account 3 wraps ZRX and delegates to account4 + vm.startPrank(account3); + token.approve(address(wToken), 500000e18); + wToken.depositFor(account3, 500000e18); + wToken.delegate(account4); + vm.stopPrank(); + + assertEq(votes.getVotes(account4), 500000e18); + assertEq(votes.getQuadraticVotes(account4), 500000e18); + + // Voting power distribution now is as follows + // account2 -> account2 1000000e18 | 1000000e18 + // account3 -> account4 500000e18 | 500000e18 + + // Account 2 deposits the remaining 700000e18 and delegates to account3 + vm.startPrank(account2); + wToken.depositFor(account2, 700000e18); + wToken.delegate(account3); + vm.stopPrank(); + + assertEq(votes.getVotes(account3), 1700000e18); + assertEq( + votes.getQuadraticVotes(account3), + quadraticThreshold + Math.sqrt((1700000e18 - quadraticThreshold) * 1e18) + ); + + // Voting power distribution now is as follows + // account2 -> account3 1700000e18 | 1000000e18 + Math.sqrt((1700000e18 - 1000000e18) * 1e18) + // account3 -> account4 500000e18 | 500000e18 + + // Account 3 delegates to itself + vm.startPrank(account3); + wToken.delegate(account3); + vm.stopPrank(); + + assertEq(votes.getVotes(account3), 2200000e18); + assertEq( + votes.getQuadraticVotes(account3), + quadraticThreshold + Math.sqrt((1700000e18 - quadraticThreshold) * 1e18) + 500000e18 + ); + + // Voting power distribution now is as follows + // account2, account3 -> account3 2200000e18 | 1000000e18 + Math.sqrt((2200000e18-1000000e18) *1e18) + 500000e18 + + // Check account2 and account4 no longer have voting power + assertEq(votes.getVotes(account2), 0); + assertEq(votes.getQuadraticVotes(account2), 0); + assertEq(votes.getVotes(account4), 0); + assertEq(votes.getQuadraticVotes(account4), 0); + } + + function testCheckpointIsCorrectlyUpdatedOnTheSameBlock() public { + // Account 2 wraps ZRX and delegates 20e18 to itself + vm.startPrank(account2); + token.approve(address(wToken), 20e18); + wToken.depositFor(account2, 20e18); + wToken.delegate(account2); + vm.stopPrank(); + + assertEq(votes.numCheckpoints(account2), 1); + IZeroExVotes.Checkpoint memory checkpoint1Account2 = votes.checkpoints(account2, 0); + assertEq(checkpoint1Account2.fromBlock, 1); + assertEq(checkpoint1Account2.votes, 20e18); + assertEq(checkpoint1Account2.quadraticVotes, 20e18); + + // Account 3 wraps ZRX and delegates 10e18 to account2 + vm.startPrank(account3); + token.approve(address(wToken), 10e18); + wToken.depositFor(account3, 10e18); + wToken.delegate(account2); + vm.stopPrank(); + + assertEq(votes.numCheckpoints(account2), 1); + checkpoint1Account2 = votes.checkpoints(account2, 0); + assertEq(checkpoint1Account2.fromBlock, 1); + assertEq(checkpoint1Account2.votes, 30e18); + assertEq(checkpoint1Account2.quadraticVotes, 20e18 + 10e18); + } + + function testCheckpointIsCorrectlyUpdatedOnDifferentBlocks() public { + // Account 2 wraps ZRX and delegates 20e18 to itself + vm.startPrank(account2); + token.approve(address(wToken), 20e18); + wToken.depositFor(account2, 20e18); + wToken.delegate(account2); + vm.stopPrank(); + + assertEq(votes.numCheckpoints(account2), 1); + IZeroExVotes.Checkpoint memory checkpoint1Account2 = votes.checkpoints(account2, 0); + assertEq(checkpoint1Account2.fromBlock, 1); + assertEq(checkpoint1Account2.votes, 20e18); + assertEq(checkpoint1Account2.quadraticVotes, 20e18); + + vm.roll(2); + // Account 3 wraps ZRX and delegates 10e18 to account2 + vm.startPrank(account3); + token.approve(address(wToken), 10e18); + wToken.depositFor(account3, 10e18); + wToken.delegate(account2); + vm.stopPrank(); + + assertEq(votes.numCheckpoints(account2), 2); + IZeroExVotes.Checkpoint memory checkpoint2Account2 = votes.checkpoints(account2, 1); + assertEq(checkpoint2Account2.fromBlock, 2); + assertEq(checkpoint2Account2.votes, 30e18); + assertEq(checkpoint2Account2.quadraticVotes, 20e18 + 10e18); + + // Check the old checkpoint hasn't changed + checkpoint1Account2 = votes.checkpoints(account2, 0); + assertEq(checkpoint1Account2.fromBlock, 1); + assertEq(checkpoint1Account2.votes, 20e18); + assertEq(checkpoint1Account2.quadraticVotes, 20e18); + } +} diff --git a/package.json b/package.json index 6d43c22480..4d135dd4a3 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "test:all": "wsrun --fast-exit --serial --exclude-missing -p $PKG -c test", "test:contracts": "wsrun --serial -p $(echo ${npm_package_config_contractsPackages} ${npm_package_config_ignoreTestsForPackages} | tr ' ' '\n' | sort | uniq -u | tr '\n' ' ') --fast-exit --exclude-missing -c test", "test:contracts:all": "wsrun --serial -p ${npm_package_config_contractsPackages} --fast-exit --exclude-missing -c test", - "test:links": "yarn check-md --ignore **/forge-std/README.md,**/lib/openzeppelin-contracts,**/node_modules", + "test:links": "yarn check-md --ignore **/forge-std/README.md,**/lib/openzeppelin-contracts,**/node_modules,**/lib", "generate_doc": "node ./node_modules/@0x/monorepo-scripts/lib/doc_generate.js --config ./doc-gen-config.json", "upload_md_docs": "aws s3 rm --recursive s3://docs-markdown; wsrun --exclude-missing -c s3:sync_md_docs", "diff_md_docs:ci": "wsrun --exclude-missing -c diff_docs", @@ -93,5 +93,7 @@ "resolutions": { "**/bignumber.js": "^9.0.2" }, - "dependencies": {} + "dependencies": { + "@openzeppelin/contracts": "^4.8.1" + } } diff --git a/yarn.lock b/yarn.lock index 55aa646505..918c1abad3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2775,6 +2775,11 @@ dependencies: "@octokit/openapi-types" "^12.11.0" +"@openzeppelin/contracts@^4.8.1": + version "4.8.1" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.8.1.tgz#709cfc4bbb3ca9f4460d60101f15dac6b7a2d5e4" + integrity sha512-xQ6eUZl+RDyb/FiZe1h+U7qr/f4p/SrTSQcTPH2bjur3C5DbuW/zFgCU/b1P/xcIaEqJep+9ju4xDRi3rmChdQ== + "@sindresorhus/slugify@^0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@sindresorhus/slugify/-/slugify-0.8.0.tgz#5550b7fa064f3a8a82651463ad635378054c72d0"