diff --git a/packages/concordia-app/src/views/Home/index.jsx b/packages/concordia-app/src/views/Home/index.jsx index 69305ba..989d684 100644 --- a/packages/concordia-app/src/views/Home/index.jsx +++ b/packages/concordia-app/src/views/Home/index.jsx @@ -8,20 +8,20 @@ import './styles.css'; import { drizzle } from '../../redux/store'; import { FORUM_CONTRACT } from '../../constants/contracts/ContractNames'; -const { contracts: { [FORUM_CONTRACT]: { methods: { getNumberOfTopics } } } } = drizzle; +const { contracts: { [FORUM_CONTRACT]: { methods: { numTopics } } } } = drizzle; const Home = () => { const [numberOfTopicsCallHash, setNumberOfTopicsCallHash] = useState(''); - const getNumberOfTopicsResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getNumberOfTopics); + const numTopicsResults = useSelector((state) => state.contracts[FORUM_CONTRACT].numTopics); useEffect(() => { - setNumberOfTopicsCallHash(getNumberOfTopics.cacheCall()); + setNumberOfTopicsCallHash(numTopics.cacheCall()); }, []); - const numberOfTopics = useMemo(() => (getNumberOfTopicsResults[numberOfTopicsCallHash] !== undefined - ? parseInt(getNumberOfTopicsResults[numberOfTopicsCallHash].value, 10) + const numberOfTopics = useMemo(() => (numTopicsResults[numberOfTopicsCallHash] !== undefined + ? parseInt(numTopicsResults[numberOfTopicsCallHash].value, 10) : null), - [getNumberOfTopicsResults, numberOfTopicsCallHash]); + [numTopicsResults, numberOfTopicsCallHash]); return useMemo(() => ( diff --git a/packages/concordia-contracts/.eslintrc.js b/packages/concordia-contracts/.eslintrc.js index 3b8b151..f4fd0dd 100644 --- a/packages/concordia-contracts/.eslintrc.js +++ b/packages/concordia-contracts/.eslintrc.js @@ -31,7 +31,9 @@ module.exports = { 'no-unused-vars': 'warn', 'no-console': 'warn', 'no-shadow': 'warn', - "no-multi-str": "warn" + 'no-multi-str': 'warn', + 'one-var': ["error", { "uninitialized": "always" }], + 'one-var-declaration-per-line': ['error', 'initializations'] }, 'settings': { 'import/resolver': { diff --git a/packages/concordia-contracts/.solhint.json b/packages/concordia-contracts/.solhint.json index d7c3de9..c610645 100644 --- a/packages/concordia-contracts/.solhint.json +++ b/packages/concordia-contracts/.solhint.json @@ -1,3 +1,9 @@ { - "extends": "solhint:default" + "extends": "solhint:recommended", + "rules": { + "compiler-version": ["error","~0.8.0"], + "func-visibility": ["warn",{"ignoreConstructors" : true}], + "not-rely-on-time": "off", + "state-visibility": "off" + } } diff --git a/packages/concordia-contracts/contracts/Forum.sol b/packages/concordia-contracts/contracts/Forum.sol index cff1f8c..05d6031 100644 --- a/packages/concordia-contracts/contracts/Forum.sol +++ b/packages/concordia-contracts/contracts/Forum.sol @@ -1,8 +1,12 @@ //SPDX-License-Identifier: MIT -pragma solidity 0.7.1; -pragma experimental ABIEncoderV2; +pragma solidity 0.8.0; contract Forum { + // Error messages for require() + string public constant USER_HAS_NOT_SIGNED_UP = "User hasn't signed up yet."; + string public constant USERNAME_TAKEN = "Username is already taken."; + string public constant TOPIC_DOES_NOT_EXIST = "Topic doesn't exist."; + string public constant POST_DOES_NOT_EXIST = "Post doesn't exist."; //----------------------------------------USER---------------------------------------- struct User { @@ -13,25 +17,24 @@ contract Forum { bool signedUp; // Helper variable for hasUserSignedUp() } - mapping (address => User) users; - mapping (string => address) userAddresses; + mapping(address => User) users; + mapping(string => address) userAddresses; event UserSignedUp(string username, address userAddress); event UsernameUpdated(string newName, string oldName, address userAddress); function signUp(string memory username) public returns (bool) { - require (!hasUserSignedUp(msg.sender), "User has already signed up."); - require(!isUserNameTaken(username), "Username is already taken."); - users[msg.sender] = User(username, - new uint[](0), new uint[](0), block.timestamp, true); + require(!hasUserSignedUp(msg.sender), USER_HAS_NOT_SIGNED_UP); + require(!isUserNameTaken(username), USERNAME_TAKEN); + users[msg.sender] = User(username, new uint[](0), new uint[](0), block.timestamp, true); userAddresses[username] = msg.sender; emit UserSignedUp(username, msg.sender); return true; } function updateUsername(string memory newUsername) public returns (bool) { - require (hasUserSignedUp(msg.sender), "User hasn't signed up yet."); - require(!isUserNameTaken(newUsername), "Username is already taken."); + require(hasUserSignedUp(msg.sender), USER_HAS_NOT_SIGNED_UP); + require(!isUserNameTaken(newUsername), USERNAME_TAKEN); string memory oldUsername = getUsername(msg.sender); delete userAddresses[users[msg.sender].username]; users[msg.sender].username = newUsername; @@ -41,7 +44,7 @@ contract Forum { } function getUsername(address userAddress) public view returns (string memory) { - require (hasUserSignedUp(userAddress), "User hasn't signed up yet."); + require(hasUserSignedUp(userAddress), USER_HAS_NOT_SIGNED_UP); return users[userAddress].username; } @@ -54,28 +57,28 @@ contract Forum { } function isUserNameTaken(string memory username) public view returns (bool) { - if (getUserAddress(username)!=address(0)) + if (getUserAddress(username) != address(0)) return true; return false; } function getUserTopics(address userAddress) public view returns (uint[] memory) { - require (hasUserSignedUp(userAddress), "User hasn't signed up yet."); + require(hasUserSignedUp(userAddress), USER_HAS_NOT_SIGNED_UP); return users[userAddress].topicIDs; } function getUserPosts(address userAddress) public view returns (uint[] memory) { - require (hasUserSignedUp(userAddress), "User hasn't signed up yet."); + require(hasUserSignedUp(userAddress), USER_HAS_NOT_SIGNED_UP); return users[userAddress].postIDs; } function getUserDateOfRegister(address userAddress) public view returns (uint) { - require (hasUserSignedUp(userAddress), "User hasn't signed up yet."); + require(hasUserSignedUp(userAddress), USER_HAS_NOT_SIGNED_UP); return users[userAddress].timestamp; } function getUser(address userAddress) public view returns (User memory) { - require(hasUserSignedUp(userAddress), "User hasn't signed up yet."); + require(hasUserSignedUp(userAddress), USER_HAS_NOT_SIGNED_UP); return users[userAddress]; } @@ -94,17 +97,17 @@ contract Forum { uint topicID; } - uint numTopics; // Total number of topics - uint numPosts; // Total number of posts + uint public numTopics; // Total number of topics + uint public numPosts; // Total number of posts - mapping (uint => Topic) topics; - mapping (uint => Post) posts; + mapping(uint => Topic) topics; + mapping(uint => Post) posts; event TopicCreated(uint topicID, uint postID); event PostCreated(uint postID, uint topicID); function createTopic() public returns (uint, uint) { - require(hasUserSignedUp(msg.sender)); // Only registered users can create topics + require(hasUserSignedUp(msg.sender), USER_HAS_NOT_SIGNED_UP); //Creates topic uint topicID = numTopics++; topics[topicID] = Topic(topicID, msg.sender, block.timestamp, new uint[](0)); @@ -121,8 +124,8 @@ contract Forum { } function createPost(uint topicID) public returns (uint) { - require(hasUserSignedUp(msg.sender)); // Only registered users can create posts - require(topicID 0, UP -> 1, DOWN -> 2 + + struct PostBallot { + mapping(address => Option) votes; + mapping(Option => address[]) voters; + } + + mapping(uint => PostBallot) postBallots; + + event UserVotedPost(address userAddress, uint postID, Option option); + + function getVote(uint postID, address voter) public view returns (Option) { + require(forum.postExists(postID), forum.POST_DOES_NOT_EXIST()); + return postBallots[postID].votes[voter]; + } + + // Gets vote count for a specific option (Option.UP/ Option.DOWN only!) + function getVoteCount(uint postID, Option option) private view returns (uint) { + require(forum.postExists(postID), forum.POST_DOES_NOT_EXIST()); + return (postBallots[postID].voters[option].length); + } + + function getUpvoteCount(uint postID) public view returns (uint) { + return (getVoteCount(postID, Option.UP)); + } + + function getDownvoteCount(uint postID) public view returns (uint) { + return (getVoteCount(postID, Option.DOWN)); + } + + // Gets voters for a specific option (Option.UP/ Option.DOWN) + function getVoters(uint postID, Option option) private view returns (address[] memory) { + require(forum.postExists(postID), forum.POST_DOES_NOT_EXIST()); + return (postBallots[postID].voters[option]); + } + + function getUpvoters(uint postID) public view returns (address[] memory) { + return (getVoters(postID, Option.UP)); + } + + function getDownvoters(uint postID) public view returns (address[] memory) { + return (getVoters(postID, Option.DOWN)); + } + + function getVoterIndex(uint postID, address voter) private view returns (uint) { + require(forum.hasUserSignedUp(voter), forum.USER_HAS_NOT_SIGNED_UP()); + require(forum.postExists(postID), forum.POST_DOES_NOT_EXIST()); + + PostBallot storage postBallot = postBallots[postID]; + Option votedOption = getVote(postID, voter); + address[] storage optionVoters = postBallot.voters[votedOption]; + + for (uint voterIndex = 0; voterIndex < optionVoters.length; voterIndex++) + if (optionVoters[voterIndex] == voter) + return voterIndex; + + revert("Couldn't find voter's index!"); + } + + function vote(uint postID, Option option) private { + require(forum.hasUserSignedUp(msg.sender), forum.USER_HAS_NOT_SIGNED_UP()); + require(forum.postExists(postID), forum.POST_DOES_NOT_EXIST()); + + PostBallot storage postBallot = postBallots[postID]; + address voter = msg.sender; + Option prevOption = postBallot.votes[voter]; + + if (prevOption == option) + return; + + // Remove previous vote if exists + if (prevOption != Option.DEFAULT) { + uint voterIndex = getVoterIndex(postID, voter); + // Swap with last voter address and delete vote + postBallot.voters[prevOption][voterIndex] = postBallot.voters[prevOption][postBallot.voters[prevOption].length - 1]; + postBallot.voters[prevOption].pop(); + } + + // Add new vote + if (option != Option.DEFAULT) + postBallot.voters[option].push(voter); + postBallot.votes[voter] = option; + emit UserVotedPost(voter, postID, option); + } + + function upvote(uint postID) public { + vote(postID, Option.UP); + } + + function downvote(uint postID) public { + vote(postID, Option.DOWN); + } + + function unvote(uint postID) public { + vote(postID, Option.DEFAULT); + } +} diff --git a/packages/concordia-contracts/contracts/Voting.sol b/packages/concordia-contracts/contracts/Voting.sol new file mode 100644 index 0000000..2ce5a6a --- /dev/null +++ b/packages/concordia-contracts/contracts/Voting.sol @@ -0,0 +1,155 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.0; + +import "./Forum.sol"; + +contract Voting { + // Error messages for require() + string constant TOPIC_POLL_DIFFERENT_CREATOR = "Only topic's author can create a poll."; + string constant POLL_EXISTS = "Poll already exists."; + string constant POLL_DOES_NOT_EXIST = "Poll does not exist."; + string constant INVALID_OPTION = "Invalid option."; + string constant USER_HAS_NOT_VOTED = "User hasn't voted."; + + Forum public forum; + + constructor(Forum addr) { + forum = Forum(addr); + } + + struct Poll { + uint topicID; + uint numOptions; + string dataHash; + mapping(address => uint) votes; + mapping(uint => address[]) voters; + bool enableVoteChanges; + uint timestamp; + } + + mapping(uint => Poll) polls; + + event PollCreated(uint topicID); + event UserVotedPoll(address userAddress, uint topicID, uint vote); + + function pollExists(uint topicID) public view returns (bool) { + if (polls[topicID].timestamp != 0) + return true; + return false; + } + + function createPoll(uint topicID, uint numOptions, string memory dataHash, bool enableVoteChanges) public returns (uint) { + require(forum.hasUserSignedUp(msg.sender), forum.USER_HAS_NOT_SIGNED_UP()); + require(forum.topicExists(topicID), forum.TOPIC_DOES_NOT_EXIST()); + require(forum.getTopicAuthor(topicID) == msg.sender, TOPIC_POLL_DIFFERENT_CREATOR); + require(!pollExists(topicID), POLL_EXISTS); + + Poll storage poll = polls[topicID]; + poll.topicID = topicID; + poll.numOptions = numOptions; + poll.dataHash = dataHash; + poll.enableVoteChanges = enableVoteChanges; + poll.timestamp = block.timestamp; + + emit PollCreated(topicID); + return topicID; + } + + function getPollInfo(uint topicID) public view returns (uint, string memory, uint, uint) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + + uint totalVotes = getTotalVotes(topicID); + + return ( + polls[topicID].numOptions, + polls[topicID].dataHash, + polls[topicID].timestamp, + totalVotes + ); + } + + function isOptionValid(uint topicID, uint option) public view returns (bool) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + if (option <= polls[topicID].numOptions) // Option 0 is valid as well (no option chosen) + return true; + return false; + } + + function hasVoted(uint topicID, address voter) public view returns (bool) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + if (polls[topicID].votes[voter] != 0) + return true; + return false; + } + + function getVote(uint topicID, address voter) public view returns (uint) { + require(hasVoted(topicID, voter), USER_HAS_NOT_VOTED); + return polls[topicID].votes[voter]; + } + + function getVoteCount(uint topicID, uint option) public view returns (uint) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + require(isOptionValid(topicID, option), INVALID_OPTION); + return (polls[topicID].voters[option].length); + } + + function getTotalVotes(uint topicID) public view returns (uint) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + + Poll storage poll = polls[topicID]; + uint totalVotes = 0; + + for (uint pollOption = 1; pollOption <= poll.numOptions; pollOption++) + totalVotes += poll.voters[pollOption].length; + + return totalVotes; + } + + // Gets voters for a specific option + function getVoters(uint topicID, uint option) public view returns (address[] memory) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + return (polls[topicID].voters[option]); + } + + function getVoterIndex(uint topicID, address voter) public view returns (uint) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + require(hasVoted(topicID, voter), USER_HAS_NOT_VOTED); + Poll storage poll = polls[topicID]; + uint votedOption = getVote(topicID, voter); + address[] storage optionVoters = poll.voters[votedOption]; + + for (uint voterIndex = 0; voterIndex < optionVoters.length; voterIndex++) + if (optionVoters[voterIndex] == voter) + return voterIndex; + + revert("Couldn't find voter's index!"); + } + + function vote(uint topicID, uint option) public { + require(forum.hasUserSignedUp(msg.sender), forum.USER_HAS_NOT_SIGNED_UP()); + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + require(isOptionValid(topicID, option), INVALID_OPTION); + Poll storage poll = polls[topicID]; + address voter = msg.sender; + uint prevOption = poll.votes[voter]; + if (prevOption == option) + return; + + // Voter hasn't voted before + if (prevOption == 0) { + poll.voters[option].push(voter); + poll.votes[voter] = option; + emit UserVotedPoll(voter, topicID, option); + } + else if (poll.enableVoteChanges) { + uint voterIndex = getVoterIndex(topicID, voter); + // Swap with last voter address and delete vote + poll.voters[prevOption][voterIndex] = poll.voters[prevOption][poll.voters[prevOption].length - 1]; + poll.voters[prevOption].pop(); + if (option != 0) + poll.voters[option].push(voter); + poll.votes[voter] = option; + emit UserVotedPoll(voter, topicID, option); + } + } +} diff --git a/packages/concordia-contracts/index.js b/packages/concordia-contracts/index.js index 0ab0aa9..bc231ce 100644 --- a/packages/concordia-contracts/index.js +++ b/packages/concordia-contracts/index.js @@ -1,14 +1,16 @@ -let Forum; +let Forum, Voting, PostVoting; +/* eslint-disable global-require */ try { - // eslint-disable-next-line global-require Forum = require('./build/Forum.json'); + Voting = require('./build/Voting.json'); + PostVoting = require('./build/PostVoting.json'); } catch (e) { // eslint-disable-next-line no-console console.error("Could not require contract artifacts. Haven't you run compile yet?"); } module.exports = { - contracts: [Forum], + contracts: [Forum, Voting, PostVoting], forumContract: Forum, }; diff --git a/packages/concordia-contracts/migrations/2_deploy_contracts.js b/packages/concordia-contracts/migrations/2_deploy_contracts.js index ec5722e..15daf19 100644 --- a/packages/concordia-contracts/migrations/2_deploy_contracts.js +++ b/packages/concordia-contracts/migrations/2_deploy_contracts.js @@ -1,6 +1,12 @@ const Forum = artifacts.require('Forum'); +const Voting = artifacts.require('Voting'); +const PostVoting = artifacts.require('PostVoting'); // eslint-disable-next-line func-names module.exports = function (deployer) { - deployer.deploy(Forum); + return deployer.deploy(Forum) + .then(async (forum) => Promise.all([ + deployer.deploy(Voting, forum.address), + deployer.deploy(PostVoting, forum.address), + ])); }; diff --git a/packages/concordia-contracts/package.json b/packages/concordia-contracts/package.json index f4be42f..595e1f1 100644 --- a/packages/concordia-contracts/package.json +++ b/packages/concordia-contracts/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@openzeppelin/contracts": "~3.2.0", - "truffle": "~5.1.45" + "truffle": "~5.1.55" }, "devDependencies": { "eslint": "^6.8.0", diff --git a/packages/concordia-contracts/test/TestVoting.sol b/packages/concordia-contracts/test/TestVoting.sol new file mode 100644 index 0000000..dd0c3a4 --- /dev/null +++ b/packages/concordia-contracts/test/TestVoting.sol @@ -0,0 +1,112 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.0; + +import "truffle/Assert.sol"; +import "truffle/DeployedAddresses.sol"; +import "../contracts/Forum.sol"; +import "../contracts/Voting.sol"; + +contract TestVoting { + Forum forum; + uint firstTopicId; + + function beforeAll() public { + forum = Forum(DeployedAddresses.Forum()); + + forum.signUp("testAccount"); + (firstTopicId,) = forum.createTopic(); + } + + function testIfPollExists() public { + Voting voting = Voting(DeployedAddresses.Voting()); + + bool actual = voting.pollExists(firstTopicId); + + Assert.equal(actual, false, "Poll should not exist"); + } + + function testCreatePoll() public { + Voting voting = Voting(DeployedAddresses.Voting()); + + uint actual = voting.createPoll(firstTopicId, 3, "asdf", false); + + Assert.equal(actual, firstTopicId, "Topic Id should be 1"); + } + + function testGetTotalVotes() public { + Voting voting = Voting(DeployedAddresses.Voting()); + + uint actual = voting.getTotalVotes(firstTopicId); + + Assert.equal(actual, 0, "Topic Id should be 0"); + } + + function testGetPollInfo() public { + Voting voting = Voting(DeployedAddresses.Voting()); + + (uint actualNumberOfOptions, string memory actualDataHash, , uint actualNumberOfVotes) = voting.getPollInfo(firstTopicId); + + Assert.equal(actualNumberOfOptions, 3, "Number of votes should be 0"); + Assert.equal(actualDataHash, "asdf", "Number of votes should be 0"); + Assert.equal(actualNumberOfVotes, 0, "Number of votes should be 0"); + } + + function testVote() public { + Voting voting = Voting(DeployedAddresses.Voting()); + + voting.vote(firstTopicId, 1); + uint votesActual = voting.getTotalVotes(firstTopicId); + + Assert.equal(votesActual, 1, "Number of votes should be 1"); + } + + function testGetVoteCount() public { + Voting voting = Voting(DeployedAddresses.Voting()); + + uint actualVotesOption0 = voting.getVoteCount(firstTopicId, 1); + uint actualVotesOption1 = voting.getVoteCount(firstTopicId, 2); + uint actualVotesOption2 = voting.getVoteCount(firstTopicId, 3); + + Assert.equal(actualVotesOption0, 1, "Vote count is not correct"); + Assert.equal(actualVotesOption1, 0, "Vote count is not correct"); + Assert.equal(actualVotesOption2, 0, "Vote count is not correct"); + } + + function testChangeVoteWhenDisabled() public { + Voting voting = Voting(DeployedAddresses.Voting()); + + (uint topicId,) = forum.createTopic(); + voting.createPoll(topicId, 3, "asdf", false); + + voting.vote(topicId, 1); + uint actualVotesOption0 = voting.getVoteCount(topicId, 1); + uint actualVotesOption1 = voting.getVoteCount(topicId, 2); + voting.vote(topicId, 2); + uint actualVotesOption2 = voting.getVoteCount(topicId, 1); + uint actualVotesOption3 = voting.getVoteCount(topicId, 2); + + Assert.equal(actualVotesOption0, 1, "Number of votes should be 1"); + Assert.equal(actualVotesOption1, 0, "Number of votes should be 0"); + Assert.equal(actualVotesOption2, 1, "Number of votes should be 1"); + Assert.equal(actualVotesOption3, 0, "Number of votes should be 0"); + } + + function testChangeVoteWhenEnabled() public { + Voting voting = Voting(DeployedAddresses.Voting()); + + (uint topicId,) = forum.createTopic(); + voting.createPoll(topicId, 3, "asdf", true); + + voting.vote(topicId, 1); + uint actualVotesOption0 = voting.getVoteCount(topicId, 1); + uint actualVotesOption1 = voting.getVoteCount(topicId, 2); + voting.vote(topicId, 2); + uint actualVotesOption2 = voting.getVoteCount(topicId, 1); + uint actualVotesOption3 = voting.getVoteCount(topicId, 2); + + Assert.equal(actualVotesOption0, 1, "Number of votes should be 1"); + Assert.equal(actualVotesOption1, 0, "Number of votes should be 0"); + Assert.equal(actualVotesOption2, 0, "Number of votes should be 0"); + Assert.equal(actualVotesOption3, 1, "Number of votes should be 1"); + } +} diff --git a/packages/concordia-contracts/truffle-config.js b/packages/concordia-contracts/truffle-config.js index f058bf3..6d07a9f 100644 --- a/packages/concordia-contracts/truffle-config.js +++ b/packages/concordia-contracts/truffle-config.js @@ -10,7 +10,7 @@ module.exports = { // to customize your Truffle configuration! compilers: { solc: { - version: '0.7.1', + version: '0.8.0', }, }, contracts_build_directory: path.join(__dirname, 'build/'),