DEV Community

Cover image for A Clear and Working Example of Zero-Knowledge Proofs in a Real Web3 Application
Gil Lopes Bueno
Gil Lopes Bueno

Posted on • Edited on

A Clear and Working Example of Zero-Knowledge Proofs in a Real Web3 Application

Zero-knowledge proofs (ZKPs) allow someone to prove they know or did something — without revealing what it was. A specific type of ZKP called a zk-SNARK (Zero-Knowledge Succinct Non-Interactive Argument of Knowledge) enables compact and fast proofs that can be verified on-chain.

Writing ZK circuits manually can be complex, but ZoKrates makes it much easier. It provides a high-level language and tooling to compile circuits, generate proofs, and export Solidity verifiers.

Working Demo

In this tutorial, we’ll use ZoKrates to build a simple private voting example:

🗳️ A user will commit to their vote (1 or 2) using a secret nonce and the Poseidon hash function.

🔒 Later, they’ll prove (without revealing their vote or nonce) that:

  • The vote was valid (1 or 2), and

  • It matches a previously published commitment.

We’ll generate the proof entirely in JavaScript and verify it on-chain — all using a local setup with scaffold-eth-2, a powerful framework for building Ethereum dApps.

⚠️ This is a minimal example to help you understand the flow. It’s not a full voting system — just a small step into the zk world.


🛠️ Step 0: Install ZoKrates

On Linux/macOS, install with:

curl -LSfs get.zokrat.es | sh
Enter fullscreen mode Exit fullscreen mode

⚠️ On Windows, make sure to install and run inside WSL (Windows Subsystem for Linux), or you’ll face compatibility issues.

Check it's working:

zokrates --help
Enter fullscreen mode Exit fullscreen mode

⚙️ Step 1: Bootstrap Your dApp

Use scaffold-eth-2 — the fastest way to start building full-stack dApps.

Initialize the project:

npx create-eth@latest # we are using foundry, but hardhat works too
cd my-zk-vote  # open the project's directory
yarn           # install all dependencies
Enter fullscreen mode Exit fullscreen mode

Add ZK dependencies on NextJS package:

cd packages/nextjs # go to nextjs directory
yarn add circomlibjs zokrates-js # add zk frontend dependencies
Enter fullscreen mode Exit fullscreen mode

Start development servers:

cd ../..       # go back to the root directory
yarn chain     # start local blockchain
yarn deploy    # deploy contracts
yarn start     # launch frontend
Enter fullscreen mode Exit fullscreen mode

🧾 Step 2: Write the Circuit

You can see the circuit as a way to validate secret information. Each information marked as private will remain secret and can be validated against other public arguments.

Create a file at packages/foundry/contracts/vote.zok:

import "hashes/poseidon/poseidon.zok";

def main(private field vote, private field nonce, field commitment) -> bool {
    assert(vote == 1 || vote == 2);
    field hash = poseidon([vote, nonce]);
    assert(hash == commitment);
    return true;
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Enforces that the vote must be 1 or 2 — a simple rule for this example.

  • Hashes the vote and a secret nonce using Poseidon, then checks that it matches the provided commitment — proving the commitment corresponds to a valid vote.

Why Poseidon? It’s a hash function designed for zero-knowledge circuits — much faster and cheaper to verify in zk-SNARKs than SHA256 or Keccak.


🔐 Step 3: Generate the Commitment in JavaScript

Create a helper file at packages/nextjs/utils/zkVote.ts:

import { buildPoseidon } from "circomlibjs";

export async function generateCommitment(vote: number, nonce: bigint): Promise<string> {
  const poseidon = await buildPoseidon();
  const commitment = poseidon([BigInt(vote), nonce]);
  return poseidon.F.toString(commitment);
}
Enter fullscreen mode Exit fullscreen mode

🔬 You can now copy vote, nonce, and commitment into the ZoKrates Playground to test your circuit manually.

💡 Tip: You can use the commands below to quickly generate a commitment to test on the playground:

cd packages/nextjs
npx --package typescript tsc utils/zkVote.ts --target es2020 --module commonjs --outDir dist --esModuleInterop
node -e "require('./dist/zkVote.js').generateCommitment(2, 123456789n).then(console.log);"
Enter fullscreen mode Exit fullscreen mode

🧱 Step 4: Compile the Circuit

Run this from your project root:

cd packages/foundry/contracts
zokrates compile -i vote.zok
zokrates setup
zokrates export-verifier
Enter fullscreen mode Exit fullscreen mode
  • This will produce a verifier.sol file you can use on-chain.
  • Move proving.key and verification.key to packages/nextjs/public so it's available in the frontend, don't worry, these keys are not private.
  • You can delete abi.json, out and out.r1cs files, if you want.

📄 Step 5: Add the Verifier to Your App

  1. Create packages/foundry/script/DeployVerifier.s.sol:
pragma solidity ^0.8.19;

import "./DeployHelpers.s.sol";
import "../contracts/verifier.sol";

contract DeployVerifier is ScaffoldETHDeploy {
    function run() external ScaffoldEthDeployerRunner {
        new Verifier();
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Edit packages/foundry/script/Deploy.s.sol:
pragma solidity ^0.8.19;

import "./DeployHelpers.s.sol";
import { DeployYourContract } from "./DeployYourContract.s.sol";
import { DeployVerifier } from "./DeployVerifier.s.sol";

contract DeployScript is ScaffoldETHDeploy {
    function run() external {
        DeployYourContract deployYourContract = new DeployYourContract();
        deployYourContract.run();

        DeployVerifier deployVerifier = new DeployVerifier();
        deployVerifier.run();
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Then redeploy everything:
yarn deploy
Enter fullscreen mode Exit fullscreen mode

🧠 Step 6: Generate the Proof in the Frontend

Continue in packages/nextjs/utils/zkVote.ts and add the following:

import { initialize } from "zokrates-js";

let cachedKeypair: any = null;

async function loadKeypair() {
  if (!cachedKeypair) {
    const provingKeyResponse = await fetch("/proving.key");
    const provingKeyBuffer = await provingKeyResponse.arrayBuffer();

    const verificationKeyResponse = await fetch("/verification.key");
    const verificationKeyBuffer = await verificationKeyResponse.arrayBuffer();

    const provingKeyUint8 = new Uint8Array(provingKeyBuffer);
    const verificationKeyUint8 = new Uint8Array(verificationKeyBuffer);

    cachedKeypair = {
      pk: provingKeyUint8,
      vk: verificationKeyUint8,
    };
  }
  return cachedKeypair;
}

export type Proof = {
  proof: {
    a: { X: bigint; Y: bigint };
    b: { X: readonly [bigint, bigint]; Y: readonly [bigint, bigint] };
    c: { X: bigint; Y: bigint };
  };
  inputs: readonly [bigint, bigint];
};

function formatProof(rawProof: any): Proof {
  const { a, b, c } = rawProof.proof;

  const toBigTuple = (arr: string[]) => [BigInt(arr[0]), BigInt(arr[1])] as const;

  return {
    proof: {
      a: { X: BigInt(a[0]), Y: BigInt(a[1]) },
      b: { X: toBigTuple(b[0]), Y: toBigTuple(b[1]) },
      c: { X: BigInt(c[0]), Y: BigInt(c[1]) },
    },
    inputs: toBigTuple(rawProof.inputs),
  };
}

export async function proveVote(vote: number, nonce: bigint, commitment: string): Promise<Proof> {
  const zokrates = await initialize();
  const artifacts = zokrates.compile(`
  import "hashes/poseidon/poseidon.zok";

  def main(private field vote, private field nonce, field commitment) -> bool {
      assert(vote == 1 || vote == 2);
      field hash = poseidon([vote, nonce]);
      assert(hash == commitment);
      return true;
  }
      `);
  const keypair = await loadKeypair();

  const { witness } = zokrates.computeWitness(artifacts, [vote.toString(), nonce.toString(), commitment]);
  const rawProof = zokrates.generateProof(artifacts.program, witness, keypair.pk);
  return formatProof(rawProof);
}

Enter fullscreen mode Exit fullscreen mode

The important method here is proveVote, it will:

  • initialize zokrates
  • compile the circuit
  • load the keypairs
  • compute witness using the arguments of the method
  • generate the proof with the compiled circuit, the witness and the proving key.

🤝 Step 7: Verify the Proof On-Chain

Call your Verifier contract from the frontend on a Nextjs page:

"use client";

import { useRef, useState } from "react";
import type { NextPage } from "next";
import { toast } from "react-hot-toast";
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
import { Proof, generateCommitment, proveVote } from "~~/utils/zkVote";

const Home: NextPage = () => {
  // generate a random nonce
  const nonceRef = useRef<bigint>(BigInt(Date.now() + Math.floor(Math.random() * 1000)));

  // state for the vote, the commitment, the proof, and the loading state
  const [vote, setVote] = useState<string>("");
  const [commitment, setCommitment] = useState<string>();
  const [proof, setProof] = useState<Proof | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  // verify the proof onchain once you have the proof
  const { data: isVerified } = useScaffoldReadContract({
    contractName: "Verifier",
    functionName: "verifyTx",
    args: [proof?.proof, proof?.inputs],
    query: {
      enabled: !!proof,
    },
  });

  // generate the commitment when the user clicks the button
  const handleVote = async () => {
    if (!vote) return;

    setIsLoading(true);
    try {
      const voteNumber = Number(vote);
      const newCommitment = await generateCommitment(voteNumber, nonceRef.current);
      setCommitment(newCommitment);
    } catch (error) {
      toast.error("Error generating commitment: " + (error as Error).message);
    } finally {
      setIsLoading(false);
    }
  };

  // prove the vote when the user clicks the button
  const handleProve = async () => {
    if (!vote || !commitment) return;

    setIsLoading(true);

    try {
      const voteNumber = Number(vote);
      const newProof = await proveVote(voteNumber, nonceRef.current, commitment);
      setProof(newProof);
    } catch {
      toast.error("Impossible to prove");
      setProof(null);
    } finally {
      setIsLoading(false);
    }
  };

  // render your page...
}
Enter fullscreen mode Exit fullscreen mode

You can check the whole code of the page component in the repository Github.

To test your ZK dApp locally, just open http://localhost:3000 in your browser.


🔚 Final Thoughts

In this example, we called the Verifier contract directly from the frontend. That’s useful for learning — but in a real-world application, it’s not the pattern you’d use.

Instead, your main contract (e.g. a voting contract, identity manager, or game logic) would receive the proof and internally call the Verifier contract to validate it before proceeding with any logic.

This architecture gives you control and flexibility:

function submitVote(
  uint[2] calldata a,
  uint[2][2] calldata b,
  uint[2] calldata c,
  uint[] calldata input
) external {
  require(verifier.verifyTx(a, b, c, input), "Invalid proof");
  // continue with vote counting, recording, etc
}
Enter fullscreen mode Exit fullscreen mode

🧠 What You Could Build with This

This approach opens doors to powerful applications:

  • Anonymous voting: prove your vote was valid without revealing it
  • Private whitelists: prove membership in a group (via Merkle root) without showing which one
  • Private randomness: commit to a number, later prove its properties (e.g., in a fair lottery)
  • Game actions: prove you played by the rules without revealing your strategy
  • Credential systems: prove you hold a credential without disclosing your identity

Zero-knowledge isn’t just privacy — it’s about integrity without exposure.

If you're exploring zero-knowledge or building real-world dApps, feel free to connect — I'd love to exchange ideas.


Written by Gil, a fullstack developer with 15+ years of experience, passionate about practical architecture, clean UX, and blockchain-powered applications.

Top comments (0)