Run the app
With the contract deployed, start the frontend and watch the whole thing work.
npm run dev # http://localhost:5173Open it, cast a vote, and watch the public tally tick up while your vote stays private. Vote again and the network rejects it — that's the nullifier.
Connecting#
Before interacting with the app, you'll need a Wallet. This is handled by VotingClient.connect. It spins up an in-browser EmbeddedWallet pointed at the network's node, wires up fee payment for that network (the canonical SponsoredFPC locally, the private PrivateFeeJuice FPC on testnet), then creates a Schnorr initializerless account. This account contract is immutable and requires no deploy transaction which means it can be created completely off-chain. The account's secret is persisted in localStorage, so a visitor keeps one identity across reloads.
// 1. Connect to the node and spin up the wallet. Whether the wallet generates
// real proofs follows the network itself: the node advertises `realProofs`
// (true on testnet, false on a local dev node).
onPhase?.("connect");
const node = createAztecNodeClient(deployment.nodeUrl);
const { realProofs } = await node.getNodeInfo();
const wallet = await EmbeddedWallet.create(node, {
pxe: { proverEnabled: realProofs },
});
// How this network pays for the user's txs: SponsoredFPC on local, the
// PrivateFeeJuice FPC on testnet.
const { sponsored, fpc } = await VotingClient.setupFeePayment(
wallet,
deployment,
);
// 2. Reconstruct or create the saved account. Initializerless = no deploy tx:
// creating it registers it in our wallet and it's immediately usable.
onPhase?.("account");
const saved = loadStoredAccount();
const secret = saved?.secret ?? Fr.random();
const salt = saved?.salt ?? Fr.random();
const account = await wallet.createSchnorrInitializerlessAccount(
secret,
salt,
deriveSigningKey(secret),
);
storeAccount(secret, salt);The app flow#
With the client connected, every interaction with the contract follows the same beats — burn this into memory, it's identical for your own apps:
Teach your PXE about the deployed contract. The contract is published on-chain (its constructor is a public initializer), so the app just asks the node for the instance with getContract(address) and hands it to the wallet — no rebuilding from deploy params, and the deployment file only needs the address:
/**
* REGISTER: teach our wallet about the deployed contract. The voting contract is
* published on chain (its constructor is a public `#[initializer]`), so instead of
* rebuilding the instance from deploy params we just ask the node for it with
* `getContract(address)` and hand that to the wallet
*
*/
private static async register(
wallet: EmbeddedWallet,
node: AztecNode,
deployment: Deployment,
): Promise<PrivateVotingContract> {
const address = AztecAddress.fromString(deployment.contractAddress);
const instance = await node.getContract(address);
if (!instance) {
throw new Error(
`The voting contract at ${deployment.contractAddress} is not published on "${deployment.network}". ` +
`Deploy it first with \`npm run deploy\`${
deployment.network === "testnet"
? " (or `npm run deploy:testnet`)"
: ""
}, then reload.`,
);
}
const { PrivateVotingContract, PrivateVotingContractArtifact } =
await loadVotingContract();
await wallet.registerContract(instance, PrivateVotingContractArtifact);
return PrivateVotingContract.at(instance.address, wallet);
}Run a function locally current state and read the result without sending a transaction or paying a fee. The chart needs every candidate's tally, so the app batches the reads into a single simulation:
/**
* SIMULATE: most of what an app does is *read* state to populate the UI. A
* simulate runs the function locally against the latest state and returns the
* value without sending a transaction or paying a fee. The chart needs every
* candidate's tally, so we batch all the `get_tally` reads into a single
* simulation with `BatchCall` instead of one round-trip per candidate.
*/
async readTallies(): Promise<Record<string, number>> {
const { candidates } = this.deployment;
const getTallyInteractions = candidates.map((c) =>
this.voting.methods.get_tally(
election(this.deployment),
new Fr(BigInt(c.id)),
),
);
const batch = new BatchCall(this.wallet, getTallyInteractions);
const { result: batchResult } = await batch.simulate({
from: this.account,
});
return Object.fromEntries(
candidates.map((c, i) => [c.id, Number(batchResult[i].result)]),
);
}Submit the real transaction. The network only ever sees the nullifier and the public tally going up by one:
/**
* SEND: submit the real transaction. The vote stays private; the network only sees a
* nullifier (so you can't vote twice) and the public tally going up by 1. Fees are paid
* by the SponsoredFPC (local) or the PrivateFeeJuice FPC (testnet) — spending a freshly
* bridged top-up on the first vote (see `fund`), or the existing balance afterwards.
*/
async vote(candidate: bigint): Promise<void> {
await this.voting.methods
.cast_vote(election(this.deployment), new Fr(candidate))
.send({ from: this.account, fee: { paymentMethod: this.votePaymentMethod() } });
}The live feed#
The running tally is public, so the app can read the TallyUpdated events the contract emits and render a live feed — public logs that reveal the candidate and count, never the voter:
/**
* QUERY PUBLIC EVENTS: read the public `TallyUpdated` events the contract emits.
* These are public logs anyone can fetch from the node and decode with the
* event's ABI; they reveal the candidate and running tally, never the voter. We
* use them to build a live feed of votes as they land.
*/
async getFeed(): Promise<VoteEvent[]> {
const { PrivateVotingContract } = await loadVotingContract();
const { events } = await getPublicEvents<{
candidate: bigint;
tally: bigint;
}>(this.node, PrivateVotingContract.events.TallyUpdated, {
contractAddress: AztecAddress.fromString(this.deployment.contractAddress),
});
return events
.map((e) => ({
candidate: BigInt(e.event.candidate),
tally: BigInt(e.event.tally),
blockNumber: e.metadata.l2BlockNumber,
txHash: e.metadata.txHash.toString(),
}))
.sort((a, b) => b.blockNumber - a.blockNumber); // newest first
}Your private vote#
Remember the private Vote event from Understand the app? The app reads it back to remind you which candidate you picked — and only you can. It's delivered to your account, so getPrivateEvents returns just yours:
/**
* QUERY PRIVATE EVENTS: read the private `Vote` events the contract delivered to
* *us* when we cast a vote. Unlike the public `TallyUpdated` feed, each `Vote` is
* encrypted to the voter and only retrievable by the account it was delivered to.
* We use it to remind the user which candidate they picked, which something no one
* else can see.
*
* Returns the candidate this account voted for in the deployment's election, or
* `null` if they haven't voted yet
*/
async getMyVote(): Promise<bigint | null> {
const { PrivateVotingContract } = await loadVotingContract();
const events = await this.wallet.getPrivateEvents<{
election_id: bigint;
candidate: bigint;
voter: AztecAddress;
}>(PrivateVotingContract.events.Vote, {
contractAddress: AztecAddress.fromString(this.deployment.contractAddress),
scopes: [this.account],
});
const mine = events.find(
(e) => BigInt(e.event.election_id) === BigInt(this.deployment.electionId),
);
return mine ? BigInt(mine.event.candidate) : null;
}