Building Ethereum DApps: Voting with Custom Tokens
In part 5 of this tutorial series on building DApps with Ethereum, we dealt with adding content to the story, looking at how to add the ability for participants to buy tokens from the DAO and to add submissions into the story. It’s now time for the DAO’s final form: voting, blacklisting/unblacklisting, and dividend distribution and withdrawal. We’ll throw in some additional helper functions for good measure.
If you get lost in all this, the full source code is available in the the repo.
Votes and Proposals
We’ll be issuing Proposals and voting with Votes. We need two new structs:
struct Proposal {
string description;
bool executed;
int256 currentResult;
uint8 typeFlag; // 1 = delete
bytes32 target; // ID of the proposal target. I.e. flag 1, target XXXXXX (hash) means proposal to delete submissions[hash]
uint256 creationDate;
uint256 deadline;
mapping (address => bool) voters;
Vote[] votes;
address submitter;
}
Proposal[] public proposals;
uint256 proposalCount = 0;
event ProposalAdded(uint256 id, uint8 typeFlag, bytes32 hash, string description, address submitter);
event ProposalExecuted(uint256 id);
event Voted(address voter, bool vote, uint256 power, string justification);
struct Vote {
bool inSupport;
address voter;
string justification;
uint256 power;
}
A Proposal will have a mapping of voters to prevent people from voting on a proposal twice, and some other metadata which should be self-explanatory. The Vote will either be a yes or no vote, and will remember the voter along with their justification for voting a certain way, and the voting power — the number of tokens they want to devote to voting for this proposal. We also added an array of Proposals so we can store them somewhere, and a counter for counting how many proposals there are.
Let’s build their accompanying functions now, starting with the voting function:
modifier tokenHoldersOnly() {
require(token.balanceOf(msg.sender) >= 10**token.decimals());
_;
}
function vote(uint256 _proposalId, bool _vote, string _description, uint256 _votePower) tokenHoldersOnly public returns (int256) {
require(_votePower > 0, "At least some power must be given to the vote.");
require(uint256(_votePower) <= token.balanceOf(msg.sender), "Voter must have enough tokens to cover the power cost.");
Proposal storage p = proposals[_proposalId];
require(p.executed == false, "Proposal must not have been executed already.");
require(p.deadline > now, "Proposal must not have expired.");
require(p.voters[msg.sender] == false, "User must not have already voted.");
uint256 voteid = p.votes.length++;
Vote storage pvote = p.votes[voteid];
pvote.inSupport = _vote;
pvote.justification = _description;
pvote.voter = msg.sender;
pvote.power = _votePower;
p.voters[msg.sender] = true;
p.currentResult = (_vote) ? p.currentResult + int256(_votePower) : p.currentResult - int256(_votePower);
token.increaseLockedAmount(msg.sender, _votePower);
emit Voted(msg.sender, _vote, _votePower, _description);
return p.currentResult;
}
Notice the function modifier: by adding that modifier into our contract, we can attach it to any future function and make sure only token holders can execute that function. It’s a reusable security check!
The vote function does some sanity checks like the voting power being positive, the voter having enough tokens to actually vote etc. Then we fetch the proposal from storage and make sure it’s neither expired nor already executed. It wouldn’t make sense to vote on a proposal that’s already done. We also need to make sure this person hasn’t yet voted. We could allow changing the vote power, but this opens the DAO to some vulnerabilities like people withdrawing their votes at the last minute etc. Perhaps a candidate for a future version?
Then we register a new Vote into the proposal, change the current result for easy lookup of scores, and finally emit the Voted event. But what’s token.increaseLockedAmount
?
This bit of logic increases the amount of locked tokens for a user. The function is only executable by the owner of the token contract (by this point that’s hopefully the DAO) and will prevent the user from sending an amount of tokens that exceeds the locked amount registered to their account. This lock is lifted after the proposal falls through or executes.
Let’s write the functions for proposing the deletion of an entry now.
Voting to Delete and Blacklist
As established in part 1 in this series, we have three entry deletion functions planned:
- Remove entry: when confirmed by vote, the target entry is removed. Voting time: 48 hours.
- Emergency remove entry [Only Owner]: can only be triggered by Owner. When confirmed by vote, the target entry is removed. Voting time: 24 hours.
- Emergency remove image [Only Owner]: only applies to image entries. Can only be triggered by Owner. When confirmed by vote, the target entry is removed. Voting time: 4 hours.
Five deletions of a single address’ entries lead to a blacklisting.
Let’s see how we can do that now. First up, the deletion functions:
modifier memberOnly() {
require(whitelist[msg.sender]);
require(!blacklist[msg.sender]);
_;
}
function proposeDeletion(bytes32 _hash, string _description) memberOnly public {
require(submissionExists(_hash), "Submission must exist to be deletable");
uint256 proposalId = proposals.length++;
Proposal storage p = proposals[proposalId];
p.description = _description;
p.executed = false;
p.creationDate = now;
p.submitter = msg.sender;
p.typeFlag = 1;
p.target = _hash;
p.deadline = now + 2 days;
emit ProposalAdded(proposalId, 1, _hash, _description, msg.sender);
proposalCount = proposalId + 1;
}
function proposeDeletionUrgent(bytes32 _hash, string _description) onlyOwner public {
require(submissionExists(_hash), "Submission must exist to be deletable");
uint256 proposalId = proposals.length++;
Proposal storage p = proposals[proposalId];
p.description = _description;
p.executed = false;
p.creationDate = now;
p.submitter = msg.sender;
p.typeFlag = 1;
p.target = _hash;
p.deadline = now + 12 hours;
emit ProposalAdded(proposalId, 1, _hash, _description, msg.sender);
proposalCount = proposalId + 1;
}
function proposeDeletionUrgentImage(bytes32 _hash, string _description) onlyOwner public {
require(submissions[_hash].image == true, "Submission must be existing image");
uint256 proposalId = proposals.length++;
Proposal storage p = proposals[proposalId];
p.description = _description;
p.executed = false;
p.creationDate = now;
p.submitter = msg.sender;
p.typeFlag = 1;
p.target = _hash;
p.deadline = now + 4 hours;
emit ProposalAdded(proposalId, 1, _hash, _description, msg.sender);
proposalCount = proposalId + 1;
}
Once proposed, a Proposal is added to the list of proposals and notes which entry is being targeted by the entry hash. The description is saved and some defaults added, and a deadline is calculated depending on proposal type. The proposal added event gets emitted and the total number of proposals is increased.
Next let’s see how to execute a proposal. To be executable, a proposal must have enough votes, and must be past its deadline. The execution function will accept the ID of the proposal to execute. There’s no easy way to make the EVM execute all pending proposals at once. It’s possible that too many would be pending to execute and that they would make big changes to the data in the DAO, which might exceed the gas limit of Ethereum blocks, thereby failing the transaction. It’s much easier to build a manual execution function callable by anyone with well defined rules, so the community can keep an eye on the proposals that need executing.
function executeProposal(uint256 _id) public {
Proposal storage p = proposals[_id];
require(now >= p.deadline && !p.executed);
if (p.typeFlag == 1 && p.currentResult > 0) {
assert(deleteSubmission(p.target));
}
uint256 len = p.votes.length;
for (uint i = 0; i < len; i++) {
token.decreaseLockedAmount(p.votes[i].voter, p.votes[i].power);
}
p.executed = true;
emit ProposalExecuted(_id);
}
We grab the proposal by its ID, check that it meets requirements of not having been executed and deadline being expired, and then if the type of the proposal is a deletion proposal and the voting result is positive, we use the already written deletion function, finally emitting a new event we added (add it to the top of the contract). The assert
call is there serving the same purpose as the require
statement: assert is generally used when you “assert” that a result is true. Require is used for prerequisites. Functionally they’re identical, with the difference of assert
statements not being able to accept message parameters for cases when they fail. The function ends by unlocking the tokens for all the votes in that one proposal.
We can use this same approach to add other types of proposals, but first, let’s update the deleteSubmission
function to ban the users that have five or more deletions on their account: it means they’ve been consistently submitting content the community voted against. Let’s update the deleteSubmission
function:
function deleteSubmission(bytes32 hash) internal returns (bool) {
require(submissionExists(hash), "Submission must exist to be deletable.");
Submission storage sub = submissions[hash];
sub.exists = false;
deletions[submissions[hash].submitter] += 1;
if (deletions[submissions[hash].submitter] >= 5) {
blacklistAddress(submissions[hash].submitter);
}
emit SubmissionDeleted(
sub.index,
sub.content,
sub.image,
sub.submitter
);
nonDeletedSubmissions -= 1;
return true;
}
That’s better. Auto-blacklisting on five deletes. It wouldn’t be fair not to give the blacklisted addresses a chance to redeem themselves, though. We also need to define the blacklisting function itself. Let’s do both of those things and set the unblacklisting fee to, for example, 0.05 ether.
function blacklistAddress(address _offender) internal {
require(blacklist[_offender] == false, "Can't blacklist a blacklisted user :/");
blacklist[_offender] == true;
token.increaseLockedAmount(_offender, token.getUnlockedAmount(_offender));
emit Blacklisted(_offender, true);
}
function unblacklistMe() payable public {
unblacklistAddress(msg.sender);
}
function unblacklistAddress(address _offender) payable public {
require(msg.value >= 0.05 ether, "Unblacklisting fee");
require(blacklist[_offender] == true, "Can't unblacklist a non-blacklisted user :/");
require(notVoting(_offender), "Offender must not be involved in a vote.");
withdrawableByOwner = withdrawableByOwner.add(msg.value);
blacklist[_offender] = false;
token.decreaseLockedAmount(_offender, token.balanceOf(_offender));
emit Blacklisted(_offender, false);
}
function notVoting(address _voter) internal view returns (bool) {
for (uint256 i = 0; i < proposalCount; i++) {
if (proposals[i].executed == false && proposals[i].voters[_voter] == true) {
return false;
}
}
return true;
}
Notice that a blacklisted account’s tokens get locked up until they send in the unblacklisting fee.
Other Types of Votes
Using the inspiration from the functions we wrote above, try writing the other proposals. For spoilers, check out the GitHub repo of the project and copy the final code from there. For brevity, let’s move on to the other functions we still have left in the DAO.
Chapter End
Once the time or chapter limit of the story is reached, it’s time to bring the story to an end. Anyone can call the ending function after the date which will allow withdrawals of dividends. First, we need a new StoryDAO attribute and an event:
bool public active = true;
event StoryEnded();
Then, let’s build the function:
function endStory() storyActive external {
withdrawToOwner();
active = false;
emit StoryEnded();
}
Simple: it deactivates the story after sending the collected fees to the owner and emits the event. But in actuality, this doesn’t really change anything in the DAO as a whole: the other functions don’t react to it being over. So let’s build another modifier:
modifier storyActive() {
require(active == true);
_;
}
Then, we add this modifier to all the functions except withdrawToOwner
, like so:
function whitelistAddress(address _add) storyActive public payable {
In case any tokens are left over in the DAO, let’s take them back and take over ownership of these tokens in order to be able to use them on another story later on:
function withdrawLeftoverTokens() external onlyOwner {
require(active == false);
token.transfer(msg.sender, token.balanceOf(address(this)));
token.transferOwnership(msg.sender);
}
function unlockMyTokens() external {
require(active == false);
require(token.getLockedAmount(msg.sender) > 0);
token.decreaseLockedAmount(msg.sender, token.getLockedAmount(msg.sender));
}
The unlockMyTokens
function is there to unlock all locked tokens in case some stayed locked for a specific user. It shouldn’t happen, and this function should be removed by a good amount of tests.
Dividend Distribution and Withdrawals
Now that the story has ended, the fees collected for submissions need to be distributed to all token holders. We can re-use our whitelist to mark everyone who’s made the withdrawal of the fees:
function withdrawDividend() memberOnly external {
require(active == false);
uint256 owed = address(this).balance.div(whitelistedNumber);
msg.sender.transfer(owed);
whitelist[msg.sender] = false;
whitelistedNumber--;
}
If these dividends aren’t withdrawn within a certain time limit, the owner can grab the rest:
function withdrawEverythingPostDeadline() external onlyOwner {
require(active == false);
require(now > deadline + 14 days);
owner.transfer(address(this).balance);
}
For homework, consider how easy or hard it would be to re-use this same deployed smart contract, clear its data, and keep the tokens in the pot and restart another chapter from this without re-deploying. Try doing this yourself and keep an eye on the repo for future updates to the tutorial series covering this! Also think about additional incentive mechanics: maybe the amount of tokens in an account influences the dividend they get from the story’s end? Your imagination is the limit!
Deployment Issues
Given that our contract is quite large now, deploying and/or testing it might exceed the gas limit of Ethereum blocks. This is what limits large applications from being deployed on the Ethereum network. To get it deployed anyway, try using the code optimizer during compilation by changing the truffle.js
file to include solc
settings for optimization, like so:
// ...
module.exports = {
solc: {
optimizer: {
enabled: true,
runs: 200
}
},
networks: {
development: {
// ...
This will run the optimizer across the code 200 times to find areas that can be minified, removed or abstracted before deployment, which should reduce the deployment cost significantly.
Conclusion
This concludes our exhaustive DAO development — but the course isn’t over yet! We still have to build and deploy the UI for this story. Luckily, with the back end completely hosted on the blockchain, building the front end is much less complicated. Let’s look at that in our penultimate part of this series.