Skip to main content

Building a Private Voting dApp

Private voting is another headline application for confidential smart contracts. Every ballot is encrypted, so no observer can trace who voted for what — but the final tally is a public, auditable record.

The design

  • Voter registration — an authorized list of eligible voters, or a token-gated model.
  • Voting phase — each voter submits an encrypted vote. Nobody sees individual ballots.
  • Tally phase — after the deadline, the contract computes the vote counts from its encrypted state and publishes the totals.

Contract

contracts/PrivateVoting.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract PrivateVoting {
enum Choice { None, Yes, No, Abstain }

address public immutable creator;
uint256 public immutable votingDeadline;
string public proposal;

mapping(address => bool) public isEligible;
mapping(address => Choice) private ballots; // encrypted per-voter
mapping(address => bool) public hasVoted;

// Public after tally
uint256 public yesCount;
uint256 public noCount;
uint256 public abstainCount;
bool public tallied;

event VoterRegistered(address voter);
event VoteCast(address indexed voter); // no choice emitted
event ResultsPublished(uint256 yes, uint256 no, uint256 abstain);

constructor(string memory _proposal, uint256 _votingDuration) {
creator = msg.sender;
proposal = _proposal;
votingDeadline = block.timestamp + _votingDuration;
}

function registerVoter(address voter) external {
require(msg.sender == creator, "not creator");
require(block.timestamp < votingDeadline, "voting closed");
isEligible[voter] = true;
emit VoterRegistered(voter);
}

function vote(Choice choice) external {
require(block.timestamp < votingDeadline, "voting closed");
require(isEligible[msg.sender], "not eligible");
require(!hasVoted[msg.sender], "already voted");
require(choice != Choice.None, "invalid choice");

ballots[msg.sender] = choice;
hasVoted[msg.sender] = true;
emit VoteCast(msg.sender);
}

// Anyone can trigger the tally after the deadline
function tally(address[] calldata voters) external {
require(block.timestamp >= votingDeadline, "voting still open");
require(!tallied, "already tallied");

for (uint256 i = 0; i < voters.length; i++) {
Choice c = ballots[voters[i]];
if (c == Choice.Yes) yesCount++;
else if (c == Choice.No) noCount++;
else if (c == Choice.Abstain) abstainCount++;
}
tallied = true;
emit ResultsPublished(yesCount, noCount, abstainCount);
}

// Voter can verify their own vote after the fact (signed query)
function myVote() external view returns (Choice) {
return ballots[msg.sender];
}
}

Notes on this design

The tally function iterates over a voter list passed by the caller. This is necessary because the contract itself can't cheaply iterate over the mapping. In practice, the creator or a trusted party submits the list.

Vote choices are stored encrypted. Only the voter can read their own ballot (via signed query). Nobody else, including the tallier, sees individual choices — the tallier only sees aggregate counts.

Public participation, private choice. hasVoted is a public bool, so anyone can see who participated but not how they voted.

Preventing coercion

The confidentiality prevents an outside observer from knowing what someone voted. But a coercer could still demand the voter reveal their ballot via signed query, and the voter might comply.

For maximum coercion resistance, consider a scheme where votes can be re-cast up until the deadline (only the latest counts) — a coerced voter can pretend to vote one way in front of the coercer and re-vote later. Or use MACI-style approaches, which OXN can implement more efficiently than public EVMs thanks to the encryption.

Testing

it("keeps individual ballots private", async function () {
await voting.connect(creator).registerVoter(alice.address);
await voting.connect(creator).registerVoter(bob.address);

await voting.connect(alice).vote(1); // Yes
await voting.connect(bob).vote(2); // No

// Neither can see the other's vote
expect(await voting.connect(alice).myVote()).to.equal(1);
expect(await voting.connect(bob).myVote()).to.equal(2);

// A third party sees only participation, not choice
expect(await voting.hasVoted(alice.address)).to.equal(true);
expect(await voting.hasVoted(bob.address)).to.equal(true);
});

Next steps