feat(plonk,fflonk): add keccak256-compressed transcript for Cardano/Plutus on-chain verification#625
Conversation
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.
e84d201 to
0c7117a
Compare
06be012 to
77aa080
Compare
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.
77aa080 to
ef06a6e
Compare
OBrezhniev
left a comment
There was a problem hiding this comment.
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
computeW3change 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.
| 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)); |
There was a problem hiding this comment.
🔴 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.
| @@ -550,9 +547,9 @@ export default async function fflonkSetup(r1csFilename, ptauFilename, zkeyFilena | |||
| } | |||
|
|
|||
| function getOmegaCubicRoot(power, Fr) { | |||
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
🟠 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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
🟠 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, |
There was a problem hiding this comment.
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.
| description: "Generates a PLONK Proof from witness", | ||
| alias: ["pkp"], | ||
| options: "-verbose|v -protocol", | ||
| options: "-verbose|v -protocol -transcript", |
There was a problem hiding this comment.
🟡 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.
| return 0; | ||
| } | ||
|
|
||
| async function groth16ExportCardanoProof(params, options) { |
There was a problem hiding this comment.
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).
Closes #498
Cardano's on-chain BLS12-381 builtins (CIP-0381) operate on compressed G1 points. The existing
Keccak256Transcripthashes 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 uncompressedfeat: add transcript option to PLONK prover and verifier— optionaloptions.transcriptparam; defaults to existing behaviour, fully backward compatiblefeat: add transcript option to FFLONK prover and verifier— same patternfeat: wire --transcript option to CLI and JS API—--transcript=keccak256-compressedflag on plonk/fflonk prove, fullprove and verify; both transcript classes exported frommain.jstest: add Cardano transcript tests— proves cross-mode isolation: a compressed-transcript proof does not verify under the default transcript and vice versafeat: add Cardano/Plutus proof and VK export commands—zkey export cardano-verificationkeycompresses all G1/G2 points in the zkey to ZCash/IETF hex;groth16/plonk/fflonk export-cardano-proofdoes the same for proof.json; both exported frommain.jsfix: compute FFLONK roots of unity curve-agnostically—getOmegaCubicRootandcomputeW3both hardcoded BN128-specific field constants, causing "Polynomial is not divisible" on BLS12-381 circuits; replaced with curve-agnostic formulas derived fromFr.pandFr.w[28]build: rebuild bundles— browser IIFE/ESM bundles updated to include the new transcript class, export modules, and FFLONK fixes