Aztec QuickstartSDK
← Quest map

Understand the app

The contract#

PrivateVoting is built around the one idea Aztec exists for: a privacy preserving smart contract capable of integrating private and public state, where the user remains in control of their private data and only the minimum necessary information is publically shared.

Storage#

Most contracts include a Storage struct, where its state is preserved on chain. On Aztec, we have private and public state.

noir
#[storage]
struct Storage<Context> {
    // Address of the admin can start and end elections
    admin: PublicMutable<AztecAddress, Context>,
    // Public mapping of election => candidate => number of votes
    tally: Map<ElectionId, Map<Field, PublicMutable<Field, Context>, Context>, Context>,
    // Public mapping of election => whether voting has ended
    vote_ended: Map<ElectionId, PublicMutable<bool, Context>, Context>,
    // Public mapping of election => block it became active at
    active_at_block: Map<ElectionId, PublicImmutable<u32, Context>, Context>,
    // Mapping of election => voter => single-use claim that lets a voter vote at most once per election
    // SingleUseClaim is **private**, as it does not reveal which user generated the claim
    vote_claims: Map<ElectionId, Owned<SingleUseClaim<Context>, Context>, Context>,
}

Private functions#

Casting a vote runs as a private function. By using a SingleUseClaim per (election, voter) we ensure an account can only vote once. This variable internally uses a nullifier, generated from secret data that only the owner of the account knows. A duplicate nullifier emission is rejected by the protocol.

noir
#[external("private")]
fn cast_vote(election_id: ElectionId, candidate: Field) {
    // `.claim()` emits a nullifier derived from (election, voter). Voting again with the
    // same account produces the same nullifier, which the protocol rejects, so each
    // account can vote at most once per election. The nullifier derivation ensures it's not
    // possible to link it back to the account that generated it.
    self.storage.vote_claims.at(election_id).at(self.msg_sender()).claim();
    // Emit a private event so we remember who we voted for
    self
        .emit(Vote { election_id: election_id.to_field(), candidate, voter: self.msg_sender() })
        .deliver_to(self.msg_sender(), MessageDelivery::onchain_unconstrained());
    // The vote itself stays private; we only enqueue a public bump of the aggregate tally.
    self.enqueue_self._add_to_tally_public(election_id, candidate);
}

A private Vote event is delivered back to the voter (deliver_to) so they can later check how they voted. Since it's private, only they can read it. Then the vote enqueues a public bump of the tally.

Public functions#

The public side updates the tally for that particular candidate and emits a TallyUpdated event for the live feed, revealing the candidate and running count but never the voter.

noir
#[external("public")]
#[only_self]
fn _add_to_tally_public(election_id: ElectionId, candidate: Field) {
    assert(self.storage.active_at_block.at(election_id).read() <= self.context.block_number());
    assert(self.storage.vote_ended.at(election_id).read() == false, "Vote has ended");
    let new_tally = self.storage.tally.at(election_id).at(candidate).read() + 1;
    self.storage.tally.at(election_id).at(candidate).write(new_tally);
    // Emit a public event so a UI can show a live feed of votes as they land.
    self.emit(TallyUpdated { election_id: election_id.to_field(), candidate, tally: new_tally });
}