Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

token voting #262

Merged
merged 7 commits into from
Dec 17, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions contracts/TokenGovernance.sol
Original file line number Diff line number Diff line change
@@ -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);
MaxStalker marked this conversation as resolved.
Show resolved Hide resolved
// 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
MaxStalker marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}

}
210 changes: 210 additions & 0 deletions test/tokenGovernance.js
Original file line number Diff line number Diff line change
@@ -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);
});

});