Introduction
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:
- Nebra is currently live only on Sepolia testnet.
- This tutorial will run for a few minutes as it waits for the proof submission to be aggregated by Nebra and verified on-chain.
Prerequisites
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:
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:
parameter | type | input |
---|
name | string | demo-issuer (don’t trust me) |
verificationStackId | uint8 | 1 |
publicKeyId | uint256 | 18776893102620642575656716525121755312642649464684953450234247405793284298744 |
publicKeyRaw | bytes | 0x0435be315dd7c00c9ba151f7c811bde6598c2e1b1f30552a3fb07a34b6c91416a99883f708f3389842c8f43c1c272bb8210a68d25f09201887354d85e8e58beff0 |
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 {
prepare,
credential,
evm,
credType,
errors,
user,
issuer,
utils,
} from "@galxe-identity-protocol/sdk";
import { ethers } from "ethers";
import { UpaClient, CircuitIdProofAndInputs, Proof } from "@nebrazkp/upa/sdk";
import * as fs from "fs";
const unwrap = errors.unwrap;
const RPC = "https://rpc.ankr.com/eth_sepolia";
const provider = new ethers.JsonRpcProvider(RPC);
const context = "Proof Aggregation Tutorial Example Context";
const circuidId = BigInt(
"535783125321978663259414080602879573584328345995263811920911450103380255481"
);
const dummyIssuerEvmAddr = "0xdeee54e0f3cbb7d5c4b2cb91d39c9c9b48a1b532";
async function issuingProcess(userEvmAddr: string, userIdc: bigint) {
const typeSpec = credType.primitiveTypes.unit;
const tp = unwrap(credType.createTypeFromSpec(typeSpec));
const contextID = credential.computeContextID(context);
const newCred = unwrap(
credential.Credential.create(
{
type: tp,
contextID: contextID,
userID: BigInt(userEvmAddr),
},
{}
)
);
newCred.attachments["creativity"] = "uncountable";
const issuerID = BigInt(dummyIssuerEvmAddr);
const issuerChainID = BigInt(11155111);
const dummyKey = utils.decodeFromHex(
"0x8d06429619a08325ea79a575e1df14787be5223614403a9142360616811f7aea"
);
const issuerPk = dummyKey;
const myIssuer = new issuer.BabyzkIssuer(issuerPk, issuerID, issuerChainID);
myIssuer.sign(newCred, {
sigID: BigInt(100),
expiredAt: BigInt(
Math.ceil(new Date().getTime() / 1000) + 7 * 24 * 60 * 60
),
identityCommitment: userIdc,
});
return newCred;
}
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(
myCred.header.type,
provider
);
console.log("proof generation gadgets are downloaded successfully.");
const expiredAtLowerBound = BigInt(
Math.ceil(new Date().getTime() / 1000) + 3 * 24 * 60 * 60
);
const equalCheckId = BigInt(0);
const pseudonym = BigInt("0xdeadbeef");
const proof = await u.genBabyzkProofWithQuery(
u.getIdentityCommitment("evm")!,
myCred,
proofGenGagets,
`
{
"options": {
"expiredAtLowerBound": "${expiredAtLowerBound}",
"externalNullifier": "${externalNullifier}",
"equalCheckId": "${equalCheckId}",
"pseudonym": "${pseudonym}"
}
}
`
);
return proof;
}
async function verifyByCallingAggregatedStatefulVerifier(
publicSignals: string[]
): Promise<boolean> {
const expectedContextID = credential.computeContextID(context);
const expectedIssuerID = BigInt(dummyIssuerEvmAddr);
const expectedTypeID = credType.primitiveTypes.unit.type_id;
const aggregatedStatefulVerifier =
evm.v1.createAggregatedBabyzkStatefulVerifier({
signerOrProvider: provider,
});
const statefulVerifierResult =
await aggregatedStatefulVerifier.verifyProofFull(
expectedTypeID,
expectedContextID,
expectedIssuerID,
circuidId,
publicSignals
);
if (statefulVerifierResult !== evm.VerifyResult.OK) {
console.error(
"Proof verification failed, reason: ",
evm.verifyResultToString(statefulVerifierResult)
);
} else {
console.log("On-chain stateful proof verification is successful.");
}
return true;
}
async function main() {
await prepare();
const upaInstanceDescriptor = JSON.parse(
fs.readFileSync("upa.instance", "ascii")
);
const signer = new ethers.Wallet(process.env.NEBRA_SIGNER_PK!, provider);
const upaClient = new UpaClient(signer, upaInstanceDescriptor);
const u = new user.User();
const evmIdSlice = u.createNewIdentitySlice("evm");
const userIdc = user.User.computeIdentityCommitment(evmIdSlice);
const userEvmAddr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
const myCred = await issuingProcess(userEvmAddr, userIdc);
console.log("Credential is issued successfully.");
console.log(myCred.marshal(2));
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: proof.publicSignals.map((x) => BigInt(x)),
},
];
const submissionHandle = await upaClient.submitProofs(
circuitIdProofAndInputs
);
const submitProofTxReceipt = await upaClient.waitForSubmissionVerified(
submissionHandle
);
console.log("Proof is submitted successfully.", submitProofTxReceipt);
await verifyByCallingAggregatedStatefulVerifier(proof.publicSignals);
process.exit(0);
}
main();
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.
And note that in verifyByCallingAggregatedStatefulVerifier
, we can now verify the proof using the public signals only with aggregatedStatefulVerifier.verifyProofFull
.
Conclusion
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