Galxe Identity Protocol SDK is capable to utilize NEBRA’s proof aggregation tech to drastically reduce the costly onchain proof verifications. To learn more on NEBRA, click here.

This tutorial guides you through the process of using Nebra UPA to aggregate and verify credentials on Sepolia.

Note that:

  1. Nebra is currently live only on Sepolia testnet.
  2. This tutorial will run for a few minutes as it waits for the proof submission to be aggregated by Nebra and verified on-chain.


To run this tutorial, you need to have an funded address on Sepolia as Nebra signer. This address is used to submit proofs to the Nebra Upa receiver. Example proof submission transaction can be found here.

  • Create .env file using .env.example as a template. Update NEBRA_SIGNER_PK with your private key.

Step 1: Setup and initialize Nodejs repo

In your Node.js repo, run:

npm install @galxe-identity-protocol/sdk ethers @nebrazkp/upa

Step 2: Import necessary dependencies and setup issuer

Note that the dummy issuer used in this tutorial has been registered on Sepolia testnet. Check out the registration transaction here.

This issuer is registered with the following parameters:

namestringdemo-issuer (don’t trust me)
  • verificationStackId = 1 means that this issuer is registered for babyzk stack

Step 3: Register your circuit on NEBRA

Since this tutorial uses the Unit primitive type, you can skip this step and use the pre-registered circuit ID instead - 535783125321978663259414080602879573584328345995263811920911450103380255481.

This circuit is registed with this transaction.

To register your own circuit, follow the tutorial here.

Step 4: Let’s dive in to the code

Code used in this tutorial is also available here.

import "dotenv/config";
import {
} from "@galxe-identity-protocol/sdk";
import { ethers } from "ethers";
import { UpaClient, CircuitIdProofAndInputs, Proof } from "@nebrazkp/upa/sdk";
import * as fs from "fs";

// conviniently unwrap the result of a function call by throwing an error if the result is an error.
const unwrap = errors.unwrap;

// Use Ankr's free open rpc in this example.
const RPC = "";
const provider = new ethers.JsonRpcProvider(RPC);

const context = "Proof Aggregation Tutorial Example Context";

// registered with this tx:
const circuidId = BigInt(

// This is a dummy issuer's EVM address that has been registered on sepolia.
const dummyIssuerEvmAddr = "0xdeee54e0f3cbb7d5c4b2cb91d39c9c9b48a1b532";

// demonstration of the issuingProcess.
async function issuingProcess(userEvmAddr: string, userIdc: bigint) {
  const typeSpec = credType.primitiveTypes.unit;
  const tp = unwrap(credType.createTypeFromSpec(typeSpec));

  const contextID = credential.computeContextID(context);
  // Now, let's create the credential.
  const newCred = unwrap(
        type: tp,
        contextID: contextID,
        userID: BigInt(userEvmAddr),
  newCred.attachments["creativity"] = "uncountable";

  // signing the credential.
  const issuerID = BigInt(dummyIssuerEvmAddr);
  const issuerChainID = BigInt(11155111); // sepolia
  // A mock private key for the signer, which is used to sign the credential.
  // This key has been registered and activated on sepolia by the dummy issuer.
  // pub key: 0x0435be315dd7c00c9ba151f7c811bde6598c2e1b1f30552a3fb07a34b6c91416a99883f708f3389842c8f43c1c272bb8210a68d25f09201887354d85e8e58beff0
  const dummyKey = utils.decodeFromHex(
  const issuerPk = dummyKey;
  // create a new issuer object using the private key, issuerID, and issuerChainID.
  const myIssuer = new issuer.BabyzkIssuer(issuerPk, issuerID, issuerChainID);
  // sign the credential to user's identity commitment, with a unique signature id and expiration date.
  myIssuer.sign(newCred, {
    sigID: BigInt(100),
    expiredAt: BigInt(
      Math.ceil(new Date().getTime() / 1000) + 7 * 24 * 60 * 60
    ), // assuming the credential will be expired after 7 days
    identityCommitment: userIdc,

  // all done, return the credential to the owner.
  return newCred;

// demonstration of the proofGenProcess.
async function proofGenProcess(myCred: credential.Credential, u: user.User) {
  const externalNullifier = utils.computeExternalNullifier(
    "Galxe Identity Protocol tutorial's verification"
  console.log("downloading proof generation gadgets...");
  const proofGenGagets = await user.User.fetchProofGenGadgetsByTypeID(
  console.log("proof generation gadgets are downloaded successfully.");
  // Let's generate the proof.
  // Assume that we want to verify that the credential is still valid after 3 days.
  const expiredAtLowerBound = BigInt(
    Math.ceil(new Date().getTime() / 1000) + 3 * 24 * 60 * 60
  // Do not reveal the credential's actual id, which is the evm address in this example
  const equalCheckId = BigInt(0);
  // Instead, claim to be Mr.Deadbeef. It's verifier's responsibility to verify that the pseudonym is who
  // he claims to be, after verifying the proof.
  const pseudonym = BigInt("0xdeadbeef");
  // Here we ignore the conditions and just use the options in the query, since unit credential needs no conditions.
  const proof = await u.genBabyzkProofWithQuery(
      "options": {
        "expiredAtLowerBound": "${expiredAtLowerBound}",
        "externalNullifier": "${externalNullifier}",
        "equalCheckId": "${equalCheckId}",
        "pseudonym": "${pseudonym}"
  return proof;

async function verifyByCallingAggregatedStatefulVerifier(
  publicSignals: string[]
): Promise<boolean> {
  // As a verifier, we must decide the expected
  // contextID, issuerID, and typeID of the credential.
  const expectedContextID = credential.computeContextID(context);
  const expectedIssuerID = BigInt(dummyIssuerEvmAddr);
  const expectedTypeID = credType.primitiveTypes.unit.type_id;

  // Let's take a look at the on-chain verification first.
  // It is just 1 simple function call.
  const aggregatedStatefulVerifier =
      signerOrProvider: provider,
  const statefulVerifierResult =
    await aggregatedStatefulVerifier.verifyProofFull(
  if (statefulVerifierResult !== evm.VerifyResult.OK) {
      "Proof verification failed, reason: ",
  } else {
    console.log("On-chain stateful proof verification is successful.");
  return true;

async function main() {
  // prepare must be called by the application before any other function.
  await prepare();

  // setup UPA
  const upaInstanceDescriptor = JSON.parse(
    fs.readFileSync("upa.instance", "ascii")
  // eslint-disable-next-line turbo/no-undeclared-env-vars
  const signer = new ethers.Wallet(process.env.NEBRA_SIGNER_PK!, provider);
  const upaClient = new UpaClient(signer, upaInstanceDescriptor);

  // The very first step is to create a user with a random identity.
  // This should be done on user's device and the identity should be stored securely.
  const u = new user.User();
  const evmIdSlice = u.createNewIdentitySlice("evm");

  // User's identity commitment is computed based on the secrets of the identity slice.
  // You can also retrive the identity commitment from the identity slice.
  const userIdc = user.User.computeIdentityCommitment(evmIdSlice);

  // let's use a famous Ethereum address in this example.
  const userEvmAddr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";

  // Issuer's process: issuing a credential to the user.
  const myCred = await issuingProcess(userEvmAddr, userIdc);
  console.log("Credential is issued successfully.");

  // User's process: generating a zk proof to prove some statements about the credential.
  const proof = await proofGenProcess(myCred, u);
  console.log("Proof is generated successfully.", proof);

  const circuitIdProofAndInputs: CircuitIdProofAndInputs[] = [
      circuitId: circuidId,
      proof: Proof.from_snarkjs(proof.proof),
      inputs: => BigInt(x)),
  const submissionHandle = await upaClient.submitProofs(
  const submitProofTxReceipt = await upaClient.waitForSubmissionVerified(
  console.log("Proof is submitted successfully.", submitProofTxReceipt);

  // Now that our proof submission has been verified, we can just verify the
  // proof using public signals only.
  await verifyByCallingAggregatedStatefulVerifier(proof.publicSignals);



While this looks similar to the previous tutorial, the main differences here is the proof verification process. Let’s take a look at the main function. Instead of verifying the proof on-chain with our stateful verifier like in the previous tutorial, we submit the proof to Nebra UPA instead and once submission is verified, we verify the proof using the public signals only.

Proof is submitted to Nebra UPA using the submitProofs function. This function returns a submission handle which can be used to wait for the submission to be verified using the waitForSubmissionVerified function. waitForSubmissionVerified will run for a while since it takes time for the proof to be aggregated by NEBRA and verified on-chain.

const submissionHandle = upaClient.submitProofs(circuitIdProofAndInputs);
const submitProofTxReceipt = await upaClient.waitForSubmissionVerified(
console.log("Proof is submitted successfully.", submitProofTxReceipt);

And note that in verifyByCallingAggregatedStatefulVerifier, we can now verify the proof using the public signals only with aggregatedStatefulVerifier.verifyProofFull.


Congratulations! You’ve successfully issued a credential, generated a zero-knowledge proof, submit the proof to be aggregated by NEBRA, and verified it on-chain. This tutorial demonstrates how NEBRA can be utilized to aggregate and verify proofs off-chain, reducing the costly on-chain proof verifications.

The code used in this tutorial is available here