Proof-of-concept application created for the ETHOnline hackathon. The app is simple anonymous voting application that uses the Semaphore construct via libsemaphore. The circuits implementation can be found here. The app uses an improved version, called Fast Semaphore (or just Semaphore, as the old version will get deprecated), which provides better perforamnce. The main goal is to show how to integrate Semaphore easily, and how to enable anonymity on app level. We do not care about sybil-attacks in this PoC, we only disallow for double registrations and double voting.
The app features client-server architecture, and is completely offchain (of course this is for demo purposes only and smart-contract architecture can be used instead of a server).
The app is a monorepo which contains 2 packages: server
and client
.
The server is implemented as a simple express.js server and exposes the following endpoints:
GET /witness/:index
POST /register
POST /vote
GET /campaigns
GET /campaign/:name
The server stores the tree for the registered users and verifies the votes. The server also takes care of preventing double reigstartions and double voting. Helper endpoints are also exposed, for example an endpoint for obtaining member witness by leaf index (this allows only the server to store the membership tree, and reduces storage cost on the client side).
In the client there are two scripts: vote
and simulate
. The vote performs a single user registration and voting, while the simulate
simulates voting of 2 users and displays more interactions.
The users can send votes to the application, but only after they register to it. The Semaphore construct allows for anonymous signaling, in our case anonymity is achieved by knowing that the user is part of the group but not being able to determine his identity. The signal is the vote.
- First clone this repository
- Install the dependencies by running:
yarn
- Build the packages:
yarn build
- Start the server (terminal 1):
yarn server
- Simulate voting (terminal 2):
yarn simulateVoting
The following is a high level tutorial on how the app works, please follow the code provided in the client
and server
packages for more clarity. Please note that this is a PoC and a work-in-progress, the code might get updated in the future.
On the server we store everything that is needed for voting, that is the Membership tree for user registrations, user internal nullifiers used for double vote prevention, as well as the voting stats.
The users need to register first. For that we've provided the register
function. Users register by providing their identityCommitment
which they generate from the client
. We insert the user's identityCommitment
in the membership tree (tree
), and that is how we perform the user registration. We return the leafIndex
to the user (his index in the membership tree).
const register = (identityCommitment: BigInt): number => {
if(tree.leaves.includes(identityCommitment)) throw new Error("User already registered");
tree.insert(identityCommitment);
return tree.nextIndex - 1;
}
After successful registration, the users can vote. For this we need to provide a voting endpoint (POST /vote
).
The voting endpoint accepts a proof generated by the client
, the voting campaign, the user's internal nullifier and the user's vote for the campaign.
We first validate the inputs by checking the validity of the campaign and the vote, and after that we check if this is a double vote or not. If everything is valid, we finally validate the proof. If the proof is valid too, then we register the user's vote in the app.
// campaign verification
const votingInputs: VotingInputs = req.body;
const voteCampaign = votingCampaigns.find(campaign => campaign.name === votingInputs.campaignName);
if (!voteCampaign) throw new Error("Invalid voting campaign");
if (!voteCampaign.options.includes(votingInputs.vote)) throw new Error("Invalid vote");
if (typeof votingInputs.nullifier === 'string') {
votingInputs.nullifier = BigInt(votingInputs.nullifier);
}
await verifyVote(votingInputs);
...
// double voting prevention
if(votedUsers.includes(votingInputs.nullifier)) throw new Error("Double vote");
// proof verification
const proof: FullProof = {
proof: votingInputs.proof,
publicSignals: [tree.root, votingInputs.nullifier, genSignalHash(votingInputs.vote), genExternalNullifier(votingInputs.campaignName)]
};
const status = await Semaphore.verifyProof(verifierKey, proof);
In order for the users to generate valid proofs, they need to have a proof of their membership in the membership tree (witness). The server exposes an endpoint that returns the witness for the registered users, which is obtained by providing the leaf index for the user:
const getWitness = (leafIndex: number) => {
return tree.genMerklePath(leafIndex);
}
We also provide helper endpoints for obtaining all voting campaigns (GET /campaigns
) as well as single campaign (GET /campaign/:name
). This would be useful for the end users, so they can check the voting stats.
In order to be able to participate in the voting activities, the clients need to create an identity and register to the application with the identity. User identity consists of public identity hash generated from random numbers, called identityCommitment
. The users perform the registration with the identityCommitment
.
const identity: ZkIdentity = new ZkIdentity();
const identityCommitment: BigInt = identity.genIdentityCommitment();
// Register to the voting app
const leafIndex = await register(identityCommitment);
After the users have registered, they can now vote. To be able to vote, they first need to generate a zero-knowledge proof, to be able to prove that they can vote and that their vote is valid without revealing their identity. The inputs for proof generation are:
- the proof of their membership in the member tree (which is stored on the server)
- the voting campaign (external nullifier)
- their vote (the signal)
- the random numbers used to genarate the
identityCommitment
hash.
The users have everything locally, they just need to obtain the witness from the server, which they can easily do so by only having to provide their index in the tree:
const merkleProof = await getWitness(leafIndex);
After that, the users can generate a proof and vote:
const externalNullifier = genExternalNullifier(campaignName);
const witness: FullProof = await Semaphore.genWitness(identity.getIdentity(), merkleProof, externalNullifier , voteOption);
const nullifierHash: BigInt = Semaphore.genNullifierHash(externalNullifier, identity.getNullifier(), 20);
const fullProof: FullProof = await Semaphore.genProof(witness, CIRCUIT_PATH, PROVER_KEY_PATH)
const voteParameters = {
proof: fullProof.proof,
nullifier: nullifierHash.toString(),
vote: voteOption,
campaignName
}
await axios.post(`${API_BASE_URL}/vote`, voteParameters)