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.
Run npm run dev and have me open http://localhost:5173 and cast a vote. As we go, walk me through packages/app/src/aztec/voting_client.ts in my editor, region by region: CONNECT (EmbeddedWallet + initializerless account), REGISTER (getContract), SIMULATE (the batched get_tally reads, no transactions), SEND (cast_vote via an actual transaction), and the public vs. private event queries. Then have me vote a second time and explain the rejection I see.
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 — the app's own wallet, not a browser extension — 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 },
});
// 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,
deriveMasterMessageSigningSecretKey(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 wallet about every contract this app touches on this network — and there are two ways a contract becomes known, both used here. The voting 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 registers whatever comes back. The fee-paying FPC (Fee-Paying Contract — a contract that covers your transaction fee; the SponsoredFPC locally, the PrivateFeeJuice FPC on testnet) is fully private — nothing about it is published — so instead the app rebuilds its instance deterministically from the deploy params (artifact + salt) and registers that. Which FPC is the network's choice:
/**
* REGISTER: teach our wallet about the contracts this app needs on this network, so
* it can build transactions against them. There are two ways a contract becomes known
* to the wallet, and this app needs both — that's the published-vs-fully-private split:
*
* - The voting contract is PUBLISHED on chain (its constructor is a public
* `#[initializer]`), so its instance already lives on the network. We don't
* reconstruct it — we ask the node for it with `getContract(address)` and register
* whatever comes back.
*
* - Both FPCs are FULLY PRIVATE: nothing about them is published, so there is
* nothing on chain to fetch. We rebuild instances deterministically from the
* same deploy params it was deployed with (artifact + salt) and register that.
* Which FPC is the network's choice — PrivateFeeJuice on testnet, SponsoredFPC
* locally.
*
* Returns the bound voting contract plus how this network pays fees (see `FeePayment`);
* the payment method itself is built per-vote in `votePaymentMethod`.
*/
private static async register(
wallet: EmbeddedWallet,
node: AztecNode,
deployment: Deployment,
): Promise<{ voting: PrivateVotingContract; feePayment: FeePayment }> {
// Published → fetch the live instance from the node.
const { PrivateVotingContract, PrivateVotingContractArtifact } =
await loadVotingContract();
const votingInstance = await node.getContract(
AztecAddress.fromStringUnsafe(deployment.contractAddress),
);
if (!votingInstance) {
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.",
);
}
await wallet.registerContract(
votingInstance,
PrivateVotingContractArtifact,
);
const voting = PrivateVotingContract.at(votingInstance.address, wallet);
// Fully private → reconstruct the fee-paying FPC from its deploy params (artifact +
// salt) and register that. `deployment.fpcSalt` present means testnet's PrivateFeeJuice
// FPC; otherwise the canonical SponsoredFPC used locally.
let feePayment: FeePayment;
if (deployment.fpcAddress && deployment.fpcSalt) {
const { PrivateFeeJuiceContract, PrivateFeeJuiceContractArtifact } =
await loadPrivateFeeJuiceContract();
const fpcInstance = await getContractInstanceFromInstantiationParams(
PrivateFeeJuiceContractArtifact,
{ salt: Fr.fromString(deployment.fpcSalt) },
);
await wallet.registerContract(
fpcInstance,
PrivateFeeJuiceContractArtifact,
);
feePayment = {
type: "fpc",
instance: PrivateFeeJuiceContract.at(fpcInstance.address, wallet),
};
} else {
const { SponsoredFPCContractArtifact } = await loadSponsoredFPCContract();
const sponsoredInstance =
await getContractInstanceFromInstantiationParams(
SponsoredFPCContractArtifact,
{ salt: new Fr(SPONSORED_FPC_SALT) },
);
await wallet.registerContract(
sponsoredInstance,
SponsoredFPCContractArtifact,
);
feePayment = { type: "sponsored", instance: sponsoredInstance };
}
return { voting, feePayment };
}Run a function locally against 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 (clicking "Vote" does this). 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.fromStringUnsafe(
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.fromStringUnsafe(
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;
}