mirror of https://gitlab.com/ecentrics/concordia
Browse Source
# Conflicts: # packages/concordia-contracts/contracts/Forum.sol # packages/concordia-contracts/package.json # yarn.lockdevelop
Apostolos Fanakis
4 years ago
54 changed files with 3450 additions and 2457 deletions
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,35 @@ |
|||||
|
# About Concordia |
||||
|
|
||||
|
## What |
||||
|
|
||||
|
Concordia is a forum platform (remember forums? 🤩) that focuses on user privacy and direct democratic voting. It is a |
||||
|
FOSS distributed via its Gitlab [repository][concordia-repository] and Docker [repository][concordia-docker-hub] under |
||||
|
the [MIT][concordia-license] license. |
||||
|
|
||||
|
## Why |
||||
|
|
||||
|
The value of privacy, freedom of speech and democracy are diminishing in modern software. Even more so in social media |
||||
|
platforms. Users are called to select between being the product of companies that sell their personal information and |
||||
|
being shut out of the modern, digital society. |
||||
|
|
||||
|
Concordia, much like other projects of this kind, provides an alternative to this predicament. |
||||
|
|
||||
|
## How |
||||
|
|
||||
|
Concordia uses decentralized technologies, namely the Ethereum blockchain and its smart contracts, as well as the |
||||
|
distributed database OrbitDB that's based on the decentralized network IPFS. These technologies make Concordia |
||||
|
impervious to censorship and guaranty the immutability of user information and anonymity while enabling user |
||||
|
authentication that makes trusted, direct voting possible. |
||||
|
|
||||
|
You can read more about the technological stack in Concordia's [whitepaper][concordia-whitepaper]. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
Developed by [apostolof][devs-apostolof-profile], [ezerous][devs-ezerous-profile] |
||||
|
|
||||
|
[concordia-repository]: https://gitlab.com/ecentrics/apella |
||||
|
[concordia-docker-hub]: https://hub.docker.com/repository/docker/ecentrics/apella-app |
||||
|
[concordia-license]: https://gitlab.com/ecentrics/apella/-/blob/master/LICENSE.md |
||||
|
[devs-apostolof-profile]: https://gitlab.com/Apostolof |
||||
|
[devs-ezerous-profile]: https://gitlab.com/Ezerous |
||||
|
[concordia-whitepaper]: https://whitepaper.concordia.ecentrics.net |
Before Width: | Height: | Size: 216 KiB |
After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 4.5 KiB |
@ -1 +1,11 @@ |
|||||
export const FORUM_CONTRACT = 'Forum'; |
export const FORUM_CONTRACT = 'Forum'; |
||||
|
export const POST_VOTING_CONTRACT = 'PostVoting'; |
||||
|
export const VOTING_CONTRACT = 'Voting'; |
||||
|
|
||||
|
const CONTRACTS = [ |
||||
|
FORUM_CONTRACT, |
||||
|
POST_VOTING_CONTRACT, |
||||
|
VOTING_CONTRACT, |
||||
|
]; |
||||
|
|
||||
|
export default CONTRACTS; |
||||
|
@ -0,0 +1,9 @@ |
|||||
|
#home-button { |
||||
|
padding: 1.2em; |
||||
|
} |
||||
|
|
||||
|
.ui.inverted.menu { |
||||
|
background: #0B2540 !important; |
||||
|
border-radius: 0; |
||||
|
margin-bottom: 5em; |
||||
|
} |
@ -0,0 +1,67 @@ |
|||||
|
import { |
||||
|
REACT_APP_CONCORDIA_HOST_DEFAULT, |
||||
|
REACT_APP_CONCORDIA_PORT_DEFAULT, |
||||
|
REACT_APP_CONTRACTS_SUPPLIER_HOST_DEFAULT, |
||||
|
REACT_APP_CONTRACTS_SUPPLIER_PORT_DEFAULT, |
||||
|
REACT_APP_CONTRACTS_VERSION_HASH_DEFAULT, |
||||
|
} from '../constants/configuration/defaults'; |
||||
|
import CONTRACTS from '../constants/contracts/ContractNames'; |
||||
|
|
||||
|
function getContractsDownloadRequest() { |
||||
|
const CONTRACTS_SUPPLIER_HOST = process.env.REACT_APP_CONTRACTS_SUPPLIER_HOST |
||||
|
|| REACT_APP_CONTRACTS_SUPPLIER_HOST_DEFAULT; |
||||
|
const CONTRACTS_SUPPLIER_PORT = process.env.REACT_APP_CONTRACTS_SUPPLIER_PORT |
||||
|
|| REACT_APP_CONTRACTS_SUPPLIER_PORT_DEFAULT; |
||||
|
const CONTRACTS_VERSION_HASH = process.env.REACT_APP_CONTRACTS_VERSION_HASH |
||||
|
|| REACT_APP_CONTRACTS_VERSION_HASH_DEFAULT; |
||||
|
const HOST = process.env.REACT_APP_CONCORDIA_HOST || REACT_APP_CONCORDIA_HOST_DEFAULT; |
||||
|
const PORT = process.env.REACT_APP_CONCORDIA_PORT || REACT_APP_CONCORDIA_PORT_DEFAULT; |
||||
|
|
||||
|
const xhrRequest = new XMLHttpRequest(); |
||||
|
|
||||
|
xhrRequest.open('GET', |
||||
|
`http://${CONTRACTS_SUPPLIER_HOST}:${CONTRACTS_SUPPLIER_PORT}/contracts/${CONTRACTS_VERSION_HASH}`, |
||||
|
false); |
||||
|
xhrRequest.setRequestHeader('Access-Control-Allow-Origin', `${HOST}:${PORT}`); |
||||
|
xhrRequest.setRequestHeader('Access-Control-Allow-Credentials', 'true'); |
||||
|
|
||||
|
return xhrRequest; |
||||
|
} |
||||
|
|
||||
|
function validateRemoteContracts(remoteContracts) { |
||||
|
if (remoteContracts.length !== CONTRACTS.length) { |
||||
|
throw new Error(`Version mismatch detected. Artifacts brought ${remoteContracts.length} contracts but app
|
||||
|
expected ${CONTRACTS.length}`);
|
||||
|
} |
||||
|
|
||||
|
const contractsPresentStatus = CONTRACTS.map((contract) => ({ |
||||
|
contract, |
||||
|
present: remoteContracts.includes((remoteContract) => remoteContract.contractName === contract), |
||||
|
})); |
||||
|
|
||||
|
if (contractsPresentStatus.reduce((accumulator, contract) => accumulator && contract.present, true)) { |
||||
|
throw new Error(`Contracts missing from artifacts. Supplier didn't bring ${contractsPresentStatus |
||||
|
.filter((contractPresentStatus) => contractPresentStatus.present === false) |
||||
|
.map((contractPresentStatus) => contractPresentStatus.contract) |
||||
|
.join(', ')}.`);
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const downloadContractArtifactsSync = () => { |
||||
|
const xhrRequest = getContractsDownloadRequest(); |
||||
|
|
||||
|
xhrRequest.send(null); |
||||
|
|
||||
|
if (xhrRequest.status === 200) { |
||||
|
const contractsRawData = xhrRequest.responseText; |
||||
|
const remoteContracts = JSON.parse(contractsRawData); |
||||
|
|
||||
|
validateRemoteContracts(remoteContracts); |
||||
|
|
||||
|
return remoteContracts; |
||||
|
} |
||||
|
|
||||
|
throw new Error(`Remote contract artifacts download request failed!\n${xhrRequest.responseText}`); |
||||
|
}; |
||||
|
|
||||
|
export default downloadContractArtifactsSync; |
@ -0,0 +1,43 @@ |
|||||
|
import React, { |
||||
|
memo, useEffect, useState, |
||||
|
} from 'react'; |
||||
|
import ReactMarkdown from 'react-markdown'; |
||||
|
import { Container, Image } from 'semantic-ui-react'; |
||||
|
import AboutMd from '../../assets/About.md'; |
||||
|
import appLogo from '../../assets/images/app_logo_circle.svg'; |
||||
|
|
||||
|
const targetBlank = () => ({ href, children }) => ( |
||||
|
<a href={href} target="_blank" rel="noopener noreferrer"> |
||||
|
{children} |
||||
|
</a> |
||||
|
); |
||||
|
|
||||
|
const About = () => { |
||||
|
const [aboutMd, setAboutMd] = useState(''); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
fetch(AboutMd) |
||||
|
.then((response) => response.text()) |
||||
|
.then((text) => { |
||||
|
setAboutMd(text); |
||||
|
}); |
||||
|
}, []); |
||||
|
|
||||
|
return ( |
||||
|
<Container id="about-container"> |
||||
|
<div style={{ textAlign: 'center' }}> |
||||
|
<Image src={appLogo} size="small" centered /> |
||||
|
{`v${process.env.REACT_APP_VERSION}`} |
||||
|
</div> |
||||
|
<ReactMarkdown |
||||
|
source={aboutMd} |
||||
|
renderers={{ |
||||
|
link: targetBlank(), |
||||
|
linkReference: targetBlank(), |
||||
|
}} |
||||
|
/> |
||||
|
</Container> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default memo(About); |
@ -0,0 +1,60 @@ |
|||||
|
module.exports = { |
||||
|
env: { |
||||
|
browser: true, |
||||
|
es6: true, |
||||
|
jest: true, |
||||
|
}, |
||||
|
extends: [ |
||||
|
'plugin:react/recommended', |
||||
|
'airbnb', |
||||
|
], |
||||
|
globals: { |
||||
|
Atomics: 'readonly', |
||||
|
SharedArrayBuffer: 'readonly', |
||||
|
}, |
||||
|
parser: 'babel-eslint', |
||||
|
parserOptions: { |
||||
|
ecmaFeatures: { |
||||
|
jsx: true, |
||||
|
}, |
||||
|
ecmaVersion: 2018, |
||||
|
sourceType: 'module', |
||||
|
}, |
||||
|
plugins: [ |
||||
|
'react', |
||||
|
'react-hooks', |
||||
|
], |
||||
|
rules: { |
||||
|
'react/jsx-props-no-spreading': 'off', |
||||
|
'import/extensions': 'off', |
||||
|
'react/jsx-indent': [ |
||||
|
'error', |
||||
|
4, |
||||
|
{ |
||||
|
checkAttributes: true, |
||||
|
indentLogicalExpressions: true, |
||||
|
}, |
||||
|
], |
||||
|
'react/require-default-props': 'off', |
||||
|
'react/prop-types': 'off', |
||||
|
'react-hooks/rules-of-hooks': 'error', |
||||
|
'react-hooks/exhaustive-deps': 'error', |
||||
|
'max-len': ['warn', { code: 120, tabWidth: 4 }], |
||||
|
'no-unused-vars': 'warn', |
||||
|
'no-console': 'warn', |
||||
|
'no-shadow': 'warn', |
||||
|
'no-multi-str': 'warn', |
||||
|
'jsx-a11y/label-has-associated-control': [2, { |
||||
|
labelAttributes: ['label'], |
||||
|
controlComponents: ['Input'], |
||||
|
depth: 3, |
||||
|
}], |
||||
|
}, |
||||
|
settings: { |
||||
|
'import/resolver': { |
||||
|
node: { |
||||
|
extensions: ['.js', '.jsx'], |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}; |
@ -0,0 +1,24 @@ |
|||||
|
# Node |
||||
|
/node_modules |
||||
|
|
||||
|
# IDE |
||||
|
.DS_Store |
||||
|
.idea |
||||
|
|
||||
|
# Build Directories |
||||
|
/build |
||||
|
/src/build |
||||
|
|
||||
|
# Logs |
||||
|
/log |
||||
|
npm-debug.log* |
||||
|
yarn-debug.log* |
||||
|
yarn-error.log* |
||||
|
|
||||
|
# Misc |
||||
|
.env.local |
||||
|
.env.development.local |
||||
|
.env.test.local |
||||
|
.env.production.local |
||||
|
|
||||
|
contracts-uploads |
@ -0,0 +1,26 @@ |
|||||
|
{ |
||||
|
"name": "concordia-contracts-provider", |
||||
|
"description": "A server that provides built contracts for Concordia.", |
||||
|
"version": "0.1.0", |
||||
|
"private": true, |
||||
|
"main": "src/index.js", |
||||
|
"scripts": { |
||||
|
"start": "node -r esm src/index.js" |
||||
|
}, |
||||
|
"license": "MIT", |
||||
|
"dependencies": { |
||||
|
"cors": "^2.8.5", |
||||
|
"esm": "~3.2.25", |
||||
|
"express": "^4.17.1", |
||||
|
"lodash": "^4.17.20", |
||||
|
"multer": "^1.4.2", |
||||
|
"multiparty": "^4.2.2" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"eslint": "^7.19.0", |
||||
|
"eslint-config-airbnb": "^18.2.1", |
||||
|
"eslint-plugin-jsx-a11y": "^6.4.1", |
||||
|
"eslint-plugin-react": "^7.22.0", |
||||
|
"eslint-plugin-react-hooks": "^4.2.0" |
||||
|
} |
||||
|
} |
@ -0,0 +1,11 @@ |
|||||
|
import path from 'path'; |
||||
|
|
||||
|
const PROVIDER_PORT = '8400'; |
||||
|
const UPLOAD_CONTRACTS_DIRECTORY = path.join(__dirname, '..', 'contracts-uploads'); |
||||
|
const CORS_ALLOWED_ORIGINS = ['http://127.0.0.1:7000', 'http://localhost:7000']; |
||||
|
|
||||
|
export default { |
||||
|
port: PROVIDER_PORT, |
||||
|
uploadsDirectory: UPLOAD_CONTRACTS_DIRECTORY, |
||||
|
corsAllowedOrigins: CORS_ALLOWED_ORIGINS, |
||||
|
}; |
@ -0,0 +1,31 @@ |
|||||
|
import * as fs from 'fs'; |
||||
|
import path from 'path'; |
||||
|
import { getStorageLocation, getTagsDirectory } from '../utils/storageUtils'; |
||||
|
|
||||
|
const downloadContracts = async (req, res) => { |
||||
|
const { params: { hash: hashOrTag } } = req; |
||||
|
let directoryPath = getStorageLocation(hashOrTag); |
||||
|
|
||||
|
if (!fs.existsSync(directoryPath)) { |
||||
|
const tagsDirectory = getTagsDirectory(); |
||||
|
|
||||
|
if (fs.existsSync(tagsDirectory)) { |
||||
|
const tagFilePath = path.join(tagsDirectory, hashOrTag); |
||||
|
const tagReference = fs.readFileSync(tagFilePath, 'utf-8'); |
||||
|
|
||||
|
directoryPath = getStorageLocation(tagReference); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const contracts = []; |
||||
|
|
||||
|
fs.readdirSync(directoryPath).forEach((contractFilename) => { |
||||
|
const rawContractData = fs.readFileSync(path.join(`${directoryPath}/${contractFilename}`), 'utf-8'); |
||||
|
const contractJson = JSON.parse(rawContractData); |
||||
|
contracts.push(contractJson); |
||||
|
}); |
||||
|
|
||||
|
res.send(contracts); |
||||
|
}; |
||||
|
|
||||
|
export default downloadContracts; |
@ -0,0 +1,37 @@ |
|||||
|
import path from 'path'; |
||||
|
import fs from 'fs'; |
||||
|
import upload from '../middleware/upload'; |
||||
|
import { getTagsDirectory } from '../utils/storageUtils'; |
||||
|
|
||||
|
const addOrTransferTag = (tag, hash) => { |
||||
|
const tagsDirectory = getTagsDirectory(); |
||||
|
const tagFilePath = path.join(tagsDirectory, tag); |
||||
|
|
||||
|
fs.mkdirSync(tagsDirectory, { recursive: true }); |
||||
|
fs.writeFileSync(tagFilePath, hash); |
||||
|
}; |
||||
|
|
||||
|
const uploadContracts = async (req, res) => { |
||||
|
try { |
||||
|
await upload(req, res); |
||||
|
|
||||
|
const { body: { tag } } = req; |
||||
|
const { params: { hash } } = req; |
||||
|
|
||||
|
if (tag) { |
||||
|
addOrTransferTag(tag, hash); |
||||
|
} |
||||
|
|
||||
|
if (req.files.length <= 0) { |
||||
|
return res.send('You must select at least 1 file.'); |
||||
|
} |
||||
|
|
||||
|
return res.send('Files have been uploaded.'); |
||||
|
} catch (error) { |
||||
|
console.log(error); |
||||
|
|
||||
|
return res.send(`Error when trying upload many files: ${error}`); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default uploadContracts; |
@ -0,0 +1,25 @@ |
|||||
|
import express from 'express'; |
||||
|
import cors from 'cors'; |
||||
|
import initRoutes from './routes/web'; |
||||
|
import constants from './constants'; |
||||
|
|
||||
|
const PROVIDER_PORT = process.env.CONTRACTS_PROVIDER_PORT || constants.port; |
||||
|
const ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS |
||||
|
? process.env.CORS_ALLOWED_ORIGINS.split(';') |
||||
|
: constants.corsAllowedOrigins; |
||||
|
|
||||
|
const app = express(); |
||||
|
|
||||
|
const corsOptions = { |
||||
|
origin: ALLOWED_ORIGINS, |
||||
|
optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204
|
||||
|
}; |
||||
|
|
||||
|
app.use(express.urlencoded({ extended: true })); |
||||
|
app.use(cors(corsOptions)); |
||||
|
|
||||
|
initRoutes(app); |
||||
|
|
||||
|
app.listen(PROVIDER_PORT, () => { |
||||
|
console.log(`Contracts provider listening at http://127.0.0.1:${PROVIDER_PORT}`); |
||||
|
}); |
@ -0,0 +1,30 @@ |
|||||
|
import * as util from 'util'; |
||||
|
import * as fs from 'fs'; |
||||
|
import multer from 'multer'; |
||||
|
import { getStorageLocation } from '../utils/storageUtils'; |
||||
|
|
||||
|
const storage = multer.diskStorage({ |
||||
|
destination: (req, file, callback) => { |
||||
|
const { params: { hash } } = req; |
||||
|
const contractsPath = getStorageLocation(hash); |
||||
|
|
||||
|
fs.mkdirSync(contractsPath, { recursive: true }); |
||||
|
callback(null, contractsPath); |
||||
|
}, |
||||
|
filename: (req, file, callback) => { |
||||
|
const match = ['application/json']; |
||||
|
|
||||
|
if (match.indexOf(file.mimetype) === -1) { |
||||
|
const message = `<strong>${file.originalname}</strong> is invalid. Only JSON files are accepted.`; |
||||
|
return callback(message, null); |
||||
|
} |
||||
|
|
||||
|
const filename = `${file.originalname}`; |
||||
|
callback(null, filename); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const uploadFiles = multer({ storage }).array('contracts'); |
||||
|
const uploadFilesMiddleware = util.promisify(uploadFiles); |
||||
|
|
||||
|
export default uploadFilesMiddleware; |
@ -0,0 +1,14 @@ |
|||||
|
import express from 'express'; |
||||
|
import downloadContracts from '../controllers/download'; |
||||
|
import uploadContracts from '../controllers/upload'; |
||||
|
|
||||
|
const router = express.Router(); |
||||
|
|
||||
|
const routes = (app) => { |
||||
|
router.get('/contracts/:hash', downloadContracts); |
||||
|
router.post('/contracts/:hash', uploadContracts); |
||||
|
|
||||
|
return app.use('/', router); |
||||
|
}; |
||||
|
|
||||
|
export default routes; |
@ -0,0 +1,17 @@ |
|||||
|
import path from 'path'; |
||||
|
import constants from '../constants'; |
||||
|
|
||||
|
export const getStorageLocation = (hash) => { |
||||
|
const UPLOADS_DIRECTORY = process.env.UPLOAD_CONTRACTS_DIRECTORY || constants.uploadsDirectory; |
||||
|
|
||||
|
if (hash) { |
||||
|
return path.join(UPLOADS_DIRECTORY, hash); |
||||
|
} |
||||
|
|
||||
|
return UPLOADS_DIRECTORY; |
||||
|
}; |
||||
|
|
||||
|
export const getTagsDirectory = () => { |
||||
|
const uploadsPath = getStorageLocation(); |
||||
|
return path.join(uploadsPath, '/tags'); |
||||
|
}; |
@ -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" |
||||
|
} |
||||
} |
} |
||||
|
@ -0,0 +1,111 @@ |
|||||
|
//SPDX-License-Identifier: MIT |
||||
|
pragma solidity 0.8.0; |
||||
|
|
||||
|
import "./Forum.sol"; |
||||
|
|
||||
|
contract PostVoting { |
||||
|
Forum public forum; |
||||
|
|
||||
|
constructor(Forum addr) { |
||||
|
forum = Forum(addr); |
||||
|
} |
||||
|
|
||||
|
enum Option { DEFAULT, UP, DOWN } // DEFAULT -> 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 { |
||||
|
address voter = msg.sender; |
||||
|
require(forum.hasUserSignedUp(voter), forum.USER_HAS_NOT_SIGNED_UP()); |
||||
|
require(forum.postExists(postID), forum.POST_DOES_NOT_EXIST()); |
||||
|
address postAuthor = forum.getPostAuthor(postID); |
||||
|
require(voter != postAuthor, "Post's author cannot vote for it."); |
||||
|
|
||||
|
PostBallot storage postBallot = postBallots[postID]; |
||||
|
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); |
||||
|
} |
||||
|
} |
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,14 +1,15 @@ |
|||||
let Forum; |
let Forum, Voting, PostVoting; |
||||
|
|
||||
|
/* eslint-disable global-require */ |
||||
try { |
try { |
||||
// eslint-disable-next-line global-require
|
|
||||
Forum = require('./build/Forum.json'); |
Forum = require('./build/Forum.json'); |
||||
|
Voting = require('./build/Voting.json'); |
||||
|
PostVoting = require('./build/PostVoting.json'); |
||||
} catch (e) { |
} catch (e) { |
||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||
console.error("Could not require contract artifacts. Haven't you run compile yet?"); |
console.error("Could not require contract artifacts. Haven't you run compile yet?"); |
||||
} |
} |
||||
|
|
||||
module.exports = { |
module.exports = { |
||||
contracts: [Forum], |
contracts: [Forum, Voting, PostVoting], |
||||
forumContract: Forum, |
|
||||
}; |
}; |
||||
|
@ -1,6 +1,12 @@ |
|||||
const Forum = artifacts.require('Forum'); |
const Forum = artifacts.require('Forum'); |
||||
|
const Voting = artifacts.require('Voting'); |
||||
|
const PostVoting = artifacts.require('PostVoting'); |
||||
|
|
||||
// eslint-disable-next-line func-names
|
// eslint-disable-next-line func-names
|
||||
module.exports = function (deployer) { |
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), |
||||
|
])); |
||||
}; |
}; |
||||
|
@ -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"); |
||||
|
} |
||||
|
} |
@ -0,0 +1,32 @@ |
|||||
|
const path = require('path'); |
||||
|
const unirest = require('unirest'); |
||||
|
const { contracts } = require('../index'); |
||||
|
const defaults = require('../constants/config/defaults'); |
||||
|
|
||||
|
const uploadContractsToProviderUnirest = (versionHash, tag) => { |
||||
|
const CONTRACTS_PROVIDER_HOST = process.env.CONTRACTS_PROVIDER_HOST || defaults.contractsProviderHost; |
||||
|
const CONTRACTS_PROVIDER_PORT = process.env.CONTRACTS_PROVIDER_PORT || defaults.contractsProviderPort; |
||||
|
|
||||
|
const uploadPath = `http://${CONTRACTS_PROVIDER_HOST}:${CONTRACTS_PROVIDER_PORT}/contracts/${versionHash}`; |
||||
|
const request = unirest('POST', uploadPath) |
||||
|
.field('tag', tag); |
||||
|
|
||||
|
contracts |
||||
|
.forEach((contract) => request |
||||
|
.attach('contracts', path.join(__dirname, '../', 'build/', `${contract.contractName}.json`))); |
||||
|
|
||||
|
console.log(`Uploading to ${uploadPath}`); |
||||
|
request.end((res) => { |
||||
|
if (res.error) { |
||||
|
throw new Error(`Failed to upload contracts to provider: ${res.error}`); |
||||
|
} |
||||
|
|
||||
|
console.log('Contracts uploaded to provider.'); |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const main = () => { |
||||
|
uploadContractsToProviderUnirest(process.argv[2], process.argv[3]); |
||||
|
}; |
||||
|
|
||||
|
main(); |
File diff suppressed because it is too large
Loading…
Reference in new issue