diff --git a/packages/convenience/contracts/Convenience.sol b/packages/convenience/contracts/Convenience.sol index e22d2a9a..a9b53ddf 100644 --- a/packages/convenience/contracts/Convenience.sol +++ b/packages/convenience/contracts/Convenience.sol @@ -227,6 +227,7 @@ contract Convenience is Ownable { else { delegateState[i] = api3Voting.getVoterState(voteIds[i], delegateAt[i]); + voterState[i] = delegateState[i]; } } } @@ -259,7 +260,7 @@ contract Convenience is Ownable { return new uint256[](0); } uint256 countOpenVote = 0; - for (uint256 i = votesLength - 1; i >= 0; i--) + for (uint256 i = votesLength ; i > 0; i--) { ( bool open, @@ -272,7 +273,7 @@ contract Convenience is Ownable { , // nay , // votingPower // script - ) = api3Voting.getVote(i); + ) = api3Voting.getVote(i-1); if (open) { countOpenVote++; @@ -288,7 +289,7 @@ contract Convenience is Ownable { } voteIds = new uint256[](countOpenVote); uint256 countAddedVote = 0; - for (uint256 i = votesLength - 1; i >= 0; i--) + for (uint256 i = votesLength ; i > 0; i--) { if (countOpenVote == countAddedVote) { @@ -305,10 +306,10 @@ contract Convenience is Ownable { , // nay , // votingPower // script - ) = api3Voting.getVote(i); + ) = api3Voting.getVote(i-1); if (open) { - voteIds[countAddedVote] = i; + voteIds[countAddedVote] = i-1; countAddedVote++; } } diff --git a/packages/convenience/contracts/mock/Api3TokenMock.sol b/packages/convenience/contracts/mock/Api3TokenMock.sol new file mode 100644 index 00000000..4af2a02a --- /dev/null +++ b/packages/convenience/contracts/mock/Api3TokenMock.sol @@ -0,0 +1,133 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.4; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "../../interfaces/v0.8.4/IApi3Token.sol"; + + +/// @title API3 token contract +/// @notice The API3 token contract is owned by the API3 DAO, which can grant +/// minting privileges to addresses. Any account is allowed to burn their +/// tokens, but this functionality is put behind a barrier that requires the +/// account to make a call to remove. +contract MockApi3Token is ERC20, Ownable, IApi3Token { + /// @dev If an address is authorized to mint tokens. + /// Token minting authorization is granted by the token contract owner + /// (i.e., the API3 DAO). + mapping(address => bool) private isMinter; + /// @dev If an address is authorized to burn tokens. + /// Token burning authorization is granted by the address itself, i.e., + /// anyone can declare themselves a token burner. + mapping(address => bool) private isBurner; + + /// @param contractOwner Address that will receive the ownership of the + /// token contract + /// @param mintingDestination Address that the tokens will be minted to + constructor( + address contractOwner, + address mintingDestination + ) + public + ERC20("API3", "API3") + { + transferOwnership(contractOwner); + // Initial supply is 100 million (100e6) + // We are using ether because the token has 18 decimals like ETH + _mint(mintingDestination, 100e6 ether); + } + + /// @notice The OpenZeppelin renounceOwnership() implementation is + /// overriden to prevent ownership from being renounced accidentally. + function renounceOwnership() + public + override + onlyOwner + { + revert("Ownership cannot be renounced"); + } + + /// @notice Updates if an address is authorized to mint tokens + /// @param minterAddress Address whose minter authorization status will be + /// updated + /// @param minterStatus Updated minter authorization status + function updateMinterStatus( + address minterAddress, + bool minterStatus + ) + external + override + onlyOwner + { + require( + isMinter[minterAddress] != minterStatus, + "Input will not update state" + ); + isMinter[minterAddress] = minterStatus; + emit MinterStatusUpdated(minterAddress, minterStatus); + } + + /// @notice Updates if the caller is authorized to burn tokens + /// @param burnerStatus Updated minter authorization status + function updateBurnerStatus(bool burnerStatus) + external + override + { + require( + isBurner[msg.sender] != burnerStatus, + "Input will not update state" + ); + isBurner[msg.sender] = burnerStatus; + emit BurnerStatusUpdated(msg.sender, burnerStatus); + } + + /// @notice Mints tokens + /// @param account Address that will receive the minted tokens + /// @param amount Amount that will be minted + function mint( + address account, + uint256 amount + ) + external + override + { + require(isMinter[msg.sender], "Only minters are allowed to mint"); + _mint(account, amount); + } + + /// @notice Burns caller's tokens + /// @param amount Amount that will be burned + function burn(uint256 amount) + external + override + { + require(isBurner[msg.sender], "Only burners are allowed to burn"); + _burn(msg.sender, amount); + } + + /// @notice Returns if an address is authorized to mint tokens + /// @param minterAddress Address whose minter authorization status will be + /// returned + /// @return minterStatus Minter authorization status + function getMinterStatus(address minterAddress) + external + view + override + returns(bool minterStatus) + { + minterStatus = isMinter[minterAddress]; + } + + /// @notice Returns if an address is authorized to burn tokens + /// @param burnerAddress Address whose burner authorization status will be + /// returned + /// @return burnerStatus Burner authorization status + function getBurnerStatus(address burnerAddress) + external + view + override + returns(bool burnerStatus) + { + burnerStatus = isBurner[burnerAddress]; + } +} \ No newline at end of file diff --git a/packages/convenience/contracts/mock/Api3VotingMock.sol b/packages/convenience/contracts/mock/Api3VotingMock.sol new file mode 100644 index 00000000..35b6619a --- /dev/null +++ b/packages/convenience/contracts/mock/Api3VotingMock.sol @@ -0,0 +1,113 @@ +pragma solidity 0.8.4; + +contract MockApi3Voting { + + enum VoterState { Absent, Yea, Nay } + + struct Vote { + bool open; + bool executed; + uint64 startDate; + uint64 snapshotBlock; + uint64 supportRequired; + uint64 minAcceptQuorum; + uint256 yea; + uint256 nay; + uint256 votingPower; + bytes script; + } + + Vote[] private votes; + + function addVote( + bool open, + bool executed, + uint64 startDate, + uint64 snapshotBlock, + uint64 supportRequired, + uint64 minAcceptQuorum, + uint256 yea, + uint256 nay, + uint256 votingPower, + bytes memory script + ) + external + { + votes.push(Vote({ + open: open, + executed: executed, + startDate: startDate, + snapshotBlock: snapshotBlock, + supportRequired: supportRequired, + minAcceptQuorum: minAcceptQuorum, + yea: yea, + nay: nay, + votingPower: votingPower, + script: script + })); + } + + function getVote(uint256 _voteId) + external + view + returns ( + bool open, + bool executed, + uint64 startDate, + uint64 snapshotBlock, + uint64 supportRequired, + uint64 minAcceptQuorum, + uint256 yea, + uint256 nay, + uint256 votingPower, + bytes memory script + ) + { + require(_voteId < votes.length, "No such vote"); + open = votes[_voteId].open; + executed = votes[_voteId].executed; + startDate = votes[_voteId].startDate; + snapshotBlock = votes[_voteId].snapshotBlock; + supportRequired = votes[_voteId].supportRequired; + minAcceptQuorum = votes[_voteId].minAcceptQuorum; + yea = votes[_voteId].yea; + nay = votes[_voteId].nay; + votingPower = votes[_voteId].votingPower; + script = votes[_voteId].script; + } + + + function votesLength() + external + view + returns (uint256) + { + return votes.length; + } + + function getVoterState(uint256 _voteId, address _voter) + external + view + returns (VoterState state) + { + if( _voteId == 1) { + state = VoterState.Yea ; + } + else if(_voteId == 2) { + state = VoterState.Absent ; + } + else { + state = VoterState.Nay ; + } + } + + + function voteTime() + external + view + returns (uint256) + { + return block.timestamp - 30 days; + } + +} \ No newline at end of file diff --git a/packages/convenience/contracts/mock/MockApi3Pool.sol b/packages/convenience/contracts/mock/MockApi3Pool.sol deleted file mode 100644 index 38b5dcfa..00000000 --- a/packages/convenience/contracts/mock/MockApi3Pool.sol +++ /dev/null @@ -1,17 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity 0.8.4; - -contract MockApi3Pool { - address public votingAppPrimary; - address public votingAppSecondary; - - function setDaoApps( - address _votingAppPrimary, - address _votingAppSecondary - ) - external - { - votingAppPrimary = _votingAppPrimary; - votingAppSecondary = _votingAppSecondary; - } -} \ No newline at end of file diff --git a/packages/convenience/contracts/mock/MockApi3Voting.sol b/packages/convenience/contracts/mock/MockApi3Voting.sol deleted file mode 100644 index 55f4357e..00000000 --- a/packages/convenience/contracts/mock/MockApi3Voting.sol +++ /dev/null @@ -1,83 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity 0.8.4; - -contract MockApi3Voting { - struct Vote { - bool open; - bool executed; - uint64 startDate; - uint64 snapshotBlock; - uint64 supportRequired; - uint64 minAcceptQuorum; - uint256 yea; - uint256 nay; - uint256 votingPower; - bytes script; - } - Vote[] private votes; - - function addVote( - bool open, - bool executed, - uint64 startDate, - uint64 snapshotBlock, - uint64 supportRequired, - uint64 minAcceptQuorum, - uint256 yea, - uint256 nay, - uint256 votingPower, - bytes calldata script - ) - external - { - votes.push(Vote({ - open: open, - executed: executed, - startDate: startDate, - snapshotBlock: snapshotBlock, - supportRequired: supportRequired, - minAcceptQuorum: minAcceptQuorum, - yea: yea, - nay: nay, - votingPower: votingPower, - script: script - })); - } - - function votesLength() - external - view - returns (uint256) - { - return votes.length; - } - - function getVote(uint256 _voteId) - external - view - returns ( - bool open, - bool executed, - uint64 startDate, - uint64 snapshotBlock, - uint64 supportRequired, - uint64 minAcceptQuorum, - uint256 yea, - uint256 nay, - uint256 votingPower, - bytes memory script - ) - { - require(_voteId < votes.length, "No such vote"); - open = votes[_voteId].open; - executed = votes[_voteId].executed; - startDate = votes[_voteId].startDate; - snapshotBlock = votes[_voteId].snapshotBlock; - supportRequired = votes[_voteId].supportRequired; - minAcceptQuorum = votes[_voteId].minAcceptQuorum; - yea = votes[_voteId].yea; - nay = votes[_voteId].nay; - votingPower = votes[_voteId].votingPower; - script = votes[_voteId].script; - } -} diff --git a/packages/convenience/hardhat.config.js b/packages/convenience/hardhat.config.js index 1372ad9c..7e635e16 100644 --- a/packages/convenience/hardhat.config.js +++ b/packages/convenience/hardhat.config.js @@ -1,4 +1,5 @@ require("@nomiclabs/hardhat-waffle"); +require("@nomiclabs/hardhat-ethers"); require("solidity-coverage"); require("hardhat-gas-reporter"); diff --git a/packages/convenience/interfaces/v0.8.4/IApi3Token.sol b/packages/convenience/interfaces/v0.8.4/IApi3Token.sol new file mode 100644 index 00000000..0dc54903 --- /dev/null +++ b/packages/convenience/interfaces/v0.8.4/IApi3Token.sol @@ -0,0 +1,45 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.4; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + + +interface IApi3Token is IERC20 { + event MinterStatusUpdated( + address indexed minterAddress, + bool minterStatus + ); + + event BurnerStatusUpdated( + address indexed burnerAddress, + bool burnerStatus + ); + + function updateMinterStatus( + address minterAddress, + bool minterStatus + ) + external; + + function updateBurnerStatus(bool burnerStatus) + external; + + function mint( + address account, + uint256 amount + ) + external; + + function burn(uint256 amount) + external; + + function getMinterStatus(address minterAddress) + external + view + returns(bool minterStatus); + + function getBurnerStatus(address burnerAddress) + external + view + returns(bool burnerStatus); +} \ No newline at end of file diff --git a/packages/convenience/test/Convenience.sol.js b/packages/convenience/test/Convenience.sol.js index 4170910d..6c091118 100644 --- a/packages/convenience/test/Convenience.sol.js +++ b/packages/convenience/test/Convenience.sol.js @@ -1,46 +1,259 @@ const { expect } = require("chai"); +const { ethers } = require("hardhat"); +let mockApi3Voting, mockApi3Token, api3Pool, convenience; let roles; -let convenience, mockApi3Pool, mockPrimaryVoting, mockSecondaryVoting; -const VotingAppType = Object.freeze({ Primary: 0, Secondary: 1 }); + +const VOTER_STATE = ["ABSENT", "YEA", "NAY"].reduce((state, key, index) => { + state[key] = index; + return state; +}, {}); + +const VOTING_TYPE = ["Primary", "Secondary"].reduce((state, key, index) => { + state[key] = index; + return state; +}, {}); beforeEach(async () => { const accounts = await ethers.getSigners(); roles = { deployer: accounts[0], + contractOwner: accounts[1], + user1: accounts[2], + user2: accounts[3], + mockTimeLockManager: accounts[4], randomPerson: accounts[9], }; - const mockApi3PoolFactory = await ethers.getContractFactory( - "MockApi3Pool", - roles.deployer - ); - mockApi3Pool = await mockApi3PoolFactory.deploy(); + const mockApi3VotingFactory = await ethers.getContractFactory( "MockApi3Voting", roles.deployer ); - mockPrimaryVoting = await mockApi3VotingFactory.deploy(); - mockSecondaryVoting = await mockApi3VotingFactory.deploy(); - await mockApi3Pool.setDaoApps( - mockPrimaryVoting.address, - mockSecondaryVoting.address + mockApi3Voting = await mockApi3VotingFactory.deploy(); + + const mockApi3TokenFactory = await ethers.getContractFactory( + "MockApi3Token", + roles.deployer + ); + mockApi3Token = await mockApi3TokenFactory.deploy( + roles.deployer.address, + roles.deployer.address + ); + + mockApi3Token2 = await mockApi3TokenFactory.deploy( + roles.deployer.address, + roles.deployer.address + ); + + api3PoolFactory = await ethers.getContractFactory("Api3Pool", roles.deployer); + + api3Pool = await api3PoolFactory.deploy(mockApi3Token.address,roles.mockTimeLockManager.address); + + EPOCH_LENGTH = await api3Pool.EPOCH_LENGTH(); + + await api3Pool.setDaoApps( + mockApi3Voting.address, + mockApi3Voting.address, + mockApi3Voting.address, + mockApi3Voting.address ); + + // Stake Tokens in the Pool + const user1Stake = ethers.utils.parseEther("20" + "000" + "000"); + await mockApi3Token + .connect(roles.deployer) + .approve(api3Pool.address, user1Stake); + await api3Pool + .connect(roles.mockTimeLockManager) + .deposit(roles.deployer.address, user1Stake, roles.user1.address); + // Stake half the tokens + await api3Pool + .connect(roles.user1) + .stake(user1Stake.div(ethers.BigNumber.from(2))); + // Delegate + await api3Pool.connect(roles.user1).delegateVotingPower(roles.user2.address); + + expect(await api3Pool.userStake(roles.user1.address)).to.equal( + user1Stake.div(ethers.BigNumber.from(2)) + ); + const convenienceFactory = await ethers.getContractFactory( "Convenience", roles.deployer ); - convenience = await convenienceFactory.deploy(mockApi3Pool.address); + + convenience = await convenienceFactory.deploy(api3Pool.address); + + await convenience.setErc20Addresses([mockApi3Token.address,mockApi3Token2.address]) +}); + +//Cast Votes in the mock contract +castVotes = async () => { + for (i = 1; i < 6; i++) { + await mockApi3Voting.addVote( + true, + true, + 60 * 60 * 24 * 30 + 60 * 60 * 24 * i, + 987654, + (50 * 10) ^ 16, + (25 * 10) ^ 16, + 8000, + 1000, + 10000, + "0xabcdef" + ); + } + return [4, 3, 2, 1, 0]; +}; + +describe("getUserStakingData", function () { + context("Valid User Address", function () { + it("returns User Staking Data", async function () { + const user1Stake = ethers.utils.parseEther("20" + "000" + "000"); + const userStakingData = await convenience.getUserStakingData( + roles.user1.address + ); + expect(userStakingData.userStaked).to.equal( + user1Stake.div(ethers.BigNumber.from(2)) + ); + }); + }); +}); + +describe("getTreasuryAndUserDelegationData", function () { + context("Valid User Address", function () { + it("returns the user delegation data", async function () { + const TreasuryAndUserDelegationData = await convenience.getTreasuryAndUserDelegationData( + roles.user1.address + ); + expect(TreasuryAndUserDelegationData.delegate).to.equal( + roles.user2.address + ); + }); + }); }); describe("getOpenVoteIds", function () { context("There are no open votes", function () { it("returns empty array", async function () { expect( - await convenience.getOpenVoteIds(VotingAppType.Primary) + await convenience.getOpenVoteIds(VOTING_TYPE.Primary) ).to.deep.equal([]); expect( - await convenience.getOpenVoteIds(VotingAppType.Secondary) + await convenience.getOpenVoteIds(VOTING_TYPE.Secondary) ).to.deep.equal([]); }); }); + + context("There are open votes", async function () { + it("returns the ids of the votes that are open", async function () { + await castVotes(); + openVoteIds = await convenience.getOpenVoteIds(VOTING_TYPE.Primary); + expect(openVoteIds.length).to.be.equal(5); + expect(openVoteIds[0]).to.be.equal(ethers.BigNumber.from(4)); + expect(openVoteIds[1]).to.be.equal(ethers.BigNumber.from(3)); + expect(openVoteIds[2]).to.be.equal(ethers.BigNumber.from(2)); + expect(openVoteIds[3]).to.be.equal(ethers.BigNumber.from(1)); + expect(openVoteIds[4]).to.be.equal(ethers.BigNumber.from(0)); + }); + }); }); + +describe("getStaticVoteData", function () { + context("Voting App type is Valid", function () { + context("Votes are casted", function () { + it("returns the vote data for the supplied voteIds", async function () { + voteIds = await castVotes(); + staticVoteData = await convenience.getStaticVoteData( + VOTING_TYPE.Primary, + voteIds + ); + for (i = 1; i < 6; i++) { + expect(staticVoteData.startDate[5 - i]).to.be.equal( + ethers.BigNumber.from(60 * 60 * 24 * 30 + 60 * 60 * 24 * i) + ); + expect(staticVoteData.supportRequired[5 - i]).to.be.equal( + ethers.BigNumber.from((50 * 10) ^ 16) + ); + expect(staticVoteData.minAcceptQuorum[5 - i]).to.be.equal( + ethers.BigNumber.from((25 * 10) ^ 16) + ); + expect(staticVoteData.votingPower[5 - i]).to.be.equal( + ethers.BigNumber.from(10000) + ); + expect(staticVoteData.script[5 - i]).to.be.equal( + "0xabcdef" + ); + } + }); + }); + + }) + + + context("Votes are not casted", function () { + it("returns empty arrays on no voteIds", async function () { + staticVoteData = await convenience.getStaticVoteData( + VOTING_TYPE.Primary, + [] + ); + expect(staticVoteData.startDate).to.deep.equal([]); + expect(staticVoteData.supportRequired).to.deep.equal([]); + expect(staticVoteData.minAcceptQuorum).to.deep.equal([]); + expect(staticVoteData.votingPower).to.deep.equal([]); + expect(staticVoteData.script).to.deep.equal([]); + }); + + it("reverts on invalid voteIds", async function () { + await expect( + convenience.getStaticVoteData(VOTING_TYPE.Primary, [1, 2, 3]) + ).to.be.revertedWith("No such vote"); + }); + }); + +}); + +describe("getDynamicVoteData", function () { + context("Voting App type is valid", function() { + context("Votes are casted", function () { + it("returns the the user Vote Data", async function () { + voteIds = await castVotes(); + dynamicVoteData = await convenience.getDynamicVoteData( + VOTING_TYPE.Primary, + roles.user1.address, + [0] + ); + expect(dynamicVoteData.executed[0]).to.be.equal(true); + expect(dynamicVoteData.yea[0]).to.be.equal("8000"); + expect(dynamicVoteData.nay[0]).to.be.equal("1000"); + expect(dynamicVoteData.delegateAt[0]).to.be.equal(roles.user2.address); + expect(dynamicVoteData.voterState[0]).to.be.equal(VOTER_STATE.NAY); // The Mock Contract returns NAY on all ids except 1 and 2 + }); + }); + context("Votes are not casted", function () { + it("returns empty arrays on no voteIds", async function () { + userVoteData = await convenience.getDynamicVoteData( + VOTING_TYPE.Primary, + roles.user1.address, + [] + ) + expect(userVoteData.executed).to.deep.equal([]); + expect(userVoteData.yea).to.deep.equal([]); + expect(userVoteData.nay).to.deep.equal([]); + expect(userVoteData.voterState).to.deep.equal([]); + expect(userVoteData.delegateAt).to.deep.equal([]); + expect(userVoteData.delegateState).to.deep.equal([]); + }) + + it("reverts on invalid voteIds", async function () { + await expect( + convenience.getDynamicVoteData( + VOTING_TYPE.Primary, + roles.user1.address, + [1,2,3] + ) + ).to.be.revertedWith("No such vote"); + }); + }); + }) +}); \ No newline at end of file