diff --git a/contracts/GovernanceEngine.sol b/contracts/GovernanceEngine.sol new file mode 100644 index 0000000..49ccb7c --- /dev/null +++ b/contracts/GovernanceEngine.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/math/SafeMath.sol"; + +/** + * @title VotingContract + * @dev Implements voting process along with vote delegation + */ +contract VotingContract is ReentrancyGuard, Ownable { + using SafeMath for uint256; + + struct Voter { + bool voted; // if true, that person already voted + address delegate; // person delegated to + uint vote; // index of the voted proposal + } + + struct Proposal { + bytes32 name; // short name (up to 32 bytes) + uint voteCount; // number of accumulated votes + } + + address public chairperson; + + mapping(address => Voter) public voters; + + Proposal[] public proposals; + + event VoteCasted(address indexed voter, uint proposalIndex); + event VotingDelegated(address indexed from, address indexed to); + event ProposalAdded(bytes32 proposalName); + event ChairpersonChanged(address indexed newChairperson); + + modifier onlyChairperson() { + require(msg.sender == chairperson, "Caller is not the chairperson"); + _; + } + + constructor(bytes32[] memory proposalNames) { + chairperson = msg.sender; + voters[chairperson].voted = false; + + for (uint i = 0; i < proposalNames.length; i++) { + proposals.push(Proposal({ + name: proposalNames[i], + voteCount: 0 + })); + emit ProposalAdded(proposalNames[i]); + } + } + + /** + * @dev Give your vote (including votes delegated to you) to proposal `proposals[proposal].name`. + * @param proposal index of proposal in the proposals array + */ + function vote(uint proposal) external nonReentrant { + Voter storage sender = voters[msg.sender]; + require(!sender.voted, "Already voted."); + require(proposal < proposals.length, "Invalid proposal index."); + + sender.voted = true; + sender.vote = proposal; + + proposals[proposal].voteCount += 1; + emit VoteCasted(msg.sender, proposal); + } + + /** + * @dev Delegate your vote to the voter `to`. + * @param to address to which vote is delegated + */ + function delegate(address to) external nonReentrant { + Voter storage sender = voters[msg.sender]; + require(!sender.voted, "You already voted."); + require(to != msg.sender, "Self-delegation is disallowed."); + + while (voters[to].delegate != address(0)) { + to = voters[to].delegate; + + // We found a loop in the delegation, not allowed. + require(to != msg.sender, "Found loop in delegation."); + } + + sender.voted = true; + sender.delegate = to; + Voter storage delegate_ = voters[to]; + if (delegate_.voted) { + proposals[delegate_.vote].voteCount += 1; + } + emit VotingDelegated(msg.sender, to); + } + + /** + * @dev Computes the winning proposal taking all previous votes into account. + * @return winningProposal_ index of winning proposal in the proposals array + */ + function winningProposal() public view returns (uint winningProposal_) { + uint winningVoteCount = 0; + for (uint p = 0; p < proposals.length; p++) { + if (proposals[p].voteCount > winningVoteCount) { + winningVoteCount = proposals[p].voteCount; + winningProposal_ = p; + } + } + } + + /** + * @dev Gets the name of the winning proposal + * @return winnerName_ name of the winning proposal + */ + function winnerName() external view returns (bytes32 winnerName_) { + winnerName_ = proposals[winningProposal()].name; + } + + /** + * @dev Changes the chairperson of the voting contract + * @param newChairperson address of the new chairperson + */ + function changeChairperson(address newChairperson) external onlyOwner { + require(newChairperson != address(0), "Zero address not allowed."); + chairperson = newChairperson; + emit ChairpersonChanged(newChairperson); + } + + /** + * @dev Adds a new proposal to the list of proposals + * @param proposalName name of the new proposal + */ + function addProposal(bytes32 proposalName) external onlyChairperson { + proposals.push(Proposal({ + name: proposalName, + voteCount: 0 + })); + emit ProposalAdded(proposalName); + } +} diff --git a/contracts/GovernanceHub.sol b/contracts/GovernanceHub.sol new file mode 100644 index 0000000..5daeb67 --- /dev/null +++ b/contracts/GovernanceHub.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract GovernanceHub { + struct Proposal { + uint256 id; + address proposer; + string description; + uint256 deadline; + uint256 votesFor; + uint256 votesAgainst; + bool executed; + } + + mapping(uint256 => Proposal) public proposals; + mapping(address => mapping(uint256 => bool)) public hasVoted; + uint256 public proposalCount; + uint256 public quorum; + address public executionAuthority; + + event ProposalCreated(uint256 indexed id, address indexed proposer, string description, uint256 deadline); + event Voted(uint256 indexed proposalId, address indexed voter, bool support); + event ProposalExecuted(uint256 indexed proposalId); + + modifier onlyExecutionAuthority() { + require(msg.sender == executionAuthority, "Not execution authority"); + _; + } + + constructor(uint256 _quorum, address _executionAuthority) { + quorum = _quorum; + executionAuthority = _executionAuthority; + } + + function createProposal(string memory description, uint256 duration) external { + require(bytes(description).length > 0, "Description cannot be empty"); + + uint256 deadline = block.timestamp + duration; + + proposals[proposalCount] = Proposal({ + id: proposalCount, + proposer: msg.sender, + description: description, + deadline: deadline, + votesFor: 0, + votesAgainst: 0, + executed: false + }); + + emit ProposalCreated(proposalCount, msg.sender, description, deadline); + proposalCount++; + } + + function vote(uint256 proposalId, bool support) external { + Proposal storage proposal = proposals[proposalId]; + require(block.timestamp < proposal.deadline, "Voting period has ended"); + require(!hasVoted[msg.sender][proposalId], "Already voted"); + + if (support) { + proposal.votesFor++; + } else { + proposal.votesAgainst++; + } + hasVoted[msg.sender][proposalId] = true; + + emit Voted(proposalId, msg.sender, support); + } + + function executeProposal(uint256 proposalId) external onlyExecutionAuthority { + Proposal storage proposal = proposals[proposalId]; + require(block.timestamp >= proposal.deadline, "Proposal is still active"); + require(!proposal.executed, "Proposal already executed"); + require(proposal.votesFor + proposal.votesAgainst >= quorum, "Quorum not reached"); + + proposal.executed = true; + + // Placeholder for execution logic + // execute(proposal); + + emit ProposalExecuted(proposalId); + } +} \ No newline at end of file diff --git a/hardhat.config.js b/hardhat.config.js new file mode 100644 index 0000000..4d384dc --- /dev/null +++ b/hardhat.config.js @@ -0,0 +1,20 @@ +require("@nomicfoundation/hardhat-toolbox"); + +/** @type import('hardhat/config').HardhatUserConfig */ +module.exports = { + solidity: { + version: "0.8.19", + settings: { + optimizer: { + enabled: true, + runs: 200 + } + } + }, + paths: { + sources: "./contracts", + tests: "./test", + cache: "./cache", + artifacts: "./artifacts" + } +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d2c9d6f --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "verisphere-governance", + "version": "1.0.0", + "description": "VeriSphere Governance Engine smart contracts", + "scripts": { + "compile": "hardhat compile", + "test": "hardhat test", + "test:coverage": "hardhat coverage" + }, + "devDependencies": { + "@nomicfoundation/hardhat-toolbox": "^4.0.0", + "@nomicfoundation/hardhat-network-helpers": "^1.0.10", + "@openzeppelin/contracts": "^5.0.1", + "hardhat": "^2.19.4" + }, + "license": "MIT" +} diff --git a/test/GovernanceEngine.test.js b/test/GovernanceEngine.test.js new file mode 100644 index 0000000..9906c01 --- /dev/null +++ b/test/GovernanceEngine.test.js @@ -0,0 +1,213 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +describe("VotingContract (GovernanceEngine)", function () { + let votingContract; + let owner; + let chairperson; + let voter1; + let voter2; + let voter3; + + const proposalNames = [ + ethers.utils.formatBytes32String("Proposal A"), + ethers.utils.formatBytes32String("Proposal B"), + ethers.utils.formatBytes32String("Proposal C") + ]; + + beforeEach(async function () { + [owner, chairperson, voter1, voter2, voter3] = await ethers.getSigners(); + + const VotingContract = await ethers.getContractFactory("VotingContract"); + votingContract = await VotingContract.connect(chairperson).deploy(proposalNames); + await votingContract.deployed(); + }); + + describe("Deployment", function () { + it("should set deployer as chairperson", async function () { + expect(await votingContract.chairperson()).to.equal(chairperson.address); + }); + + it("should create proposals from constructor", async function () { + for (let i = 0; i < proposalNames.length; i++) { + const proposal = await votingContract.proposals(i); + expect(proposal.name).to.equal(proposalNames[i]); + expect(proposal.voteCount).to.equal(0); + } + }); + + it("should emit ProposalAdded events", async function () { + const VotingContract = await ethers.getContractFactory("VotingContract"); + + await expect(VotingContract.deploy(proposalNames)) + .to.emit(VotingContract, "ProposalAdded"); + }); + }); + + describe("vote", function () { + it("should allow voting for a proposal", async function () { + await expect(votingContract.connect(voter1).vote(0)) + .to.emit(votingContract, "VoteCasted") + .withArgs(voter1.address, 0); + + const proposal = await votingContract.proposals(0); + expect(proposal.voteCount).to.equal(1); + }); + + it("should mark voter as having voted", async function () { + await votingContract.connect(voter1).vote(1); + + const voter = await votingContract.voters(voter1.address); + expect(voter.voted).to.equal(true); + expect(voter.vote).to.equal(1); + }); + + it("should prevent double voting", async function () { + await votingContract.connect(voter1).vote(0); + + await expect(votingContract.connect(voter1).vote(1)) + .to.be.revertedWith("Already voted."); + }); + + it("should reject invalid proposal index", async function () { + await expect(votingContract.connect(voter1).vote(99)) + .to.be.revertedWith("Invalid proposal index."); + }); + + it("should count multiple votes correctly", async function () { + await votingContract.connect(voter1).vote(0); + await votingContract.connect(voter2).vote(0); + await votingContract.connect(voter3).vote(1); + + const proposal0 = await votingContract.proposals(0); + const proposal1 = await votingContract.proposals(1); + + expect(proposal0.voteCount).to.equal(2); + expect(proposal1.voteCount).to.equal(1); + }); + }); + + describe("delegate", function () { + it("should allow delegation of vote", async function () { + await expect(votingContract.connect(voter1).delegate(voter2.address)) + .to.emit(votingContract, "VotingDelegated") + .withArgs(voter1.address, voter2.address); + + const voter1Data = await votingContract.voters(voter1.address); + expect(voter1Data.voted).to.equal(true); + expect(voter1Data.delegate).to.equal(voter2.address); + }); + + it("should add vote if delegate already voted", async function () { + await votingContract.connect(voter2).vote(1); + await votingContract.connect(voter1).delegate(voter2.address); + + const proposal = await votingContract.proposals(1); + expect(proposal.voteCount).to.equal(2); + }); + + it("should prevent self-delegation", async function () { + await expect(votingContract.connect(voter1).delegate(voter1.address)) + .to.be.revertedWith("Self-delegation is disallowed."); + }); + + it("should prevent delegation after voting", async function () { + await votingContract.connect(voter1).vote(0); + + await expect(votingContract.connect(voter1).delegate(voter2.address)) + .to.be.revertedWith("You already voted."); + }); + + it("should detect delegation loops", async function () { + await votingContract.connect(voter1).delegate(voter2.address); + await votingContract.connect(voter2).delegate(voter3.address); + + await expect(votingContract.connect(voter3).delegate(voter1.address)) + .to.be.revertedWith("Found loop in delegation."); + }); + + it("should follow delegation chain", async function () { + await votingContract.connect(voter1).delegate(voter2.address); + await votingContract.connect(voter2).delegate(voter3.address); + await votingContract.connect(voter3).vote(2); + + const proposal = await votingContract.proposals(2); + expect(proposal.voteCount).to.equal(3); + }); + }); + + describe("winningProposal", function () { + it("should return proposal with most votes", async function () { + await votingContract.connect(voter1).vote(1); + await votingContract.connect(voter2).vote(1); + await votingContract.connect(voter3).vote(0); + + expect(await votingContract.winningProposal()).to.equal(1); + }); + + it("should return first proposal on tie", async function () { + await votingContract.connect(voter1).vote(0); + await votingContract.connect(voter2).vote(1); + + expect(await votingContract.winningProposal()).to.equal(0); + }); + + it("should return zero with no votes", async function () { + expect(await votingContract.winningProposal()).to.equal(0); + }); + }); + + describe("winnerName", function () { + it("should return name of winning proposal", async function () { + await votingContract.connect(voter1).vote(2); + await votingContract.connect(voter2).vote(2); + + expect(await votingContract.winnerName()).to.equal(proposalNames[2]); + }); + }); + + describe("addProposal", function () { + it("should allow chairperson to add proposals", async function () { + const newProposal = ethers.utils.formatBytes32String("New Proposal"); + + await expect(votingContract.connect(chairperson).addProposal(newProposal)) + .to.emit(votingContract, "ProposalAdded") + .withArgs(newProposal); + + const proposal = await votingContract.proposals(3); + expect(proposal.name).to.equal(newProposal); + }); + + it("should reject non-chairperson adding proposals", async function () { + const newProposal = ethers.utils.formatBytes32String("Unauthorized"); + + await expect(votingContract.connect(voter1).addProposal(newProposal)) + .to.be.revertedWith("Caller is not the chairperson"); + }); + }); + + describe("changeChairperson", function () { + it("should allow owner to change chairperson", async function () { + await expect(votingContract.connect(chairperson).changeChairperson(voter1.address)) + .to.emit(votingContract, "ChairpersonChanged") + .withArgs(voter1.address); + + expect(await votingContract.chairperson()).to.equal(voter1.address); + }); + + it("should reject zero address", async function () { + await expect(votingContract.connect(chairperson).changeChairperson(ethers.constants.AddressZero)) + .to.be.revertedWith("Zero address not allowed."); + }); + }); + + describe("ReentrancyGuard", function () { + it("should protect vote function", async function () { + // ReentrancyGuard is inherited, verify by checking nonReentrant modifier exists + // This is implicitly tested by successful vote execution + await votingContract.connect(voter1).vote(0); + const voter = await votingContract.voters(voter1.address); + expect(voter.voted).to.equal(true); + }); + }); +}); diff --git a/test/GovernanceHub.test.js b/test/GovernanceHub.test.js new file mode 100644 index 0000000..146a04c --- /dev/null +++ b/test/GovernanceHub.test.js @@ -0,0 +1,185 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { time } = require("@nomicfoundation/hardhat-network-helpers"); + +describe("GovernanceHub", function () { + let governanceHub; + let owner; + let executionAuthority; + let voter1; + let voter2; + let voter3; + + const QUORUM = 3; + const VOTING_DURATION = 86400; // 1 day in seconds + + beforeEach(async function () { + [owner, executionAuthority, voter1, voter2, voter3] = await ethers.getSigners(); + + const GovernanceHub = await ethers.getContractFactory("GovernanceHub"); + governanceHub = await GovernanceHub.deploy(QUORUM, executionAuthority.address); + await governanceHub.deployed(); + }); + + describe("Deployment", function () { + it("should set the correct quorum", async function () { + expect(await governanceHub.quorum()).to.equal(QUORUM); + }); + + it("should set the correct execution authority", async function () { + expect(await governanceHub.executionAuthority()).to.equal(executionAuthority.address); + }); + + it("should start with zero proposals", async function () { + expect(await governanceHub.proposalCount()).to.equal(0); + }); + }); + + describe("createProposal", function () { + it("should create a proposal with correct parameters", async function () { + const description = "Upgrade protocol to v2"; + + await expect(governanceHub.connect(voter1).createProposal(description, VOTING_DURATION)) + .to.emit(governanceHub, "ProposalCreated") + .withArgs(0, voter1.address, description, await getDeadline(VOTING_DURATION)); + + const proposal = await governanceHub.proposals(0); + expect(proposal.proposer).to.equal(voter1.address); + expect(proposal.description).to.equal(description); + expect(proposal.votesFor).to.equal(0); + expect(proposal.votesAgainst).to.equal(0); + expect(proposal.executed).to.equal(false); + }); + + it("should increment proposal count", async function () { + await governanceHub.createProposal("Proposal 1", VOTING_DURATION); + await governanceHub.createProposal("Proposal 2", VOTING_DURATION); + + expect(await governanceHub.proposalCount()).to.equal(2); + }); + + it("should reject empty description", async function () { + await expect(governanceHub.createProposal("", VOTING_DURATION)) + .to.be.revertedWith("Description cannot be empty"); + }); + }); + + describe("vote", function () { + beforeEach(async function () { + await governanceHub.createProposal("Test proposal", VOTING_DURATION); + }); + + it("should allow voting for a proposal", async function () { + await expect(governanceHub.connect(voter1).vote(0, true)) + .to.emit(governanceHub, "Voted") + .withArgs(0, voter1.address, true); + + const proposal = await governanceHub.proposals(0); + expect(proposal.votesFor).to.equal(1); + expect(proposal.votesAgainst).to.equal(0); + }); + + it("should allow voting against a proposal", async function () { + await governanceHub.connect(voter1).vote(0, false); + + const proposal = await governanceHub.proposals(0); + expect(proposal.votesFor).to.equal(0); + expect(proposal.votesAgainst).to.equal(1); + }); + + it("should prevent double voting", async function () { + await governanceHub.connect(voter1).vote(0, true); + + await expect(governanceHub.connect(voter1).vote(0, false)) + .to.be.revertedWith("Already voted"); + }); + + it("should reject votes after deadline", async function () { + await time.increase(VOTING_DURATION + 1); + + await expect(governanceHub.connect(voter1).vote(0, true)) + .to.be.revertedWith("Voting period has ended"); + }); + + it("should track multiple voters correctly", async function () { + await governanceHub.connect(voter1).vote(0, true); + await governanceHub.connect(voter2).vote(0, true); + await governanceHub.connect(voter3).vote(0, false); + + const proposal = await governanceHub.proposals(0); + expect(proposal.votesFor).to.equal(2); + expect(proposal.votesAgainst).to.equal(1); + }); + }); + + describe("executeProposal", function () { + beforeEach(async function () { + await governanceHub.createProposal("Test proposal", VOTING_DURATION); + await governanceHub.connect(voter1).vote(0, true); + await governanceHub.connect(voter2).vote(0, true); + await governanceHub.connect(voter3).vote(0, true); + }); + + it("should execute proposal after deadline with quorum met", async function () { + await time.increase(VOTING_DURATION + 1); + + await expect(governanceHub.connect(executionAuthority).executeProposal(0)) + .to.emit(governanceHub, "ProposalExecuted") + .withArgs(0); + + const proposal = await governanceHub.proposals(0); + expect(proposal.executed).to.equal(true); + }); + + it("should reject execution before deadline", async function () { + await expect(governanceHub.connect(executionAuthority).executeProposal(0)) + .to.be.revertedWith("Proposal is still active"); + }); + + it("should reject execution without quorum", async function () { + await governanceHub.createProposal("Low vote proposal", VOTING_DURATION); + await governanceHub.connect(voter1).vote(1, true); + + await time.increase(VOTING_DURATION + 1); + + await expect(governanceHub.connect(executionAuthority).executeProposal(1)) + .to.be.revertedWith("Quorum not reached"); + }); + + it("should reject double execution", async function () { + await time.increase(VOTING_DURATION + 1); + await governanceHub.connect(executionAuthority).executeProposal(0); + + await expect(governanceHub.connect(executionAuthority).executeProposal(0)) + .to.be.revertedWith("Proposal already executed"); + }); + + it("should reject execution by non-authority", async function () { + await time.increase(VOTING_DURATION + 1); + + await expect(governanceHub.connect(voter1).executeProposal(0)) + .to.be.revertedWith("Not execution authority"); + }); + }); + + describe("Quorum enforcement", function () { + it("should enforce quorum with mixed votes", async function () { + await governanceHub.createProposal("Quorum test", VOTING_DURATION); + + await governanceHub.connect(voter1).vote(0, true); + await governanceHub.connect(voter2).vote(0, false); + await governanceHub.connect(voter3).vote(0, true); + + await time.increase(VOTING_DURATION + 1); + + // Total votes (3) >= quorum (3), should pass + await expect(governanceHub.connect(executionAuthority).executeProposal(0)) + .to.emit(governanceHub, "ProposalExecuted"); + }); + }); + + async function getDeadline(duration) { + const block = await ethers.provider.getBlock("latest"); + return block.timestamp + duration; + } +});