Aztec QuickstartSDK

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 — 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.

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 },
});
 
// 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:

CONNECTREGISTERSIMULATESEND

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:

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

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:

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 (clicking "Vote" does this). 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.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:

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.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;
}