Skip to content

feat(plonk,fflonk): add keccak256-compressed transcript for Cardano/Plutus on-chain verification#625

Open
perturbing wants to merge 8 commits into
iden3:masterfrom
cardano-scaling:perturbing/cardano-compatibility
Open

feat(plonk,fflonk): add keccak256-compressed transcript for Cardano/Plutus on-chain verification#625
perturbing wants to merge 8 commits into
iden3:masterfrom
cardano-scaling:perturbing/cardano-compatibility

Conversation

@perturbing

@perturbing perturbing commented Jun 6, 2026

Copy link
Copy Markdown

Closes #498

Cardano's on-chain BLS12-381 builtins (CIP-0381) operate on compressed G1 points. The existing Keccak256Transcript hashes uncompressed points, so a Plutus verifier cannot replicate the transcript. Keccak-256 itself is now available in Plutus via CIP-0101, so only the point serialisation needs to change.

Also note that this version of SnarkJS has been e2e tested in this repository for all three proof systems (groth16/plonk/fflonk).

Commits

  • feat: add Keccak256CompressedTranscript — same keccak-256 hash, G1 points serialised as 48-byte ZCash/IETF compressed form with sign flags instead of 96-byte uncompressed
  • feat: add transcript option to PLONK prover and verifier — optional options.transcript param; defaults to existing behaviour, fully backward compatible
  • feat: add transcript option to FFLONK prover and verifier — same pattern
  • feat: wire --transcript option to CLI and JS API--transcript=keccak256-compressed flag on plonk/fflonk prove, fullprove and verify; both transcript classes exported from main.js
  • test: add Cardano transcript tests — proves cross-mode isolation: a compressed-transcript proof does not verify under the default transcript and vice versa
  • feat: add Cardano/Plutus proof and VK export commandszkey export cardano-verificationkey compresses all G1/G2 points in the zkey to ZCash/IETF hex; groth16/plonk/fflonk export-cardano-proof does the same for proof.json; both exported from main.js
  • fix: compute FFLONK roots of unity curve-agnosticallygetOmegaCubicRoot and computeW3 both hardcoded BN128-specific field constants, causing "Polynomial is not divisible" on BLS12-381 circuits; replaced with curve-agnostic formulas derived from Fr.p and Fr.w[28]
  • build: rebuild bundles — browser IIFE/ESM bundles updated to include the new transcript class, export modules, and FFLONK fixes

Adds a Fiat-Shamir transcript variant that uses Keccak-256 (same hash as
the default) but serialises G1 polynomial commitments as 48-byte
ZCash/IETF compressed points with sign flags rather than 96-byte
uncompressed points.

Cardano's on-chain BLS12-381 builtins (CIP-0381) operate on compressed
points natively, so the prover and the Plutus verifier must agree on
this serialisation. The Keccak-256 hash itself is now available in
Plutus via CIP-0101, so no hash function change is required.
Threads an optional `options.transcript` parameter through plonkProve()
and plonkVerify().  When set to "keccak256-compressed" the
Keccak256CompressedTranscript is used; the default ("keccak256") retains
the existing behaviour, keeping all existing call sites unaffected.
Same pattern as the PLONK change: optional options.transcript parameter
selects the transcript class. All four Keccak256Transcript instantiations
inside the fflonkProve round closures are replaced with a newTranscript()
helper that captures options from the outer function scope.
Adds --transcript flag to plonk/fflonk prove, fullprove and verify
commands.  Pass --transcript=keccak256-compressed to generate or verify
proofs for Cardano/Plutus on-chain verification.  The default remains
keccak256 for full backward compatibility.

Also exports Keccak256Transcript and Keccak256CompressedTranscript from
the public JS API so downstream libraries can reference them directly.
Verifies that keccak256-compressed proofs verify only under the
matching transcript, and that default (uncompressed) proofs verify
only under the default transcript.  Reuses existing plonk_circuit and
fflonk test fixtures — no new binary artefacts.
@perturbing perturbing force-pushed the perturbing/cardano-compatibility branch from e84d201 to 0c7117a Compare June 7, 2026 08:52
@perturbing perturbing force-pushed the perturbing/cardano-compatibility branch 4 times, most recently from 06be012 to 77aa080 Compare June 7, 2026 10:08
getOmegaCubicRoot hardcoded a BN128-specific constant for the cube root
of Fr.w[28], causing 'Polynomial is not divisible' on BLS12-381 circuits.

Replace with the curve-agnostic formula Fr.w[28]^inv(3 mod 2^28), where
inv(3) mod 2^28 = 178956971. Works on any prime field with a 2^28-order
multiplicative subgroup.
@perturbing perturbing force-pushed the perturbing/cardano-compatibility branch from 77aa080 to ef06a6e Compare June 7, 2026 10:31

@OBrezhniev OBrezhniev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review focused on correctness. I ran the actual curve arithmetic to verify the headline finding rather than rely on reasoning.

Summary:

  • 🔴 1 critical regression: the computeW3 change breaks all FFLONK proving/verification on bn128 (the default curve).
  • 🟠 1 medium: Cardano export commands silently corrupt output for non-BLS12-381 inputs (no curve guard).
  • 🟡 2 low-severity robustness issues.
  • A few cleanup/duplication notes.

The BLS12-381 path and the getOmegaCubicRoot fix both check out correctly — nice work on the cross-mode isolation tests. Inline comments below.

Comment thread src/fflonk_setup.js
return Fr.exp(generator, exponent);
// Curve-agnostic: (r-1)/3 using the actual field modulus.
// Hardcoded divisors (as in the BN128-only original) produce wrong roots on other curves.
const exponent = Scalar.div(Scalar.sub(Fr.p, Scalar.one), Scalar.e(3));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical regression: this returns 1 on bn128, breaking all FFLONK there.

The new exponent (r-1)/3 makes 31624^((r-1)/3) == 1 on bn128, because 31624 is a cubic residue mod the bn128 scalar field. I verified by running both versions on the actual curve:

oldW3 = 21888242871839275217838484774961031246154997185409878258781734729429964517155  (primitive cube root)
newW3 = 1                                                                               ← broken

The old code used exponent (r-1)/18 (orderRsub1 = (r-1)/6, then /3) and produced a genuine primitive cube root. With w3 = 1, roots.w3 = [1,1,1] in fflonk_prove.js/fflonk_verify.js, the S2 evaluation domain degenerates, and every FFLONK proof on bn128 — the primary production curve — fails.

The formula yields a primitive root only when the base is a cubic non-residue, which is curve-dependent (31624 is a non-residue on BLS12-381, so that path happens to work, but a residue on bn128). Fix: keep curve-specific constants, or search for a non-residue base / derive the root from Fr generator powers so it's correct on both curves.

Comment thread src/fflonk_setup.js
@@ -550,9 +547,9 @@ export default async function fflonkSetup(r1csFilename, ptauFilename, zkeyFilena
}

function getOmegaCubicRoot(power, Fr) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the Fr parameter shadows the Fr already in scope (used by computeW3/computeW4 just above without a parameter). It's dead noise and a hazard — a caller passing a different Fr would silently change behavior. Drop the parameter and use the closed-over Fr for consistency with the sibling helpers.

(For reference: I confirmed the new getOmegaCubicRoot formula itself is correct — it reproduces the old hardcoded constant exactly on bn128 and is a valid cube root of Fr.w[28] on BLS12-381.)

export default async function exportCardanoProof(_proof) {
const proof = unstringifyBigInts(_proof);

const curve = await getCurveFromName(proof.curve);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 No curve guard → silently corrupt output for bn128 inputs.

The ZCash flag scheme in compressG1/compressG2 ORs flags into the top 3 bits of byte 0. That's only safe when the field leaves those bits free: BLS12-381's 381-bit field in a 48-byte serialization does, but bn128's 254-bit field in 32 bytes leaves only the top 2 bits free — so OR-ing the 0x20 sign flag into bit 5 overwrites real x-coordinate data. Running export-cardano-proof on a bn128 proof produces silently-malformed points with no error.

Since the Cardano/ZCash compressed format is BLS12-381-specific anyway, add an explicit guard (e.g. reject when curve.name !== "bls12381").


// ---------- compression helpers ----------

function compressG1(curve, point) {

@OBrezhniev OBrezhniev Jun 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: compressG1/compressG2 are duplicated byte-for-byte in src/zkey_export_cardano_verificationkey.js, and the same flag logic appears a third time inline in Keccak256CompressedTranscript.getChallenge. This is security-critical encoding — a fix to the sign/infinity rule would have to be applied in three places. Consider extracting one shared module (e.g. src/point_compress.js).

}

async function groth16CardanoVk(zkey, fd, sections) {
const curve = await getCurve(zkey.q);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Same missing curve guard as export_cardano_proof.js. zkey export cardano-verificationkey is reachable for any zkey. For a bn128 zkey (32-byte field, top bits not all free), the compressG1/compressG2 flag-masking corrupts the x-coordinate of every point, producing a silently-wrong VK. Add a curve.name === "bls12381" check.

// bit 7 (0x80): compression flag — always set for compressed encoding
// bit 6 (0x40): infinity flag
// bit 5 (0x20): sign flag — set when y is the lexicographically larger root
// The top three bits of a valid BLS12-381 field element are always 0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "The top three bits of a valid BLS12-381 field element are always 0" is misleading: ffjavascript's toRprCompressed already writes bit 7 (sign) and bit 6 (infinity) itself — e.g. 2·G serializes with top byte 0x85. The OR still yields the correct ZCash byte (the compression bit is always 1, and you recompute sign/infinity independently), so this isn't a correctness bug and prover/verifier stay symmetric — but the comment is factually wrong and the encoding is fragile. Worth correcting the comment and ideally consuming the library's flags rather than re-deriving them.

Comment thread cli.js
description: "Generates a PLONK Proof from witness",
alias: ["pkp"],
options: "-verbose|v -protocol",
options: "-verbose|v -protocol -transcript",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Low: --transcript is a plain option, so a user who types --transcript without =keccak256-compressed gets options.transcript === true, the === "keccak256-compressed" check fails, and a non-Cardano proof is produced with no warning. Consider validating the option value and erroring on anything unrecognized, so a typo can't silently yield an incompatible proof.

Comment thread cli.js
return 0;
}

async function groth16ExportCardanoProof(params, options) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: groth16ExportCardanoProof, plonkExportCardanoProof, and fflonkExportCardanoProof have identical bodies, and exportCardanoProof already dispatches on proof.protocol internally. The three wrappers add no per-protocol logic — they could collapse to a single shared handler (or one command).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FR: Officially support proof generation for Cardano

2 participants