Skip to content

Verify proofs on Cardano

View Markdown

Cardano’s Plutus V3 ledger has native BLS12-381 builtins (CIP-0381): point decompression, group operations, Miller loops and a final pairing check. That is everything a Groth16 verifier needs, so a smart contract can check a zero-knowledge proof itself. ZeroJ’s zeroj-onchain-julc module ships that verifier as Java source compiled to Plutus by JuLC (a Java-to-Plutus compiler), plus the off-chain codecs that turn a ZeroJ or snarkjs proof into the bytes the validator expects.

This guide explains the pieces and how to compose them. For a step-by-step walkthrough on a local devnet, follow Verify your proof on Cardano.

ComponentPackage (org.zeroj.onchain.julc.…)StatusUse it for
Groth16BLS12381Verifiergroth16.validatorWorking, crypto-onlyLearning, tests, and as the reference shape. Never protects value alone.
Groth16BLS12381TxOutRefBindingVerifiergroth16.validatorReference exampleShows the check that binds the first public input to the spent output. It reads that value from the guarded UTxO’s datum, so it can’t lock real funds as-is (why).
Groth16BLS12381Libgroth16.libWorking @OnchainLibraryComposing Groth16 verification into your own validator.
ProverToCardano, SnarkjsToCardanogroth16.codecOff-chain helpersCompressing ZeroJ or snarkjs proofs/VKs into validator parameters and redeemers.
Groth16AuthenticatedStateTransitionValidator and its script factorygroth16.validator, groth16.codecExperimentalPoseidon MPF/JMT state transitions. See Large authenticated state.
BbsProofVerify, BbsHashToScalarbbs.libWorking libraries, fixed profileOn-chain BBS selective disclosure. See BBS.
PlonkBLS12381Lib, PlonkBLS12381Verifier, PlonkBLS12381MultiInputVerifier, PlonkBLS12381MultiInputParamVerifier, PlonKProverToCardanoplonk.*ExperimentalLabeled testnet trials only.
ScriptBudgetEstimator, OnChainFeasibilityanalysisPlanning helpersRough budget estimates and a proof-system/curve feasibility matrix.
ReferenceScriptDeployerdeploymentConfig helperDescribing CIP-0033 reference-script deployment patterns. Does not submit transactions.

The generic Groth16 validator splits the statement across the three places a Cardano spend can carry data:

DataWhereWhy
Verification key (vkAlpha, vkBeta, vkGamma, vkDelta, vkIc)Script parameters, applied at load timeThe VK becomes part of the script hash, so the script address itself pins the key.
Public inputsDatum: a list of integers in VK orderFixed when the UTxO is locked.
Proof (piA, piB, piC)Redeemer: Constr 0 [piA, piB, piC] of compressed pointsSupplied by whoever spends.

vkIc holds one compressed G1 point per public input plus one (IC[0]). The validator checks the datum length matches.

Inside Groth16BLS12381Lib.verify, every public input must be in [0, r), and every proof and VK point must be a canonical compressed encoding (48 bytes for G1, 96 for G2) that is not the point at infinity. Only then does it fold the public inputs into vk_x and run the pairing check with the Plutus builtins.

Compress the VK and proof off-chain, then apply the VK as parameters with JuLC’s JulcScriptLoader. This mirrors ZeroJ’s Yaci DevKit end-to-end test.

LoadVerifier.java
import com.bloxbean.cardano.client.address.AddressProvider;
import com.bloxbean.cardano.client.common.model.Networks;
import com.bloxbean.cardano.client.plutus.spec.BigIntPlutusData;
import com.bloxbean.cardano.client.plutus.spec.BytesPlutusData;
import com.bloxbean.cardano.client.plutus.spec.ConstrPlutusData;
import com.bloxbean.cardano.client.plutus.spec.ListPlutusData;
import com.bloxbean.cardano.julc.clientlib.JulcScriptLoader;
import org.zeroj.onchain.julc.groth16.codec.ProverToCardano;
import org.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier;
// keys: a Groth16Keys handle; proof: keys.prove(...); witness[1..numPublic] are the public inputs
var vk = ProverToCardano.compressVk(keys);
var compressedProof = ProverToCardano.compressProof(proof);
var ic = ListPlutusData.of();
for (byte[] point : vk.ic()) {
ic.add(new BytesPlutusData(point));
}
var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class,
new BytesPlutusData(vk.alpha()),
new BytesPlutusData(vk.beta()),
new BytesPlutusData(vk.gamma()),
new BytesPlutusData(vk.delta()),
ic);
String scriptAddress = AddressProvider.getEntAddress(script, Networks.testnet()).toBech32();
var datum = ListPlutusData.of(BigIntPlutusData.of(witness[1])); // one entry per public input
var redeemer = ConstrPlutusData.builder()
.alternative(0)
.data(ListPlutusData.of(
new BytesPlutusData(compressedProof.piA()),
new BytesPlutusData(compressedProof.piB()),
new BytesPlutusData(compressedProof.piC())))
.build();

Lock funds at scriptAddress with the datum, then spend that UTxO with the redeemer and the script attached (or referenced), using Cardano Client Lib’s QuickTxBuilder.

If your proof and key came from snarkjs, use SnarkjsToCardano.parseVk(vkJson), SnarkjsToCardano.parseProof(proofJson) and SnarkjsToCardano.parsePublicInputs(publicJson) instead. They return the same VkCompressed and ProofCompressed records.

Dependencies for the off-chain side:

build.gradle
dependencies {
implementation platform('org.zeroj:zeroj-bom-core:0.1.0-pre12')
implementation 'org.zeroj:zeroj-onchain-julc'
implementation "com.bloxbean.cardano:julc-cardano-client-lib:0.1.0-pre16" // JulcScriptLoader
implementation "com.bloxbean.cardano:cardano-client-lib:0.8.0-pre5"
runtimeOnly "com.bloxbean.cardano:julc-vm-java:0.1.0-pre16"
}

Groth16BLS12381Verifier.validate ignores its ScriptContext argument. Three consequences follow:

  1. Replay across UTxOs. If two UTxOs carry the same datum, one proof unlocks both.
  2. Front-running. A proof is public the moment its transaction hits the mempool. Anyone can copy the redeemer into their own transaction that spends the same UTxO and pays themselves.
  3. No business rules. Nothing checks signers, outputs, deadlines, or nullifiers.

The standard fix for the first problem is to make public input 0 depend on the UTxO being spent: spendRef = blake2b_256(spentTxId || outputIndex) mod r, with the index encoded as 32 big-endian bytes. The circuit exposes spendRef as its first public input, the prover computes it for the UTxO it is about to spend, and the validator recomputes it from ScriptContext. A proof made for one UTxO then fails for every other one. It does not stop front-running: a watcher can still copy the redeemer into a transaction that spends the same UTxO and pays itself, so bind the beneficiary too.

Groth16BLS12381TxOutRefBindingVerifier demonstrates this check, and its own documentation calls it an example policy. Don’t use it to lock real funds as-is.

The fix for all three problems is a custom validator that ties the statement to the transaction.

Define your own spending validator, keep the proof record local (JuLC decodes records per validator), and call Groth16BLS12381Lib.verify alongside your own checks. The validator below binds the proof to the UTxO being spent the right way: the datum holds only the application’s public inputs, and the validator computes spendRef from ScriptContext and prepends it before checking the proof. It is the validator built and tested in Verify your proof on Cardano, where it unlocks a real lock transaction on an in-memory ledger and rejects the same proof replayed against a second UTxO (about 3.02 billion CPU steps and 263,000 memory units for two application inputs).

SpendBoundGroth16Verifier.java
package com.example.onchain;
import com.bloxbean.cardano.julc.core.PlutusData;
import com.bloxbean.cardano.julc.ledger.ScriptContext;
import com.bloxbean.cardano.julc.ledger.ScriptInfo;
import com.bloxbean.cardano.julc.ledger.TxOutRef;
import com.bloxbean.cardano.julc.stdlib.Builtins;
import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint;
import com.bloxbean.cardano.julc.stdlib.annotation.Param;
import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator;
import org.zeroj.onchain.julc.groth16.lib.Groth16BLS12381Lib;
import java.math.BigInteger;
/**
* Groth16 verifier whose statement is bound to the UTxO being spent.
*
* <p>The datum holds only the application's public inputs, e.g. [a, product]. The validator
* computes spendRef = blake2b_256(txId || outputIndex as 32 bytes) mod r for the UTxO it is
* validating and prepends it, so the proof is checked against [spendRef, a, product]. A proof
* made for one UTxO therefore fails for every other UTxO.</p>
*/
@SpendingValidator
public class SpendBoundGroth16Verifier {
@Param static byte[] vkAlpha; // G1 compressed, 48 bytes
@Param static byte[] vkBeta; // G2 compressed, 96 bytes
@Param static byte[] vkGamma; // G2 compressed, 96 bytes
@Param static byte[] vkDelta; // G2 compressed, 96 bytes
@Param static PlutusData vkIc; // list of G1 compressed IC points
record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {}
@Entrypoint
public static boolean validate(PlutusData datum, Groth16Proof proof, ScriptContext ctx) {
PlutusData publicInputs = Builtins.listData(
Builtins.mkCons(Builtins.iData(spendRef(ctx)), Builtins.unListData(datum)));
return Groth16BLS12381Lib.verify(publicInputs, proof.piA(), proof.piB(), proof.piC(),
vkAlpha, vkBeta, vkGamma, vkDelta, vkIc);
}
/** blake2b_256(txId || outputIndex as 32-byte big-endian) mod r; -1 (always rejected) if not a spend. */
private static BigInteger spendRef(ScriptContext ctx) {
ScriptInfo scriptInfo = ctx.scriptInfo();
if (scriptInfo instanceof ScriptInfo.SpendingScript spendingScript) {
TxOutRef txOutRef = spendingScript.txOutRef();
byte[] indexBytes = Builtins.integerToByteString(true, 32, txOutRef.index());
byte[] preimage = Builtins.appendByteString(txOutRef.txId().hash(), indexBytes);
return Builtins.byteStringToInteger(true, Builtins.blake2b_256(preimage)).mod(fr());
} else {
return BigInteger.valueOf(-1);
}
}
/** The BLS12-381 scalar field order r. */
private static BigInteger fr() {
BigInteger base = BigInteger.valueOf(1000000000000000000L);
return BigInteger.valueOf(52435L).multiply(base)
.add(BigInteger.valueOf(875175126190479447L)).multiply(base)
.add(BigInteger.valueOf(740508185965837690L)).multiply(base)
.add(BigInteger.valueOf(552500527637822603L)).multiply(base)
.add(BigInteger.valueOf(658699938581184513L));
}
}

The circuit must declare spendRef as its first public input and use it in a real constraint, and the prover computes it for the out-ref it is about to spend. The tutorial shows the matching circuit and the off-chain blake2b_256 computation. Unlike the bundled Groth16BLS12381TxOutRefBindingVerifier, nothing here requires a datum to contain a hash of its own transaction.

A spend binding alone doesn’t stop front-running on the same UTxO. Add whatever else your application needs:

  • Bind the beneficiary. Put the recipient’s key hash in the statement (or the datum) and check that an output pays them, so a copied proof can’t redirect funds. The BBS claim validator in zeroj-usecases (reusable-kyc) does exactly this.
  • Enforce single use. Spending a UTxO is already one-time. For “one action per person”, add a nullifier and record it on-chain. See Secure your ZK application.
  • Check signers, validity ranges, minted tokens and continuing outputs as your protocol requires.

To compile a validator that calls Groth16BLS12381Lib, add ZeroJ’s on-chain module and the JuLC annotation processor to your build. The runnable apps in zeroj-usecases use:

build.gradle
dependencies {
implementation "org.zeroj:zeroj-onchain-julc:0.1.0-pre12"
annotationProcessor "org.zeroj:zeroj-onchain-julc:0.1.0-pre12"
implementation "com.bloxbean.cardano:julc-stdlib:0.1.0-pre16"
annotationProcessor "com.bloxbean.cardano:julc-annotation-processor:0.1.0-pre16"
testImplementation "com.bloxbean.cardano:julc-testkit:0.1.0-pre16" // run validators in the JuLC VM
testRuntimeOnly "com.bloxbean.cardano:julc-vm-java:0.1.0-pre16"
}

The annotationProcessor "org.zeroj:zeroj-onchain-julc" line is required: JuLC compiles library code such as Groth16BLS12381Lib from the sources that module ships under META-INF/plutus-sources, and without it compilation fails with Plutus compilation error: Undefined variable: Groth16BLS12381Lib. If you use the BOM with versionless coordinates, add annotationProcessor platform('org.zeroj:zeroj-bom-core:0.1.0-pre12') as well. Only the JuLC testkit and VM lines are for running validators locally; loading a compiled script and submitting transactions needs just JuLC’s julc-cardano-client-lib and Cardano Client Lib.

Test the validator in the JuLC VM with positive cases and negative ones: wrong public input, tampered proof, a different spent UTxO, an output paying someone else. Then run it on Yaci DevKit before any public testnet.

A Groth16 check is four Miller loops and one final verification, plus one G1 scalar multiplication and addition per public input. ZeroJ’s JuLC VM tests measured the generic Groth16BLS12381Verifier, evaluated as a spending validator with a full ScriptContext, at 2,627,770,348 CPU steps and 177,749 memory units for one public input, about 2.82 billion CPU steps and 212,000 memory units for two, and about 3.02 billion and 246,000 for three. Your own validator’s policy checks come on top, and a JuLC upgrade can shift these numbers.

For planning, ScriptBudgetEstimator gives estimates built from BLS12-381 builtin costs (they leave out the rest of the validator, so measured budgets come out higher), and OnChainFeasibility reports what ZeroJ considers workable:

import org.zeroj.api.CurveId;
import org.zeroj.api.ProofSystemId;
import org.zeroj.onchain.julc.analysis.OnChainFeasibility;
import org.zeroj.onchain.julc.analysis.ScriptBudgetEstimator;
long cpu = ScriptBudgetEstimator.estimateCpu(ProofSystemId.GROTH16, CurveId.BLS12_381, 3);
var entry = OnChainFeasibility.lookup(ProofSystemId.GROTH16, CurveId.BLS12_381); // status WORKING
boolean bn254 = OnChainFeasibility.isFeasible(ProofSystemId.GROTH16, CurveId.BN254); // false

Estimates are not measurements. Before relying on a validator, measure it in the JuLC VM with a representative transaction and compare against the target network’s current per-transaction execution limits. Keep public inputs few: each one costs a scalar multiplication on-chain.

Attaching the full validator, with its VK parameters applied, to every spending transaction makes each one larger and more expensive. Instead, publish the script once in a UTxO as a reference script and point spends at it. That shrinks transactions and fees; the execution budget is unchanged. ReferenceScriptDeployer.DeploymentConfig records the three patterns ZeroJ describes (VK_IN_SCRIPT, REFERENCE_SCRIPT_DATUM_VK, VK_HASH_COMMITMENT); your transaction builder does the actual work.

If you ever supply the VK through a datum or redeemer instead of script parameters, the validator must check it against a hash it already trusts. An unauthenticated VK lets the spender pick the circuit.

The compiled UPLC, and therefore the script hash and address, is produced by the JuLC compiler. ZeroJ 0.1.0-pre12 builds against JuLC 0.1.0-pre16. Treat a JuLC upgrade as a new script: recompute the hash, re-measure budgets, and plan how funds locked at the old address move. ZeroJ’s authenticated-state release tooling binds the compiler version into each release identity for exactly this reason. Renaming Java packages alone does not change the hash: the org.zeroj namespace move left script hashes identical. See Migration notes.

For completeness: PlonkBLS12381Verifier handles one public input, PlonkBLS12381MultiInputVerifier takes 1–8 public inputs from the datum, and PlonkBLS12381MultiInputParamVerifier takes them as script parameters, so the statement values are pinned by the script hash. ScriptBudgetEstimator records roughly 4.8 billion CPU steps for the one-input profile.

BbsProofVerify verifies a BBS selective-disclosure presentation natively. It is unrolled for one fixed shape: a 5-message credential disclosing indexes 2 and 3, measured at about 2.44 billion CPU steps and 183,509 memory units. Other shapes need a different unrolling. The off-chain half is BbsToCardano in zeroj-bbs; the BBS guide shows both.