Aztec QuickstartSDK
← Quest map

Run the app

With the contract deployed, start the frontend and watch the whole thing work.

bash
npm run dev   # http://localhost:5173

Open 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#

CONNECTREGISTERSIMULATESEND

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.

typescript
// 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:

CONNECTREGISTERSIMULATESEND

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:

typescript
/**
 * 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);
}
CONNECTREGISTERSIMULATESEND

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:

typescript
/**
 * 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)]),
  );
}
CONNECTREGISTERSIMULATESEND

Submit the real transaction. The network only ever sees the nullifier and the public tally going up by one:

typescript
/**
 * 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:

typescript
/**
 * 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:

typescript
/**
 * 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;
}