diff --git a/.gitignore b/.gitignore index 5d303d3831..0495e2f5d6 100644 --- a/.gitignore +++ b/.gitignore @@ -113,6 +113,8 @@ contracts/dev-utils/generated-artifacts/ contracts/dev-utils/test/generated-artifacts/ contracts/zero-ex/generated-artifacts/ contracts/zero-ex/test/generated-artifacts/ +contracts/treasury/generated-artifacts/ +contracts/treasury/test/generated-artifacts/ # generated truffle contract artifacts/ contracts/broker/build/ @@ -167,6 +169,8 @@ contracts/dev-utils/generated-wrappers/ contracts/dev-utils/test/generated-wrappers/ contracts/zero-ex/generated-wrappers/ contracts/zero-ex/test/generated-wrappers/ +contracts/treasury/generated-wrappers/ +contracts/treasury/test/generated-wrappers/ # Doc README copy packages/*/docs/README.md diff --git a/.prettierignore b/.prettierignore index c1cd4f7413..b316fa8865 100644 --- a/.prettierignore +++ b/.prettierignore @@ -64,6 +64,10 @@ lib /contracts/zero-ex/test/generated-wrappers /contracts/zero-ex/generated-artifacts /contracts/zero-ex/test/generated-artifacts +/contracts/treasury/generated-wrappers +/contracts/treasury/test/generated-wrappers +/contracts/treasury/generated-artifacts +/contracts/treasury/test/generated-artifacts /contracts/staking/build/ /contracts/coordinator/build/ /contracts/exchange/build/ diff --git a/contracts/.solhint.json b/contracts/.solhint.json index 3d89418c4e..3be459e776 100644 --- a/contracts/.solhint.json +++ b/contracts/.solhint.json @@ -13,7 +13,6 @@ "indent": ["error", 4], "max-line-length": ["warn", 160], "no-inline-assembly": false, - "no-empty-blocks": false, "quotes": ["error", "double"], "separate-by-one-line-in-contract": "error", "space-after-comma": "error", diff --git a/contracts/treasury/.npmignore b/contracts/treasury/.npmignore new file mode 100644 index 0000000000..bdf2b8acbe --- /dev/null +++ b/contracts/treasury/.npmignore @@ -0,0 +1,10 @@ +# Blacklist all files +.* +* +# Whitelist lib +!lib/**/* +# Whitelist Solidity contracts +!contracts/src/**/* +# Blacklist tests in lib +/lib/test/* +# Package specific ignore diff --git a/contracts/treasury/CHANGELOG.json b/contracts/treasury/CHANGELOG.json new file mode 100644 index 0000000000..91f3300b52 --- /dev/null +++ b/contracts/treasury/CHANGELOG.json @@ -0,0 +1,11 @@ +[ + { + "version": "1.0.0", + "changes": [ + { + "note": "Create this package", + "pr": 120 + } + ] + } +] diff --git a/contracts/treasury/DEPLOYS.json b/contracts/treasury/DEPLOYS.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/contracts/treasury/DEPLOYS.json @@ -0,0 +1 @@ +[] diff --git a/contracts/treasury/README.md b/contracts/treasury/README.md new file mode 100644 index 0000000000..3752591e29 --- /dev/null +++ b/contracts/treasury/README.md @@ -0,0 +1,65 @@ +## Governance + +This package contains contracts for the governance of the 0x ZRX treasury. + +## Installation + +**Install** + +```bash +npm install @0x/contracts-treasury --save +``` + +## Contributing + +We strongly recommend that the community help us make improvements and determine the future direction of the protocol. To report bugs within this package, please create an issue in this repository. + +For proposals regarding the 0x protocol's smart contract architecture, message format, or additional functionality, go to the [0x Improvement Proposals (ZEIPs)](https://github.com/0xProject/ZEIPs) repository and follow the contribution guidelines provided therein. + +Please read our [contribution guidelines](../../CONTRIBUTING.md) before getting started. + +### Install Dependencies + +If you don't have yarn workspaces enabled (Yarn < v1.0) - enable them: + +```bash +yarn config set workspaces-experimental true +``` + +Then install dependencies + +```bash +yarn install +``` + +### Build + +To build this package and all other monorepo packages that it depends on, run the following from the monorepo root directory: + +```bash +PKG=@0x/contracts-treasury yarn build +``` + +Or continuously rebuild on change: + +```bash +PKG=@0x/contracts-treasury yarn watch +``` + +### Clean + +```bash +yarn clean +``` + +### Lint + +```bash +yarn lint +``` + +### Run Tests + +```bash +yarn test +``` diff --git a/contracts/treasury/compiler.json b/contracts/treasury/compiler.json new file mode 100644 index 0000000000..435dd39830 --- /dev/null +++ b/contracts/treasury/compiler.json @@ -0,0 +1,29 @@ +{ + "artifactsDir": "./test/generated-artifacts", + "contractsDir": "./contracts", + "useDockerisedSolc": false, + "isOfflineMode": false, + "shouldSaveStandardInput": true, + "shouldCompileIndependently": true, + "compilerSettings": { + "evmVersion": "istanbul", + "optimizer": { + "enabled": true, + "runs": 1000000, + "details": { "yul": true, "deduplicate": true, "cse": true, "constantOptimizer": true } + }, + "outputSelection": { + "*": { + "*": [ + "abi", + "devdoc", + "evm.bytecode.object", + "evm.bytecode.sourceMap", + "evm.deployedBytecode.object", + "evm.deployedBytecode.sourceMap", + "evm.methodIdentifiers" + ] + } + } + } +} diff --git a/contracts/treasury/contracts/src/DefaultPoolOperator.sol b/contracts/treasury/contracts/src/DefaultPoolOperator.sol new file mode 100644 index 0000000000..d9115aa710 --- /dev/null +++ b/contracts/treasury/contracts/src/DefaultPoolOperator.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2021 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.6.12; +pragma experimental ABIEncoderV2; + +import "@0x/contracts-erc20/contracts/src/v06/IERC20TokenV06.sol"; +import "@0x/contracts-erc20/contracts/src/v06/LibERC20TokenV06.sol"; +import "./IStaking.sol"; + + +contract DefaultPoolOperator { + using LibERC20TokenV06 for IERC20TokenV06; + + // Immutables + IStaking public immutable stakingProxy; + IERC20TokenV06 public immutable weth; + bytes32 public immutable poolId; + + /// @dev Initializes this contract and creates a staking pool. + /// @param stakingProxy_ The 0x staking proxy contract. + /// @param weth_ The WETH token contract. + constructor( + IStaking stakingProxy_, + IERC20TokenV06 weth_ + ) + public + { + stakingProxy = stakingProxy_; + weth = weth_; + // operator share = 100% + poolId = stakingProxy_.createStakingPool(10 ** 6, false); + } + + /// @dev Sends this contract's entire WETH balance to the + /// staking proxy contract. This function exists in case + /// someone joins the default staking pool and starts + /// market making for some reason, thus earning this contract + /// some staking rewards. Note that anyone can call this + /// function at any time. + function returnStakingRewards() + external + { + uint256 wethBalance = weth.compatBalanceOf(address(this)); + weth.compatTransfer(address(stakingProxy), wethBalance); + } +} diff --git a/contracts/treasury/contracts/src/IStaking.sol b/contracts/treasury/contracts/src/IStaking.sol new file mode 100644 index 0000000000..37ed738fd9 --- /dev/null +++ b/contracts/treasury/contracts/src/IStaking.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2021 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.6.12; +pragma experimental ABIEncoderV2; + + +interface IStaking { + /// @dev Statuses that stake can exist in. + /// Any stake can be (re)delegated effective at the next epoch + /// Undelegated stake can be withdrawn if it is available in both the current and next epoch + enum StakeStatus { + UNDELEGATED, + DELEGATED + } + + /// @dev Encapsulates a balance for the current and next epochs. + /// Note that these balances may be stale if the current epoch + /// is greater than `currentEpoch`. + /// @param currentEpoch The current epoch + /// @param currentEpochBalance Balance in the current epoch. + /// @param nextEpochBalance Balance in `currentEpoch+1`. + struct StoredBalance { + uint64 currentEpoch; + uint96 currentEpochBalance; + uint96 nextEpochBalance; + } + + /// @dev Holds the metadata for a staking pool. + /// @param operator Operator of the pool. + /// @param operatorShare Fraction of the total balance owned by the operator, in ppm. + struct Pool { + address operator; + uint32 operatorShare; + } + + /// @dev Create a new staking pool. The sender will be the operator of this pool. + /// Note that an operator must be payable. + /// @param operatorShare Portion of rewards owned by the operator, in ppm. + /// @param addOperatorAsMaker Adds operator to the created pool as a maker for convenience iff true. + /// @return poolId The unique pool id generated for this pool. + function createStakingPool(uint32 operatorShare, bool addOperatorAsMaker) + external + returns (bytes32 poolId); + + /// @dev Returns the current staking epoch number. + /// @return epoch The current epoch. + function currentEpoch() + external + view + returns (uint256 epoch); + + /// @dev Returns the time (in seconds) at which the current staking epoch started. + /// @return startTime The start time of the current epoch, in seconds. + function currentEpochStartTimeInSeconds() + external + view + returns (uint256 startTime); + + /// @dev Returns the duration of an epoch in seconds. This value can be updated. + /// @return duration The duration of an epoch, in seconds. + function epochDurationInSeconds() + external + view + returns (uint256 duration); + + /// @dev Returns a staking pool + /// @param poolId Unique id of pool. + function getStakingPool(bytes32 poolId) + external + view + returns (Pool memory); + + /// @dev Gets global stake for a given status. + /// @param stakeStatus UNDELEGATED or DELEGATED + /// @return balance Global stake for given status. + function getGlobalStakeByStatus(StakeStatus stakeStatus) + external + view + returns (StoredBalance memory balance); + + /// @dev Gets an owner's stake balances by status. + /// @param staker Owner of stake. + /// @param stakeStatus UNDELEGATED or DELEGATED + /// @return balance Owner's stake balances for given status. + function getOwnerStakeByStatus( + address staker, + StakeStatus stakeStatus + ) + external + view + returns (StoredBalance memory balance); + + /// @dev Returns the total stake delegated to a specific staking pool, + /// across all members. + /// @param poolId Unique Id of pool. + /// @return balance Total stake delegated to pool. + function getTotalStakeDelegatedToPool(bytes32 poolId) + external + view + returns (StoredBalance memory balance); + + /// @dev Returns the stake delegated to a specific staking pool, by a given staker. + /// @param staker of stake. + /// @param poolId Unique Id of pool. + /// @return balance Stake delegated to pool by staker. + function getStakeDelegatedToPoolByOwner(address staker, bytes32 poolId) + external + view + returns (StoredBalance memory balance); +} diff --git a/contracts/treasury/contracts/src/IZrxTreasury.sol b/contracts/treasury/contracts/src/IZrxTreasury.sol new file mode 100644 index 0000000000..27627de135 --- /dev/null +++ b/contracts/treasury/contracts/src/IZrxTreasury.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2021 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.6.12; +pragma experimental ABIEncoderV2; + +import "./DefaultPoolOperator.sol"; +import "./IStaking.sol"; + + +interface IZrxTreasury { + + struct TreasuryParameters { + uint256 votingPeriod; + uint256 proposalThreshold; + uint256 quorumThreshold; + } + + struct ProposedAction { + address target; + bytes data; + uint256 value; + } + + struct Proposal { + bytes32 actionsHash; + uint256 executionEpoch; + uint256 voteEpoch; + uint256 votesFor; + uint256 votesAgainst; + bool executed; + } + + event ProposalCreated( + address proposer, + bytes32[] operatedPoolIds, + uint256 proposalId, + ProposedAction[] actions, + uint256 executionEpoch, + string description + ); + + event VoteCast( + address voter, + bytes32[] operatedPoolIds, + uint256 proposalId, + bool support, + uint256 votingPower + ); + + event ProposalExecuted(uint256 proposalId); + + function stakingProxy() + external + view + returns (IStaking); + + function defaultPoolOperator() + external + view + returns (DefaultPoolOperator); + + function defaultPoolId() + external + view + returns (bytes32); + + function votingPeriod() + external + view + returns (uint256); + + function proposalThreshold() + external + view + returns (uint256); + + function quorumThreshold() + external + view + returns (uint256); + + /// @dev Creates a proposal to send ZRX from this treasury on the + /// the given actions. Must have at least `proposalThreshold` + /// of voting power to call this function. See `getVotingPower` + /// for how voting power is computed. If a proposal is successfully + /// created, voting starts at the epoch after next (currentEpoch + 2). + /// If the vote passes, the proposal is executable during the + /// `executionEpoch`. See `hasProposalPassed` for the passing criteria. + /// @param actions The proposed ZRX actions. An action specifies a + /// contract call. + /// @param executionEpoch The epoch during which the proposal is to + /// be executed if it passes. Must be at least two epochs + /// from the current epoch. + /// @param description A text description for the proposal. + /// @param operatedPoolIds The pools operated by `msg.sender`. The + /// ZRX currently delegated to those pools will be accounted + /// for in the voting power. + /// @return proposalId The ID of the newly created proposal. + function propose( + ProposedAction[] calldata actions, + uint256 executionEpoch, + string calldata description, + bytes32[] calldata operatedPoolIds + ) + external + returns (uint256 proposalId); + + /// @dev Casts a vote for the given proposal. Only callable + /// during the voting period for that proposal. See + /// `getVotingPower` for how voting power is computed. + /// @param proposalId The ID of the proposal to vote on. + /// @param support Whether to support the proposal or not. + /// @param operatedPoolIds The pools operated by `msg.sender`. The + /// ZRX currently delegated to those pools will be accounted + /// for in the voting power. + function castVote( + uint256 proposalId, + bool support, + bytes32[] calldata operatedPoolIds + ) + external; + + /// @dev Executes a proposal that has passed and is + /// currently executable. + /// @param proposalId The ID of the proposal to execute. + /// @param actions Actions associated with the proposal to execute. + function execute(uint256 proposalId, ProposedAction[] memory actions) + external + payable; + + /// @dev Returns the total number of proposals. + /// @return count The number of proposals. + function proposalCount() + external + view + returns (uint256 count); + + /// @dev Computes the current voting power of the given account. + /// Voting power is equal to: + /// (ZRX delegated to the default pool) + + /// 0.5 * (ZRX delegated to other pools) + + /// 0.5 * (ZRX delegated to pools operated by account) + /// @param account The address of the account. + /// @param operatedPoolIds The pools operated by `account`. The + /// ZRX currently delegated to those pools will be accounted + /// for in the voting power. + /// @return votingPower The current voting power of the given account. + function getVotingPower(address account, bytes32[] calldata operatedPoolIds) + external + view + returns (uint256 votingPower); +} diff --git a/contracts/treasury/contracts/src/ZrxTreasury.sol b/contracts/treasury/contracts/src/ZrxTreasury.sol new file mode 100644 index 0000000000..65325e91ae --- /dev/null +++ b/contracts/treasury/contracts/src/ZrxTreasury.sol @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + + Copyright 2021 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.6.12; +pragma experimental ABIEncoderV2; + +import "@0x/contracts-erc20/contracts/src/v06/IERC20TokenV06.sol"; +import "@0x/contracts-erc20/contracts/src/v06/LibERC20TokenV06.sol"; +import "@0x/contracts-utils/contracts/src/v06/LibBytesV06.sol"; +import "@0x/contracts-utils/contracts/src/v06/LibSafeMathV06.sol"; +import "@0x/contracts-utils/contracts/src/v06/errors/LibRichErrorsV06.sol"; +import "@0x/contracts-zero-ex/contracts/src/features/libs/LibSignature.sol"; +import "./IZrxTreasury.sol"; + + +contract ZrxTreasury is + IZrxTreasury +{ + using LibERC20TokenV06 for IERC20TokenV06; + using LibSafeMathV06 for uint256; + using LibRichErrorsV06 for bytes; + using LibBytesV06 for bytes; + + // Immutables + IStaking public immutable override stakingProxy; + DefaultPoolOperator public immutable override defaultPoolOperator; + bytes32 public immutable override defaultPoolId; + uint256 public immutable override votingPeriod; + uint256 public immutable override proposalThreshold; + uint256 public immutable override quorumThreshold; + + // Storage + Proposal[] public proposals; + mapping (uint256 => mapping (address => bool)) public hasVoted; + + /// @dev Initializes the ZRX treasury and creates the default + /// staking pool. + /// @param stakingProxy_ The 0x staking proxy contract. + /// @param weth_ The WETH token contract. + /// @param params Immutable treasury parameters. + constructor( + IStaking stakingProxy_, + IERC20TokenV06 weth_, + TreasuryParameters memory params + ) + public + { + require( + params.votingPeriod < stakingProxy_.epochDurationInSeconds(), + "VOTING_PERIOD_TOO_LONG" + ); + stakingProxy = stakingProxy_; + DefaultPoolOperator defaultPoolOperator_ = new DefaultPoolOperator( + stakingProxy_, + weth_ + ); + defaultPoolOperator = defaultPoolOperator_; + defaultPoolId = defaultPoolOperator_.poolId(); + votingPeriod = params.votingPeriod; + proposalThreshold = params.proposalThreshold; + quorumThreshold = params.quorumThreshold; + } + + // solhint-disable + /// @dev Allows this contract to receive ether. + receive() external payable {} + // solhint-enable + + /// @dev Creates a proposal to send ZRX from this treasury on the + /// the given actions. Must have at least `proposalThreshold` + /// of voting power to call this function. See `getVotingPower` + /// for how voting power is computed. If a proposal is successfully + /// created, voting starts at the epoch after next (currentEpoch + 2). + /// If the vote passes, the proposal is executable during the + /// `executionEpoch`. See `hasProposalPassed` for the passing criteria. + /// @param actions The proposed ZRX actions. An action specifies a + /// contract call. + /// @param executionEpoch The epoch during which the proposal is to + /// be executed if it passes. Must be at least two epochs + /// from the current epoch. + /// @param description A text description for the proposal. + /// @param operatedPoolIds The pools operated by `msg.sender`. The + /// ZRX currently delegated to those pools will be accounted + /// for in the voting power. + /// @return proposalId The ID of the newly created proposal. + function propose( + ProposedAction[] memory actions, + uint256 executionEpoch, + string memory description, + bytes32[] memory operatedPoolIds + ) + public + override + returns (uint256 proposalId) + { + require( + getVotingPower(msg.sender, operatedPoolIds) >= proposalThreshold, + "propose/INSUFFICIENT_VOTING_POWER" + ); + require( + actions.length > 0, + "propose/NO_ACTIONS_PROPOSED" + ); + uint256 currentEpoch = stakingProxy.currentEpoch(); + require( + executionEpoch >= currentEpoch + 2, + "propose/INVALID_EXECUTION_EPOCH" + ); + + proposalId = proposalCount(); + Proposal storage newProposal = proposals.push(); + newProposal.actionsHash = keccak256(abi.encode(actions)); + newProposal.executionEpoch = executionEpoch; + newProposal.voteEpoch = currentEpoch + 2; + + emit ProposalCreated( + msg.sender, + operatedPoolIds, + proposalId, + actions, + executionEpoch, + description + ); + } + + /// @dev Casts a vote for the given proposal. Only callable + /// during the voting period for that proposal. See + /// `getVotingPower` for how voting power is computed. + /// @param proposalId The ID of the proposal to vote on. + /// @param support Whether to support the proposal or not. + /// @param operatedPoolIds The pools operated by `msg.sender`. The + /// ZRX currently delegated to those pools will be accounted + /// for in the voting power. + function castVote( + uint256 proposalId, + bool support, + bytes32[] memory operatedPoolIds + ) + public + override + { + if (proposalId >= proposalCount()) { + revert("castVote/INVALID_PROPOSAL_ID"); + } + if (hasVoted[proposalId][msg.sender]) { + revert("castVote/ALREADY_VOTED"); + } + + Proposal memory proposal = proposals[proposalId]; + if ( + proposal.voteEpoch != stakingProxy.currentEpoch() || + _hasVoteEnded(proposal.voteEpoch) + ) { + revert("castVote/VOTING_IS_CLOSED"); + } + + uint256 votingPower = getVotingPower(msg.sender, operatedPoolIds); + if (votingPower == 0) { + revert("castVote/NO_VOTING_POWER"); + } + + if (support) { + proposals[proposalId].votesFor = proposals[proposalId].votesFor + .safeAdd(votingPower); + hasVoted[proposalId][msg.sender] = true; + } else { + proposals[proposalId].votesAgainst = proposals[proposalId].votesAgainst + .safeAdd(votingPower); + hasVoted[proposalId][msg.sender] = true; + } + + emit VoteCast( + msg.sender, + operatedPoolIds, + proposalId, + support, + votingPower + ); + } + + /// @dev Executes a proposal that has passed and is + /// currently executable. + /// @param proposalId The ID of the proposal to execute. + /// @param actions Actions associated with the proposal to execute. + function execute(uint256 proposalId, ProposedAction[] memory actions) + public + payable + override + { + if (proposalId >= proposalCount()) { + revert("execute/INVALID_PROPOSAL_ID"); + } + Proposal memory proposal = proposals[proposalId]; + _assertProposalExecutable(proposal, actions); + + proposals[proposalId].executed = true; + + for (uint256 i = 0; i != actions.length; i++) { + ProposedAction memory action = actions[i]; + (bool didSucceed, ) = action.target.call{value: action.value}(action.data); + require( + didSucceed, + "execute/ACTION_EXECUTION_FAILED" + ); + } + + emit ProposalExecuted(proposalId); + } + + /// @dev Returns the total number of proposals. + /// @return count The number of proposals. + function proposalCount() + public + override + view + returns (uint256 count) + { + return proposals.length; + } + + /// @dev Computes the current voting power of the given account. + /// Voting power is equal to: + /// (ZRX delegated to the default pool) + + /// 0.5 * (ZRX delegated to other pools) + + /// 0.5 * (ZRX delegated to pools operated by account) + /// @param account The address of the account. + /// @param operatedPoolIds The pools operated by `account`. The + /// ZRX currently delegated to those pools will be accounted + /// for in the voting power. + /// @return votingPower The current voting power of the given account. + function getVotingPower(address account, bytes32[] memory operatedPoolIds) + public + override + view + returns (uint256 votingPower) + { + uint256 delegatedBalance = stakingProxy.getOwnerStakeByStatus( + account, + IStaking.StakeStatus.DELEGATED + ).currentEpochBalance; + uint256 balanceDelegatedToDefaultPool = stakingProxy.getStakeDelegatedToPoolByOwner( + account, + defaultPoolId + ).currentEpochBalance; + + // Voting power for ZRX delegated to the default pool is not diluted, + // so we double-count the balance delegated to the default pool before + // dividing by 2. + votingPower = delegatedBalance + .safeAdd(balanceDelegatedToDefaultPool) + .safeDiv(2); + + // Add voting power for operated staking pools. + for (uint256 i = 0; i != operatedPoolIds.length; i++) { + IStaking.Pool memory pool = stakingProxy.getStakingPool(operatedPoolIds[i]); + require( + pool.operator == account, + "getVotingPower/POOL_NOT_OPERATED_BY_ACCOUNT" + ); + uint96 stakeDelegatedToPool = stakingProxy + .getTotalStakeDelegatedToPool(operatedPoolIds[i]) + .currentEpochBalance; + uint256 poolVotingPower = uint256(stakeDelegatedToPool).safeDiv(2); + votingPower = votingPower.safeAdd(poolVotingPower); + } + + return votingPower; + } + + /// @dev Checks whether the given proposal is executable. + /// Reverts if not. + /// @param proposal The proposal to check. + function _assertProposalExecutable( + Proposal memory proposal, + ProposedAction[] memory actions + ) + private + view + { + require( + keccak256(abi.encode(actions)) == proposal.actionsHash, + "_assertProposalExecutable/INVALID_ACTIONS" + ); + require( + _hasProposalPassed(proposal), + "_assertProposalExecutable/PROPOSAL_HAS_NOT_PASSED" + ); + require( + !proposal.executed, + "_assertProposalExecutable/PROPOSAL_ALREADY_EXECUTED" + ); + require( + stakingProxy.currentEpoch() == proposal.executionEpoch, + "_assertProposalExecutable/CANNOT_EXECUTE_THIS_EPOCH" + ); + } + + /// @dev Checks whether the given proposal has passed or not. + /// @param proposal The proposal to check. + /// @return hasPassed Whether the proposal has passed. + function _hasProposalPassed(Proposal memory proposal) + private + view + returns (bool hasPassed) + { + // Proposal is not passed until the vote is over. + if (!_hasVoteEnded(proposal.voteEpoch)) { + return false; + } + // Must have >50% support. + if (proposal.votesFor <= proposal.votesAgainst) { + return false; + } + // Must reach quorum threshold. + if (proposal.votesFor < quorumThreshold) { + return false; + } + return true; + } + + /// @dev Checks whether a vote starting at the given + /// epoch has ended or not. + /// @param voteEpoch The epoch at which the vote started. + /// @return hasEnded Whether the vote has ended. + function _hasVoteEnded(uint256 voteEpoch) + private + view + returns (bool hasEnded) + { + uint256 currentEpoch = stakingProxy.currentEpoch(); + if (currentEpoch < voteEpoch) { + return false; + } + if (currentEpoch > voteEpoch) { + return true; + } + // voteEpoch == currentEpoch + // Vote ends at currentEpochStartTime + votingPeriod + uint256 voteEndTime = stakingProxy + .currentEpochStartTimeInSeconds() + .safeAdd(votingPeriod); + return block.timestamp > voteEndTime; + } +} diff --git a/contracts/treasury/package.json b/contracts/treasury/package.json new file mode 100644 index 0000000000..9798c0471c --- /dev/null +++ b/contracts/treasury/package.json @@ -0,0 +1,88 @@ +{ + "name": "@0x/contracts-treasury", + "version": "0.1.0", + "engines": { + "node": ">=6.12" + }, + "description": "Smart contracts for governing the 0x ZRX treasury", + "main": "lib/src/index.js", + "directories": { + "test": "test" + }, + "scripts": { + "build": "yarn pre_build && yarn build:ts", + "build:ci": "yarn build", + "build:ts": "tsc -b", + "pre_build": "run-s compile contracts:gen generate_contract_wrappers contracts:copy", + "test": "yarn run_mocha", + "rebuild_and_test": "run-s build test", + "run_mocha": "mocha --require source-map-support/register --require make-promises-safe 'lib/test/**/*.js' --timeout 100000 --bail --exit", + "compile": "sol-compiler", + "watch": "sol-compiler -w", + "clean": "shx rm -rf lib test/generated-artifacts test/generated-wrappers generated-artifacts generated-wrappers", + "generate_contract_wrappers": "abi-gen --debug --abis ${npm_package_config_abis} --output test/generated-wrappers --backend ethers", + "lint": "tslint --format stylish --project . --exclude ./generated-wrappers/**/* --exclude ./test/generated-wrappers/**/* --exclude ./generated-artifacts/**/* --exclude ./test/generated-artifacts/**/* --exclude **/lib/**/* && yarn lint-contracts", + "fix": "tslint --fix --format stylish --project . --exclude ./generated-wrappers/**/* --exclude ./generated-artifacts/**/* --exclude ./test/generated-wrappers/**/* --exclude ./test/generated-artifacts/**/* --exclude **/lib/**/* && yarn lint-contracts", + "test:circleci": "yarn test", + "contracts:gen": "contracts-gen generate", + "contracts:copy": "contracts-gen copy", + "lint-contracts": "#solhint -c ../.solhint.json contracts/**/**/**/**/*.sol", + "docs:md": "ts-doc-gen --sourceDir='$PROJECT_FILES' --output=$MD_FILE_DIR --fileExtension=mdx --tsconfig=./typedoc-tsconfig.json", + "docs:json": "typedoc --excludePrivate --excludeExternals --excludeProtected --ignoreCompilerErrors --target ES5 --tsconfig typedoc-tsconfig.json --json $JSON_FILE_PATH $PROJECT_FILES", + "publish:private": "yarn build && gitpkg publish" + }, + "config": { + "publicInterfaceContracts": "ZrxTreasury,DefaultPoolOperator", + "abis:comment": "This list is auto-generated by contracts-gen. Don't edit manually.", + "abis": "./test/generated-artifacts/@(DefaultPoolOperator|IStaking|IZrxTreasury|ZrxTreasury).json" + }, + "repository": { + "type": "git", + "url": "https://github.com/0xProject/protocol.git" + }, + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/0xProject/protocol/issues" + }, + "homepage": "https://github.com/0xProject/protocol/tree/main/contracts/treasury", + "devDependencies": { + "@0x/abi-gen": "^5.4.13", + "@0x/contract-addresses": "^5.8.0", + "@0x/contracts-asset-proxy": "^3.7.3", + "@0x/contracts-erc20": "^3.3.0", + "@0x/contracts-gen": "^2.0.24", + "@0x/contracts-staking": "^2.0.29", + "@0x/contracts-test-utils": "^5.3.18", + "@0x/sol-compiler": "^4.4.1", + "@0x/ts-doc-gen": "^0.0.28", + "@0x/tslint-config": "^4.1.3", + "@types/isomorphic-fetch": "^0.0.35", + "@types/lodash": "4.14.104", + "@types/mocha": "^5.2.7", + "@types/prompts": "^2.0.9", + "isomorphic-fetch": "^3.0.0", + "lodash": "^4.17.11", + "mocha": "^6.2.0", + "npm-run-all": "^4.1.2", + "prompts": "^2.4.0", + "shx": "^0.2.2", + "solhint": "^1.4.1", + "tslint": "5.11.0", + "typedoc": "~0.16.11", + "typescript": "3.0.1" + }, + "dependencies": { + "@0x/base-contract": "^6.2.14", + "@0x/protocol-utils": "^1.1.3", + "@0x/subproviders": "^6.2.3", + "@0x/types": "^3.3.1", + "@0x/typescript-typings": "^5.1.6", + "@0x/utils": "^6.1.1", + "@0x/web3-wrapper": "^7.3.0", + "ethereum-types": "^3.4.0", + "ethereumjs-util": "^5.1.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/contracts/treasury/src/artifacts.ts b/contracts/treasury/src/artifacts.ts new file mode 100644 index 0000000000..2bc446dbcc --- /dev/null +++ b/contracts/treasury/src/artifacts.ts @@ -0,0 +1,13 @@ +/* + * ----------------------------------------------------------------------------- + * Warning: This file is auto-generated by contracts-gen. Don't edit manually. + * ----------------------------------------------------------------------------- + */ +import { ContractArtifact } from 'ethereum-types'; + +import * as DefaultPoolOperator from '../generated-artifacts/DefaultPoolOperator.json'; +import * as ZrxTreasury from '../generated-artifacts/ZrxTreasury.json'; +export const artifacts = { + ZrxTreasury: ZrxTreasury as ContractArtifact, + DefaultPoolOperator: DefaultPoolOperator as ContractArtifact, +}; diff --git a/contracts/treasury/src/index.ts b/contracts/treasury/src/index.ts new file mode 100644 index 0000000000..a1273b4286 --- /dev/null +++ b/contracts/treasury/src/index.ts @@ -0,0 +1,2 @@ +export { artifacts } from './artifacts'; +export { DefaultPoolOperatorContract, ZrxTreasuryContract } from './wrappers'; diff --git a/contracts/treasury/src/wrappers.ts b/contracts/treasury/src/wrappers.ts new file mode 100644 index 0000000000..eaa83d1f53 --- /dev/null +++ b/contracts/treasury/src/wrappers.ts @@ -0,0 +1,7 @@ +/* + * ----------------------------------------------------------------------------- + * Warning: This file is auto-generated by contracts-gen. Don't edit manually. + * ----------------------------------------------------------------------------- + */ +export * from '../generated-wrappers/default_pool_operator'; +export * from '../generated-wrappers/zrx_treasury'; diff --git a/contracts/treasury/test/artifacts.ts b/contracts/treasury/test/artifacts.ts new file mode 100644 index 0000000000..eac03af0a5 --- /dev/null +++ b/contracts/treasury/test/artifacts.ts @@ -0,0 +1,17 @@ +/* + * ----------------------------------------------------------------------------- + * Warning: This file is auto-generated by contracts-gen. Don't edit manually. + * ----------------------------------------------------------------------------- + */ +import { ContractArtifact } from 'ethereum-types'; + +import * as DefaultPoolOperator from '../test/generated-artifacts/DefaultPoolOperator.json'; +import * as IStaking from '../test/generated-artifacts/IStaking.json'; +import * as IZrxTreasury from '../test/generated-artifacts/IZrxTreasury.json'; +import * as ZrxTreasury from '../test/generated-artifacts/ZrxTreasury.json'; +export const artifacts = { + DefaultPoolOperator: DefaultPoolOperator as ContractArtifact, + IStaking: IStaking as ContractArtifact, + IZrxTreasury: IZrxTreasury as ContractArtifact, + ZrxTreasury: ZrxTreasury as ContractArtifact, +}; diff --git a/contracts/treasury/test/treasury_test.ts b/contracts/treasury/test/treasury_test.ts new file mode 100644 index 0000000000..e6bf95ffad --- /dev/null +++ b/contracts/treasury/test/treasury_test.ts @@ -0,0 +1,583 @@ +import { artifacts as assetProxyArtifacts, ERC20ProxyContract } from '@0x/contracts-asset-proxy'; +import { artifacts as erc20Artifacts, DummyERC20TokenContract } from '@0x/contracts-erc20'; +import { + artifacts as stakingArtifacts, + constants as stakingConstants, + StakeInfo, + StakeStatus, + StakingProxyContract, + TestStakingContract, + ZrxVaultContract, +} from '@0x/contracts-staking'; +import { + blockchainTests, + constants, + expect, + getRandomInteger, + randomAddress, + verifyEventsFromLogs, +} from '@0x/contracts-test-utils'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { artifacts } from './artifacts'; +import { DefaultPoolOperatorContract, ZrxTreasuryContract, ZrxTreasuryEvents } from './wrappers'; + +blockchainTests.resets('Treasury governance', env => { + const TREASURY_PARAMS = { + votingPeriod: new BigNumber(3).times(stakingConstants.ONE_DAY_IN_SECONDS), + proposalThreshold: new BigNumber(100), + quorumThreshold: new BigNumber(1000), + }; + const PROPOSAL_DESCRIPTION = 'A very compelling proposal!'; + const TREASURY_BALANCE = constants.INITIAL_ERC20_BALANCE; + const INVALID_PROPOSAL_ID = new BigNumber(999); + const GRANT_PROPOSALS = [ + { recipient: randomAddress(), amount: getRandomInteger(1, TREASURY_BALANCE.dividedToIntegerBy(2)) }, + { recipient: randomAddress(), amount: getRandomInteger(1, TREASURY_BALANCE.dividedToIntegerBy(2)) }, + ]; + + interface ProposedAction { + target: string; + data: string; + value: BigNumber; + } + + let zrx: DummyERC20TokenContract; + let weth: DummyERC20TokenContract; + let erc20ProxyContract: ERC20ProxyContract; + let staking: TestStakingContract; + let treasury: ZrxTreasuryContract; + let defaultPoolId: string; + let defaultPoolOperator: DefaultPoolOperatorContract; + let admin: string; + let nonDefaultPoolId: string; + let poolOperator: string; + let delegator: string; + let actions: ProposedAction[]; + + async function deployStakingAsync(): Promise { + erc20ProxyContract = await ERC20ProxyContract.deployFrom0xArtifactAsync( + assetProxyArtifacts.ERC20Proxy, + env.provider, + env.txDefaults, + assetProxyArtifacts, + ); + const zrxVaultContract = await ZrxVaultContract.deployFrom0xArtifactAsync( + stakingArtifacts.ZrxVault, + env.provider, + env.txDefaults, + stakingArtifacts, + erc20ProxyContract.address, + zrx.address, + ); + await erc20ProxyContract.addAuthorizedAddress(zrxVaultContract.address).awaitTransactionSuccessAsync(); + await zrxVaultContract.addAuthorizedAddress(admin).awaitTransactionSuccessAsync(); + const stakingLogic = await TestStakingContract.deployFrom0xArtifactAsync( + stakingArtifacts.TestStaking, + env.provider, + env.txDefaults, + artifacts, + weth.address, + zrxVaultContract.address, + ); + const stakingProxyContract = await StakingProxyContract.deployFrom0xArtifactAsync( + stakingArtifacts.StakingProxy, + env.provider, + env.txDefaults, + artifacts, + stakingLogic.address, + ); + await stakingProxyContract.addAuthorizedAddress(admin).awaitTransactionSuccessAsync(); + await zrxVaultContract.setStakingProxy(stakingProxyContract.address).awaitTransactionSuccessAsync(); + staking = new TestStakingContract(stakingProxyContract.address, env.provider, env.txDefaults); + } + + async function fastForwardToNextEpochAsync(): Promise { + const epochEndTime = await staking.getCurrentEpochEarliestEndTimeInSeconds().callAsync(); + const lastBlockTime = await env.web3Wrapper.getBlockTimestampAsync('latest'); + const dt = Math.max(0, epochEndTime.minus(lastBlockTime).toNumber()); + await env.web3Wrapper.increaseTimeAsync(dt); + // mine next block + await env.web3Wrapper.mineBlockAsync(); + await staking.endEpoch().awaitTransactionSuccessAsync(); + } + + before(async () => { + [admin, poolOperator, delegator] = await env.getAccountAddressesAsync(); + zrx = await DummyERC20TokenContract.deployFrom0xArtifactAsync( + erc20Artifacts.DummyERC20Token, + env.provider, + env.txDefaults, + erc20Artifacts, + constants.DUMMY_TOKEN_NAME, + constants.DUMMY_TOKEN_SYMBOL, + constants.DUMMY_TOKEN_DECIMALS, + constants.DUMMY_TOKEN_TOTAL_SUPPLY, + ); + weth = await DummyERC20TokenContract.deployFrom0xArtifactAsync( + erc20Artifacts.DummyERC20Token, + env.provider, + env.txDefaults, + erc20Artifacts, + constants.DUMMY_TOKEN_NAME, + constants.DUMMY_TOKEN_SYMBOL, + constants.DUMMY_TOKEN_DECIMALS, + constants.DUMMY_TOKEN_TOTAL_SUPPLY, + ); + await deployStakingAsync(); + await zrx.mint(constants.INITIAL_ERC20_BALANCE).awaitTransactionSuccessAsync({ from: poolOperator }); + await zrx.mint(constants.INITIAL_ERC20_BALANCE).awaitTransactionSuccessAsync({ from: delegator }); + await zrx + .approve(erc20ProxyContract.address, constants.INITIAL_ERC20_ALLOWANCE) + .awaitTransactionSuccessAsync({ from: poolOperator }); + await zrx + .approve(erc20ProxyContract.address, constants.INITIAL_ERC20_ALLOWANCE) + .awaitTransactionSuccessAsync({ from: delegator }); + + const createStakingPoolTx = staking.createStakingPool(stakingConstants.PPM, false); + nonDefaultPoolId = await createStakingPoolTx.callAsync({ from: poolOperator }); + await createStakingPoolTx.awaitTransactionSuccessAsync({ from: poolOperator }); + + treasury = await ZrxTreasuryContract.deployFrom0xArtifactAsync( + artifacts.ZrxTreasury, + env.provider, + env.txDefaults, + { ...artifacts, ...erc20Artifacts }, + staking.address, + weth.address, + TREASURY_PARAMS, + ); + await zrx.mint(TREASURY_BALANCE).awaitTransactionSuccessAsync(); + await zrx.transfer(treasury.address, TREASURY_BALANCE).awaitTransactionSuccessAsync(); + actions = [ + { + target: zrx.address, + data: zrx + .transfer(GRANT_PROPOSALS[0].recipient, GRANT_PROPOSALS[0].amount) + .getABIEncodedTransactionData(), + value: constants.ZERO_AMOUNT, + }, + { + target: zrx.address, + data: zrx + .transfer(GRANT_PROPOSALS[1].recipient, GRANT_PROPOSALS[1].amount) + .getABIEncodedTransactionData(), + value: constants.ZERO_AMOUNT, + }, + ]; + + defaultPoolId = await treasury.defaultPoolId().callAsync(); + const defaultPoolOperatorAddress = await treasury.defaultPoolOperator().callAsync(); + defaultPoolOperator = new DefaultPoolOperatorContract(defaultPoolOperatorAddress, env.provider, env.txDefaults); + }); + describe('getVotingPower()', () => { + it('Unstaked ZRX has no voting power', async () => { + const votingPower = await treasury.getVotingPower(delegator, []).callAsync(); + expect(votingPower).to.bignumber.equal(0); + }); + it('Staked but undelegated ZRX has no voting power', async () => { + await staking.stake(constants.INITIAL_ERC20_BALANCE).awaitTransactionSuccessAsync({ from: delegator }); + const votingPower = await treasury.getVotingPower(delegator, []).callAsync(); + expect(votingPower).to.bignumber.equal(0); + }); + it('ZRX delegated during epoch N has no voting power during Epoch N', async () => { + await staking.stake(TREASURY_PARAMS.proposalThreshold).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + TREASURY_PARAMS.proposalThreshold, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + const votingPower = await treasury.getVotingPower(delegator, []).callAsync(); + expect(votingPower).to.bignumber.equal(0); + await fastForwardToNextEpochAsync(); + }); + it('ZRX delegated to the default pool retains full voting power', async () => { + await staking.stake(TREASURY_PARAMS.proposalThreshold).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + TREASURY_PARAMS.proposalThreshold, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const votingPower = await treasury.getVotingPower(delegator, []).callAsync(); + expect(votingPower).to.bignumber.equal(TREASURY_PARAMS.proposalThreshold); + }); + it('ZRX delegated to a non-default pool splits voting power between delegator and pool operator', async () => { + await staking.stake(TREASURY_PARAMS.proposalThreshold).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, nonDefaultPoolId), + TREASURY_PARAMS.proposalThreshold, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const delegatorVotingPower = await treasury.getVotingPower(delegator, []).callAsync(); + expect(delegatorVotingPower).to.bignumber.equal(TREASURY_PARAMS.proposalThreshold.dividedBy(2)); + const operatorVotingPower = await treasury.getVotingPower(poolOperator, [nonDefaultPoolId]).callAsync(); + expect(operatorVotingPower).to.bignumber.equal(TREASURY_PARAMS.proposalThreshold.dividedBy(2)); + }); + it('Correctly sums voting power delegated to multiple pools', async () => { + await staking + .stake(TREASURY_PARAMS.proposalThreshold.times(2)) + .awaitTransactionSuccessAsync({ from: delegator }); + // Delegate half of total stake to the default pool. + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + TREASURY_PARAMS.proposalThreshold, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + // Delegate the other half to a non-default pool. + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, nonDefaultPoolId), + TREASURY_PARAMS.proposalThreshold, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const delegatorVotingPower = await treasury.getVotingPower(delegator, []).callAsync(); + expect(delegatorVotingPower).to.bignumber.equal(TREASURY_PARAMS.proposalThreshold.times(1.5)); + }); + it('Correctly sums voting power for operator with multiple pools', async () => { + const createStakingPoolTx = staking.createStakingPool(stakingConstants.PPM, false); + const firstPool = nonDefaultPoolId; + const secondPool = await createStakingPoolTx.callAsync({ from: poolOperator }); + await createStakingPoolTx.awaitTransactionSuccessAsync({ from: poolOperator }); + + const amountDelegatedToDefaultPool = new BigNumber(1337); + const amountSelfDelegatedToFirstPool = new BigNumber(420); + const amountExternallyDelegatedToSecondPool = new BigNumber(2020); + + await staking + .stake(amountDelegatedToDefaultPool.plus(amountSelfDelegatedToFirstPool)) + .awaitTransactionSuccessAsync({ from: poolOperator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + amountDelegatedToDefaultPool, + ) + .awaitTransactionSuccessAsync({ from: poolOperator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, firstPool), + amountSelfDelegatedToFirstPool, + ) + .awaitTransactionSuccessAsync({ from: poolOperator }); + await staking + .stake(amountExternallyDelegatedToSecondPool) + .awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, secondPool), + amountExternallyDelegatedToSecondPool, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + + await fastForwardToNextEpochAsync(); + const votingPower = await treasury.getVotingPower(poolOperator, [firstPool, secondPool]).callAsync(); + expect(votingPower).to.bignumber.equal( + amountDelegatedToDefaultPool + .plus(amountSelfDelegatedToFirstPool) + .plus(amountExternallyDelegatedToSecondPool.dividedToIntegerBy(2)), + ); + }); + }); + describe('propose()', () => { + it('Cannot create proposal without sufficient voting power', async () => { + const votingPower = TREASURY_PARAMS.proposalThreshold.minus(1); + await staking.stake(votingPower).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + votingPower, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const currentEpoch = await staking.currentEpoch().callAsync(); + const tx = treasury + .propose(actions, currentEpoch.plus(2), PROPOSAL_DESCRIPTION, []) + .awaitTransactionSuccessAsync({ from: delegator }); + return expect(tx).to.revertWith('propose/INSUFFICIENT_VOTING_POWER'); + }); + it('Cannot create proposal with no actions', async () => { + const votingPower = TREASURY_PARAMS.proposalThreshold; + await staking.stake(votingPower).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + votingPower, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const currentEpoch = await staking.currentEpoch().callAsync(); + const tx = treasury + .propose([], currentEpoch.plus(2), PROPOSAL_DESCRIPTION, []) + .awaitTransactionSuccessAsync({ from: delegator }); + return expect(tx).to.revertWith('propose/NO_ACTIONS_PROPOSED'); + }); + it('Cannot create proposal with an invalid execution epoch', async () => { + const votingPower = TREASURY_PARAMS.proposalThreshold; + await staking.stake(votingPower).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + votingPower, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const currentEpoch = await staking.currentEpoch().callAsync(); + const tx = treasury + .propose(actions, currentEpoch.plus(1), PROPOSAL_DESCRIPTION, []) + .awaitTransactionSuccessAsync({ from: delegator }); + return expect(tx).to.revertWith('propose/INVALID_EXECUTION_EPOCH'); + }); + it('Can create a valid proposal', async () => { + const votingPower = TREASURY_PARAMS.proposalThreshold; + await staking.stake(votingPower).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + votingPower, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const currentEpoch = await staking.currentEpoch().callAsync(); + const executionEpoch = currentEpoch.plus(2); + const tx = await treasury + .propose(actions, executionEpoch, PROPOSAL_DESCRIPTION, []) + .awaitTransactionSuccessAsync({ from: delegator }); + const proposalId = new BigNumber(0); + verifyEventsFromLogs( + tx.logs, + [ + { + proposer: delegator, + operatedPoolIds: [], + proposalId, + actions, + executionEpoch, + description: PROPOSAL_DESCRIPTION, + }, + ], + ZrxTreasuryEvents.ProposalCreated, + ); + expect(await treasury.proposalCount().callAsync()).to.bignumber.equal(1); + }); + }); + describe('castVote()', () => { + const VOTE_PROPOSAL_ID = new BigNumber(0); + const DELEGATOR_VOTING_POWER = new BigNumber(420); + + before(async () => { + await staking.stake(DELEGATOR_VOTING_POWER).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + DELEGATOR_VOTING_POWER, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const currentEpoch = await staking.currentEpoch().callAsync(); + await treasury + .propose(actions, currentEpoch.plus(2), PROPOSAL_DESCRIPTION, []) + .awaitTransactionSuccessAsync({ from: delegator }); + }); + it('Cannot vote on invalid proposalId', async () => { + await fastForwardToNextEpochAsync(); + await fastForwardToNextEpochAsync(); + const tx = treasury + .castVote(INVALID_PROPOSAL_ID, true, []) + .awaitTransactionSuccessAsync({ from: delegator }); + return expect(tx).to.revertWith('castVote/INVALID_PROPOSAL_ID'); + }); + it('Cannot vote before voting period starts', async () => { + const tx = treasury.castVote(VOTE_PROPOSAL_ID, true, []).awaitTransactionSuccessAsync({ from: delegator }); + return expect(tx).to.revertWith('castVote/VOTING_IS_CLOSED'); + }); + it('Cannot vote after voting period ends', async () => { + await fastForwardToNextEpochAsync(); + await fastForwardToNextEpochAsync(); + await env.web3Wrapper.increaseTimeAsync(TREASURY_PARAMS.votingPeriod.plus(1).toNumber()); + await env.web3Wrapper.mineBlockAsync(); + const tx = treasury.castVote(VOTE_PROPOSAL_ID, true, []).awaitTransactionSuccessAsync({ from: delegator }); + return expect(tx).to.revertWith('castVote/VOTING_IS_CLOSED'); + }); + it('Cannot vote twice on same proposal', async () => { + await fastForwardToNextEpochAsync(); + await fastForwardToNextEpochAsync(); + await treasury.castVote(VOTE_PROPOSAL_ID, true, []).awaitTransactionSuccessAsync({ from: delegator }); + const tx = treasury.castVote(VOTE_PROPOSAL_ID, false, []).awaitTransactionSuccessAsync({ from: delegator }); + return expect(tx).to.revertWith('castVote/ALREADY_VOTED'); + }); + it('Can cast a valid vote', async () => { + await fastForwardToNextEpochAsync(); + await fastForwardToNextEpochAsync(); + const tx = await treasury + .castVote(VOTE_PROPOSAL_ID, true, []) + .awaitTransactionSuccessAsync({ from: delegator }); + verifyEventsFromLogs( + tx.logs, + [ + { + voter: delegator, + operatedPoolIds: [], + proposalId: VOTE_PROPOSAL_ID, + support: true, + votingPower: DELEGATOR_VOTING_POWER, + }, + ], + ZrxTreasuryEvents.VoteCast, + ); + }); + }); + describe('execute()', () => { + let passedProposalId: BigNumber; + let failedProposalId: BigNumber; + let defeatedProposalId: BigNumber; + let ongoingVoteProposalId: BigNumber; + + before(async () => { + // OPerator has enough ZRX to create and pass a proposal + await staking.stake(TREASURY_PARAMS.quorumThreshold).awaitTransactionSuccessAsync({ from: poolOperator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + TREASURY_PARAMS.quorumThreshold, + ) + .awaitTransactionSuccessAsync({ from: poolOperator }); + // Delegator only has enough ZRX to create a proposal + await staking.stake(TREASURY_PARAMS.proposalThreshold).awaitTransactionSuccessAsync({ from: delegator }); + await staking + .moveStake( + new StakeInfo(StakeStatus.Undelegated), + new StakeInfo(StakeStatus.Delegated, defaultPoolId), + TREASURY_PARAMS.proposalThreshold, + ) + .awaitTransactionSuccessAsync({ from: delegator }); + await fastForwardToNextEpochAsync(); + const currentEpoch = await staking.currentEpoch().callAsync(); + // Proposal 0 + let tx = treasury.propose(actions, currentEpoch.plus(4), PROPOSAL_DESCRIPTION, []); + passedProposalId = await tx.callAsync({ from: delegator }); + await tx.awaitTransactionSuccessAsync({ from: delegator }); + // Proposal 1 + tx = treasury.propose(actions, currentEpoch.plus(3), PROPOSAL_DESCRIPTION, []); + failedProposalId = await tx.callAsync({ from: delegator }); + await tx.awaitTransactionSuccessAsync({ from: delegator }); + // Proposal 2 + tx = treasury.propose(actions, currentEpoch.plus(3), PROPOSAL_DESCRIPTION, []); + defeatedProposalId = await tx.callAsync({ from: delegator }); + await tx.awaitTransactionSuccessAsync({ from: delegator }); + + await fastForwardToNextEpochAsync(); + // Proposal 3 + tx = treasury.propose(actions, currentEpoch.plus(3), PROPOSAL_DESCRIPTION, []); + ongoingVoteProposalId = await tx.callAsync({ from: delegator }); + await tx.awaitTransactionSuccessAsync({ from: delegator }); + + await fastForwardToNextEpochAsync(); + /********** Start Vote Epoch for Proposals 0, 1, 2 **********/ + // Proposal 0 passes + await treasury.castVote(passedProposalId, true, []).awaitTransactionSuccessAsync({ from: poolOperator }); + // Proposal 1 fails to reach quorum + await treasury.castVote(failedProposalId, true, []).awaitTransactionSuccessAsync({ from: delegator }); + // Proposal 2 is voted down + await treasury.castVote(defeatedProposalId, true, []).awaitTransactionSuccessAsync({ from: delegator }); + await treasury.castVote(defeatedProposalId, false, []).awaitTransactionSuccessAsync({ from: poolOperator }); + /********** End Vote Epoch for Proposals 0, 1, 2 **********/ + + await fastForwardToNextEpochAsync(); + /********** Start Execution Epoch for Proposals 1, 2, 3 **********/ + /********** Start Vote Epoch for Proposal 3 **********************/ + // Proposal 3 has enough votes to pass, but the vote is ongoing + await treasury + .castVote(ongoingVoteProposalId, true, []) + .awaitTransactionSuccessAsync({ from: poolOperator }); + }); + it('Cannot execute an invalid proposalId', async () => { + const tx = treasury.execute(INVALID_PROPOSAL_ID, actions).awaitTransactionSuccessAsync(); + return expect(tx).to.revertWith('execute/INVALID_PROPOSAL_ID'); + }); + it('Cannot execute a proposal whose vote is ongoing', async () => { + const tx = treasury.execute(ongoingVoteProposalId, actions).awaitTransactionSuccessAsync(); + return expect(tx).to.revertWith('_assertProposalExecutable/PROPOSAL_HAS_NOT_PASSED'); + }); + it('Cannot execute a proposal that failed to reach quorum', async () => { + const tx = treasury.execute(failedProposalId, actions).awaitTransactionSuccessAsync(); + return expect(tx).to.revertWith('_assertProposalExecutable/PROPOSAL_HAS_NOT_PASSED'); + }); + it('Cannot execute a proposal that was defeated in its vote', async () => { + const tx = treasury.execute(defeatedProposalId, actions).awaitTransactionSuccessAsync(); + return expect(tx).to.revertWith('_assertProposalExecutable/PROPOSAL_HAS_NOT_PASSED'); + }); + it('Cannot execute before or after the execution epoch', async () => { + const tooEarly = treasury.execute(passedProposalId, actions).awaitTransactionSuccessAsync(); + expect(tooEarly).to.revertWith('_assertProposalExecutable/CANNOT_EXECUTE_THIS_EPOCH'); + await fastForwardToNextEpochAsync(); + // Proposal 0 is executable here + await fastForwardToNextEpochAsync(); + const tooLate = treasury.execute(passedProposalId, actions).awaitTransactionSuccessAsync(); + return expect(tooLate).to.revertWith('_assertProposalExecutable/CANNOT_EXECUTE_THIS_EPOCH'); + }); + it('Cannot execute the same proposal twice', async () => { + await fastForwardToNextEpochAsync(); + await treasury.execute(passedProposalId, actions).awaitTransactionSuccessAsync(); + const tx = treasury.execute(passedProposalId, actions).awaitTransactionSuccessAsync(); + return expect(tx).to.revertWith('_assertProposalExecutable/PROPOSAL_ALREADY_EXECUTED'); + }); + it('Cannot execute actions that do not match the proposal `actionsHash`', async () => { + await fastForwardToNextEpochAsync(); + const tx = treasury + .execute(passedProposalId, [ + { + target: zrx.address, + data: zrx.transfer(randomAddress(), GRANT_PROPOSALS[0].amount).getABIEncodedTransactionData(), + value: constants.ZERO_AMOUNT, + }, + ]) + .awaitTransactionSuccessAsync(); + return expect(tx).to.revertWith('_assertProposalExecutable/INVALID_ACTIONS'); + }); + it('Can execute a valid proposal', async () => { + await fastForwardToNextEpochAsync(); + const tx = await treasury.execute(passedProposalId, actions).awaitTransactionSuccessAsync(); + verifyEventsFromLogs(tx.logs, [{ proposalId: passedProposalId }], ZrxTreasuryEvents.ProposalExecuted); + expect(await zrx.balanceOf(GRANT_PROPOSALS[0].recipient).callAsync()).to.bignumber.equal( + GRANT_PROPOSALS[0].amount, + ); + expect(await zrx.balanceOf(GRANT_PROPOSALS[1].recipient).callAsync()).to.bignumber.equal( + GRANT_PROPOSALS[1].amount, + ); + }); + }); + describe('Default pool operator contract', () => { + it('Returns WETH to the staking proxy', async () => { + const wethAmount = new BigNumber(1337); + await weth.mint(wethAmount).awaitTransactionSuccessAsync(); + // Some amount of WETH ends up in the default pool operator + // contract, e.g. from errant staking rewards. + await weth.transfer(defaultPoolOperator.address, wethAmount).awaitTransactionSuccessAsync(); + // This function should send all the WETH to the staking proxy. + await defaultPoolOperator.returnStakingRewards().awaitTransactionSuccessAsync(); + expect(await weth.balanceOf(defaultPoolOperator.address).callAsync()).to.bignumber.equal(0); + expect(await weth.balanceOf(staking.address).callAsync()).to.bignumber.equal(wethAmount); + }); + }); +}); diff --git a/contracts/treasury/test/wrappers.ts b/contracts/treasury/test/wrappers.ts new file mode 100644 index 0000000000..5882b4cc83 --- /dev/null +++ b/contracts/treasury/test/wrappers.ts @@ -0,0 +1,9 @@ +/* + * ----------------------------------------------------------------------------- + * Warning: This file is auto-generated by contracts-gen. Don't edit manually. + * ----------------------------------------------------------------------------- + */ +export * from '../test/generated-wrappers/default_pool_operator'; +export * from '../test/generated-wrappers/i_staking'; +export * from '../test/generated-wrappers/i_zrx_treasury'; +export * from '../test/generated-wrappers/zrx_treasury'; diff --git a/contracts/treasury/tsconfig.json b/contracts/treasury/tsconfig.json new file mode 100644 index 0000000000..cd2fac4eba --- /dev/null +++ b/contracts/treasury/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { "outDir": "lib", "rootDir": ".", "resolveJsonModule": true }, + "include": ["./src/**/*", "./test/**/*", "./generated-wrappers/**/*"], + "files": [ + "generated-artifacts/DefaultPoolOperator.json", + "generated-artifacts/ZrxTreasury.json", + "test/generated-artifacts/DefaultPoolOperator.json", + "test/generated-artifacts/IStaking.json", + "test/generated-artifacts/IZrxTreasury.json", + "test/generated-artifacts/ZrxTreasury.json" + ], + "exclude": ["./deploy/solc/solc_bin"] +} diff --git a/contracts/treasury/tslint.json b/contracts/treasury/tslint.json new file mode 100644 index 0000000000..8cdabb0d0c --- /dev/null +++ b/contracts/treasury/tslint.json @@ -0,0 +1,13 @@ +{ + "extends": ["@0x/tslint-config"], + "rules": { + "custom-no-magic-numbers": false, + "max-file-line-count": false, + "no-non-null-assertion": false, + "no-unnecessary-type-assertion": false, + "number-literal-format": false + }, + "linterOptions": { + "exclude": ["src/artifacts.ts", "test/artifacts.ts"] + } +} diff --git a/contracts/zero-ex/README.md b/contracts/zero-ex/README.md index 253807b3c7..7696806e97 100644 --- a/contracts/zero-ex/README.md +++ b/contracts/zero-ex/README.md @@ -1,6 +1,6 @@ -## ERC20BridgeSampler +## ZeroEx (ExchangeProxy) -This package contains contracts contracts for the ZeroEx extensible contract architecture. +This package contains contracts for the ZeroEx extensible contract architecture. ## Installation diff --git a/package.json b/package.json index 613d05ea55..a3e7f10ffc 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "verdaccio": "docker run --rm -i -p 4873:4873 0xorg/verdaccio" }, "config": { - "contractsPackages": "@0x/contracts-asset-proxy @0x/contracts-dev-utils @0x/contracts-erc20 @0x/contracts-erc721 @0x/contracts-erc1155 @0x/contracts-exchange @0x/contracts-exchange-forwarder @0x/contracts-exchange-libs @0x/contracts-integrations @0x/contracts-multisig @0x/contracts-staking @0x/contracts-test-utils @0x/contracts-utils @0x/contracts-coordinator @0x/contracts-broker @0x/contracts-zero-ex", + "contractsPackages": "@0x/contracts-asset-proxy @0x/contracts-dev-utils @0x/contracts-erc20 @0x/contracts-erc721 @0x/contracts-erc1155 @0x/contracts-exchange @0x/contracts-exchange-forwarder @0x/contracts-exchange-libs @0x/contracts-integrations @0x/contracts-multisig @0x/contracts-staking @0x/contracts-test-utils @0x/contracts-utils @0x/contracts-coordinator @0x/contracts-broker @0x/contracts-zero-ex @0x/contracts-treasury", "nonContractPackages": "@0x/order-utils @0x/migrations @0x/contract-wrappers @0x/contract-addresses @0x/contract-artifacts @0x/contract-wrappers-test @0x/asset-swapper", "ignoreTestsForPackages": "@0x/contracts-integrations @0x/contracts-staking @0x/contracts-exchange @0x/contracts-exchange-forwarder @0x/contracts-coordinator", "mnemonic": "concert load couple harbor equip island argue ramp clarify fence smart topic",