diff --git a/contracts/TokenGovernance.sol b/contracts/TokenGovernance.sol new file mode 100644 index 0000000..273d34d --- /dev/null +++ b/contracts/TokenGovernance.sol @@ -0,0 +1,149 @@ + +/** + * Copyright (c) 2018-present, Leap DAO (leapdao.org) + * + * This source code is licensed under the Mozilla Public License, version 2, + * found in the LICENSE file in the root directory of this source tree. + */ + +pragma solidity 0.5.2; + +import "../node_modules/openzeppelin-solidity/contracts/token/ERC20/IERC20.sol"; +import "../node_modules/openzeppelin-solidity/contracts/math/SafeMath.sol"; +import "./Bridge.sol"; +import "./Vault.sol"; +import "./TxLib.sol"; + +contract TokenGovernance { + using SafeMath for uint256; + + uint256 constant PROPOSAL_STAKE = 5000000000000000000000; + uint256 constant DUST_THRESHOLD = 1000000000000000000; + uint32 constant PROPOSAL_TIME = 604800; // 60 * 60 * 24 * 7 = 7 days + address public mvgAddress; + IERC20 public leapToken; + Bridge public bridge; + Vault public vault; + + struct Proposal { + address initiator; + uint32 openTime; + bool finalized; + uint256 yesVotes; + uint256 noVotes; + mapping(address => int256) votes; + mapping(bytes32 => address) usedTxns; + } + + mapping(bytes32 => Proposal) public proposals; + + event ProposalRegistered(bytes32 indexed proposalHash, address indexed initiator); + event VoiceCasted(bytes32 indexed proposalHash, address indexed subject, uint256 weight, bytes32 txHash); + event VoiceChallenged(bytes32 indexed proposalHash, address indexed subject, address challenger); + event ProposalFinalized(bytes32 indexed proposalHash, bool isApproved); + + constructor(address _mvgAddress, IERC20 _leapToken, Vault _vault) public { + mvgAddress = _mvgAddress; + leapToken = _leapToken; + vault = _vault; + bridge = _vault.bridge(); + } + + function _revertVote(Proposal storage proposal, bytes32 txHash) internal { + address signer = proposal.usedTxns[txHash]; + delete proposal.usedTxns[txHash]; + int256 votes = proposal.votes[signer]; + delete proposal.votes[signer]; + if (votes > 0) { + proposal.yesVotes = proposal.yesVotes.sub(uint256(votes)); + } else { + proposal.noVotes = proposal.noVotes.sub(uint256(votes * -1)); + } + } + + function registerProposal(bytes32 _proposalHash) public { + // make sure same proposals hasn't been opened before + require(proposals[_proposalHash].openTime == 0, "proposal already exists"); + // get instance of token contract and pull proposal stake + leapToken.transferFrom(msg.sender, address(this), PROPOSAL_STAKE); + // create a new proposal in storage + proposals[_proposalHash] = Proposal(msg.sender, uint32(now), false, 0, 0); + // emit event for frontend + emit ProposalRegistered(_proposalHash, msg.sender); + } + + function castVote(bytes32 _proposalHash, bytes32[] memory _proof, uint8 _outputIndex, bool isYes) public { + Proposal memory proposal = proposals[_proposalHash]; + require(proposal.openTime > 0, "proposal does not exist"); + require(proposal.finalized == false, "proposal already finalized"); + uint32 timestamp; + (, timestamp,,) = bridge.periods(_proof[0]); + require(timestamp > 0, "The referenced period was not submitted to bridge"); + require(timestamp <= proposal.openTime, "The transaction was submitted after the vote open time"); + + bytes memory txData; + bytes32 txHash; + (, txHash, txData) = TxLib.validateProof(96, _proof); + + // parse tx and check if it is usable for voting + TxLib.Tx memory tx = TxLib.parseTx(txData); + TxLib.Output memory out = tx.outs[_outputIndex]; + require(out.value >= DUST_THRESHOLD, "UTXO is below dust threshold"); + require(out.owner == msg.sender, "msg.sender not owner of utxo"); + require(address(leapToken) == vault.getTokenAddr(out.color), "not Leap UTXO"); + + if (proposals[_proposalHash].votes[msg.sender] != 0) { + _revertVote(proposals[_proposalHash], txHash); + } + if (isYes) { + proposals[_proposalHash].yesVotes = proposal.yesVotes.add(out.value); + proposals[_proposalHash].votes[msg.sender] = int256(out.value); + } else { + proposals[_proposalHash].noVotes = proposal.noVotes.add(out.value); + proposals[_proposalHash].votes[msg.sender] = int256(out.value) * -1; + } + proposals[_proposalHash].usedTxns[txHash] = msg.sender; + emit VoiceCasted(_proposalHash, msg.sender, out.value, txHash); + } + + function challengeUTXO(bytes32 _proposalHash, bytes32[] memory _proof, uint8 _inputIndex) public { + uint32 timestamp; + (, timestamp,,) = bridge.periods(_proof[0]); + require(timestamp > 0, "The referenced period was not submitted to bridge"); + require(timestamp <= proposals[_proposalHash].openTime, "The transaction was submitted after the vote open time"); + + bytes memory txData; + (,, txData) = TxLib.validateProof(64, _proof); + // parse tx + TxLib.Tx memory txn = TxLib.parseTx(txData); + // checking that the transactions has been used in a vote + address signer = proposals[_proposalHash].usedTxns[txn.ins[_inputIndex].outpoint.hash]; + // note: we don't check the output index here, assume that each transactions is only used for 1 vote + require(signer != address(0), "prevout check failed on hash"); + + // substract vote + _revertVote(proposals[_proposalHash], txn.ins[_inputIndex].outpoint.hash); + emit VoiceChallenged(_proposalHash, signer, msg.sender); + } + + function finalizeProposal(bytes32 _proposalHash) public { + Proposal memory proposal = proposals[_proposalHash]; + require(proposal.finalized == false, "already finalized"); + require(proposal.openTime > 0, "proposal does not exist"); + // disable to simplify testing + require(proposal.openTime + PROPOSAL_TIME < uint32(now), "proposal time not exceeded"); + // can't delete full mappings + // delete proposals[_proposalHash].votes; + // delete proposals[_proposalHash].usedTxns; + proposals[_proposalHash].finalized = true; + // return stake + if (proposal.yesVotes > proposal.noVotes) { + leapToken.transfer(proposal.initiator, PROPOSAL_STAKE); + emit ProposalFinalized(_proposalHash, true); + } else { + leapToken.transfer(mvgAddress, PROPOSAL_STAKE); + emit ProposalFinalized(_proposalHash, false); + } + } + +} \ No newline at end of file diff --git a/test/tokenGovernance.js b/test/tokenGovernance.js new file mode 100644 index 0000000..abab24d --- /dev/null +++ b/test/tokenGovernance.js @@ -0,0 +1,210 @@ +import chai from 'chai'; +import { Tx, Input, Output, Outpoint } from 'leap-core'; +import { EVMRevert, submitNewPeriodWithTx } from './helpers'; + +const time = require('./helpers/time'); +require('./helpers/setup'); + +const AdminableProxy = artifacts.require('AdminableProxy'); +const NativeToken = artifacts.require('NativeToken'); +const Bridge = artifacts.require('Bridge'); +const Vault = artifacts.require('Vault'); +const TokenGovernance = artifacts.require('TokenGovernance'); + +chai.use(require('chai-as-promised')).should(); + +contract('TokenGovernance', (accounts) => { + let bridge; + let vault; + let gov; + let leapToken; + let proxy; + const proposalHash = '0x1122334411223344112233441122334411223344112233441122334411223344'; + const proposalStake = '5000000000000000000000'; + const totalSupply = '20000000000000000000000'; + const parentBlockInterval = 0; + const alice = accounts[0]; + const alicePriv = '0x278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f'; + const bob = accounts[1]; + const mvgAddress = accounts[2]; + const voiceAmount = '2000000000000000000'; + + const submitNewPeriod = txs => submitNewPeriodWithTx(txs, bridge, { from: bob }); + + before(async () => { + leapToken = await NativeToken.new("Leap Token", "Leap", 18); + leapToken.mint(accounts[0], totalSupply); + + const bridgeCont = await Bridge.new(); + let data = await bridgeCont.contract.methods.initialize(parentBlockInterval).encodeABI(); + proxy = await AdminableProxy.new(bridgeCont.address, data, {from: accounts[2]}); + bridge = await Bridge.at(proxy.address); + + data = await bridge.contract.methods.setOperator(bob).encodeABI(); + await proxy.applyProposal(data, {from: accounts[2]}).should.be.fulfilled; + + const vaultCont = await Vault.new(); + data = await vaultCont.contract.methods.initialize(bridge.address).encodeABI(); + proxy = await AdminableProxy.new(vaultCont.address, data, {from: accounts[2]}); + vault = await Vault.at(proxy.address); + + // register first token + data = await vault.contract.methods.registerToken(leapToken.address, 0).encodeABI(); + await proxy.applyProposal(data, {from: accounts[2]}).should.be.fulfilled; + + gov = await TokenGovernance.new(mvgAddress, leapToken.address, vault.address); + }); + + + it('should fail if funds not approved', async () => { + // register proposal + await gov.registerProposal(proposalHash).should.be.rejectedWith(EVMRevert); + }); + + it('should allow to create, vote and finalize proposal', async () => { + + const depositTx = Tx.deposit(123, voiceAmount, alice); + + const period1 = await submitNewPeriod([depositTx]); + const inputProof = period1.proof(depositTx); + + // allow gov contract to pull funds + leapToken.approve(gov.address, proposalStake); + const balBefore = await leapToken.balanceOf(alice); + // register proposal + await gov.registerProposal(proposalHash); + let bal = await leapToken.balanceOf(gov.address); + assert.equal(bal, proposalStake); + // read proposal + let rsp = await gov.proposals(proposalHash); + assert(rsp.openTime > 0); + + // check that same proposal can not be registered twice + leapToken.approve(gov.address, proposalStake); + await gov.registerProposal(proposalHash).should.be.rejectedWith(EVMRevert); + + // cast vote + await gov.castVote(proposalHash, inputProof, 0, false); + rsp = await gov.proposals(proposalHash); + assert.equal(rsp.noVotes, voiceAmount); + + // recast vote + await gov.castVote(proposalHash, inputProof, 0, true); + rsp = await gov.proposals(proposalHash); + assert.equal(rsp.yesVotes, voiceAmount); + assert.equal(rsp.noVotes, 0); + + // wait, finalize and count + await time.increaseTo((await time.latest()) + 60 * 60 * 24 * 7 + 1); + await gov.finalizeProposal(proposalHash); + rsp = await gov.proposals(proposalHash); + assert.equal(rsp.yesVotes, voiceAmount); + assert.equal(rsp.noVotes, 0); + assert.equal(rsp.finalized, true); + // token governance should have returned the stake + bal = await leapToken.balanceOf(gov.address); + assert.equal(bal, 0); + // alice should have same amount like before opening the vote + bal = await leapToken.balanceOf(alice); + assert.equal(bal.toString(), balBefore.toString()); + + // try sending vote to finalized proposal + await gov.castVote(proposalHash, inputProof, 0, false).should.be.rejectedWith(EVMRevert); + // try to re-open same vote + await gov.registerProposal(proposalHash).should.be.rejectedWith(EVMRevert); + }); + + it('should allow to fail proposal', async () => { + const anotherProposalHash = '0x6677889966778899667788996677889966778899667788996677889966778899'; + const transferTx = Tx.transfer( + [new Input(new Outpoint(anotherProposalHash, 0))], + [new Output(voiceAmount, alice)] + ).sign([alicePriv]); + + const period1 = await submitNewPeriod([transferTx]); + const inputProof = period1.proof(transferTx); + + // allow gov contract to pull funds + leapToken.approve(gov.address, proposalStake); + // register proposal + await gov.registerProposal(anotherProposalHash); + let bal = await leapToken.balanceOf(gov.address); + assert.equal(bal, proposalStake); + + // cast vote + await gov.castVote(anotherProposalHash, inputProof, 0, false); + let rsp = await gov.proposals(anotherProposalHash); + assert.equal(rsp.noVotes, voiceAmount); + + // wait, finalize and count + await time.increaseTo((await time.latest()) + 60 * 60 * 24 * 7 + 1); + await gov.finalizeProposal(anotherProposalHash); + rsp = await gov.proposals(anotherProposalHash); + assert.equal(rsp.noVotes, voiceAmount); + assert.equal(rsp.finalized, true); + + // token governance should have returned the stake + bal = await leapToken.balanceOf(gov.address); + assert.equal(bal, 0); + // stake should now be in minimalViableGovernance, as vote resulted in NO + bal = await leapToken.balanceOf(mvgAddress); + assert.equal(bal.toString(), proposalStake); + }); + + it('should prevent vote with younger UTXO', async () => { + const anotherProposalHash = '0x5500aabb5500aabb5500aabb5500aabb5500aabb5500aabb5500aabb5500aabb'; + + // allow gov contract to pull funds + leapToken.approve(gov.address, proposalStake); + // register proposal + await gov.registerProposal(anotherProposalHash); + const bal = await leapToken.balanceOf(gov.address); + assert.equal(bal, proposalStake); + + // increase time + await time.increaseTo((await time.latest()) + 1); + // create utxo after vote start time + const transferTx = Tx.transfer( + [new Input(new Outpoint(anotherProposalHash, 0))], + [new Output(voiceAmount, alice)] + ).sign([alicePriv]); + // submit period + const period1 = await submitNewPeriod([transferTx]); + const inputProof = period1.proof(transferTx); + + // try to cast vote + await gov.castVote(anotherProposalHash, inputProof, 0, true).should.be.rejectedWith(EVMRevert); + }); + + it('should allow challenge UTXO', async () => { + const anotherProposalHash = '0xccddeeffccddeeffccddeeffccddeeffccddeeffccddeeffccddeeffccddeeff'; + // prepare 2 transactions + const depositTx = Tx.deposit(123, voiceAmount, alice); + const transferTx = Tx.transfer( + [new Input(new Outpoint(depositTx.hash(), 0))], + [new Output(voiceAmount, bob)] + ).sign([alicePriv]); + // submit and create proofs + const period1 = await submitNewPeriod([depositTx, transferTx]); + const inputProofA = period1.proof(depositTx); + const inputProofB = period1.proof(transferTx); + + // allow gov contract to pull funds + leapToken.approve(gov.address, proposalStake); + // register proposal + await gov.registerProposal(anotherProposalHash); + + // cast vote with spent UTXO + await gov.castVote(anotherProposalHash, inputProofA, 0, true); + let rsp = await gov.proposals(anotherProposalHash); + assert.equal(rsp.yesVotes, voiceAmount); + + // challenge vote + await gov.challengeUTXO(anotherProposalHash, inputProofB, 0); + + // check that vote got reverted + rsp = await gov.proposals(anotherProposalHash); + assert.equal(rsp.yesVotes, 0); + }); + +});