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.
|
Thank you for the review @OBrezhniev , the code is much cleaner now 👍 Let me know if you want anything else done to this PR. |
OBrezhniev
left a comment
There was a problem hiding this comment.
Reviewed at e36bd30. Thanks for this — the design is well-judged (opt-in, default-preserving) and the hard parts are correct. I verified the cryptographic core by running it rather than only reading it, and it holds up:
| Check | Result |
|---|---|
compressG1/compressG2 vs. official IETF pairing-friendly-curves generator vectors |
✅ exact match, both groups |
| Differential test vs. an independent from-scratch ZCash encoder, 200 points | ✅ 0 mismatches; both sign parities hit; neg(P) flips exactly bit 5 |
Point-at-infinity encodings (0xc0 + zeros) |
✅ correct for G1 and G2 |
getOmegaCubicRoot refactor vs. the removed hardcoded bn128 constant |
✅ bit-identical at every power — no deployed-verifier breakage |
bls12381 computeW3 primitive-cube-root property + documented constant |
✅ w3³=1, w3≠1, equals 228988810152649578064853576960394133503 |
exportCardanoProof end-to-end on a bls12381 groth16 proof |
✅ 48/96-byte outputs matching IETF vectors |
npm test / test/cardano.test.js / npx eslint |
✅ 66 passing / 17 passing / clean |
Committed build/ bundles vs. fresh npm run build |
✅ byte-identical |
Special credit for the getOmegaCubicRoot change being provably backward compatible, and for the bundles actually being in sync — both are easy to get wrong.
Two blocking issues, both in the plumbing added in the most recent commits rather than in the crypto.
1. --transcript breaks every PLONK/FFLONK CLI command when the flag is absent
src/clprocessor.js:177 returns null, not undefined, for an option that is declared but not supplied. createTranscript (src/transcript.js:29) only special-cases undefined, so null falls through to the throw:
$ node cli.js plonk verify test/plonk_circuit/verification_key.json \
test/plonk_circuit/public.json test/plonk_circuit/proof.json
[INFO] snarkJS: PLONK VERIFIER STARTED
[ERROR] snarkJS: Error: Unknown transcript type 'null'. Valid values are "keccak256" (default) and "keccak256-compressed"
at createTranscript (src/transcript.js:35:11)
at calculatechallenges (src/plonk_verify.js:211:24)I reproduced this on plonk prove, plonk verify, fflonk prove and fflonk verify; plonk fullprove and fflonk fullprove reach it through the same six call sites (cli.js:1169, 1191, 1213, 1242, 1265, 1285). Passing --transcript=keccak256 explicitly works fine — so the default path, which is what every existing user runs, is the only broken one. 3db7366 introduced it; the commit before it was fine.
One-line fix in src/transcript.js:29:
if (name === undefined || name === null || "keccak256" === name) {{transcript: true} still throws, so the existing rejection test keeps passing.
The current tests can't catch this: they either omit options entirely or pass an explicit string, and the null only ever originates in the CLI layer, which has no coverage. Given this seam has now produced a bug, a CLI-level smoke test (plonk prove with no flag) would be worth adding.
2. The compressed transcript silently corrupts points on bn128 — the only curve it's tested on
The ZCash layout needs three free high bits in byte 0. Measured:
| curve | Fq bits | encoding bits | free |
|---|---|---|---|
| bls12381 | 381 | 384 | 3 ✅ |
| bn128 | 254 | 256 | 2 ❌ |
compressG1 masks with 0b00011111 (src/point_compress.js:44), so on bn128 it destroys bit 253 of x — set in 63/200 sampled G1 x-coordinates. The encoding is therefore not injective, and a collision is trivial to exhibit:
P.x = 1
Q.x = 14474011154664524427946373126085988481658748083205070504932198000989141204993 (= P.x + 2^253)
both on curve: true P == Q: false
compress(P) = 8000...0001
compress(Q) = 8000...0001 <-- identical
Two distinct commitments collapsing to one transcript contribution defeats the binding property Fiat-Shamir depends on. I'm not claiming a practical attack — a prover would need two openable commitments differing by exactly 2^253 — but there's no reason to permit it, and it's an interop hazard independently: a correct third-party implementation of this transcript would disagree with snarkjs on bn128.
The two export commands already guard this properly (only bls12381 ..., with tests for both). The transcript doesn't. Suggest the same guard in createTranscript, ideally derived rather than name-matched so it stays correct if a curve is ever added:
// ZCash flags occupy the three high bits of byte 0; only safe when the base
// field leaves them free (bls12381: 381 bits in a 384-bit encoding).
if (curve.G1.F.n8 * 8 - curve.F1.p.toString(2).length < 3) {
throw new Error(`keccak256-compressed transcript requires a curve with 3 free high bits, '${curve.name}' has fewer`);
}Related, and the part I'd most like addressed: test/cardano.test.js:33 runs the whole compressed-transcript suite on bn128 — the affected curve. Those tests pass only because prover and verifier are consistently wrong together, so there is currently no automated coverage of the compressed transcript on bls12381, the one curve it targets. I appreciate that a bls12381 ptau fixture is a real cost and that you've e2e-tested externally in cardano-scaling/snarkjs-circom-aiken. But as written the suite wouldn't notice a bls12381 encoding regression. The cheap version needs no ceremony file at all — unit-test compressG1/compressG2 against the IETF generator vectors:
// draft-irtf-cfrg-pairing-friendly-curves
assert.strictEqual(hex(compressG1(G1, G1.g)),
"97f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb");
assert.strictEqual(hex(compressG2(G2, G2.g)),
"93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e"
+ "024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8");That pins the byte order (including Fq2 c1-before-c0), the flag layout and the sign convention in one assertion per group. Both pass against your implementation today.
Non-blocking suggestions
-
src/point_compress.js:41-44— simpler, and removes an unreachable-but-wrong edge. The sign is derived viaG1.negplus twotoAffine/toObjectround-trips (three field inversions) to comparey >= yNeg. The standard formulation is a single comparison against(p−1)/2, which I verified gives byte-identical output on both generators and all 200 test points:const [, y] = G1.toObject(G1.toAffine(point)); const flags = y > (G1.F.p - 1n) / 2n ? 0b10100000 : 0b10000000;
Note also that
>=is subtly wrong where>is right: aty = 0,y >= -ysets the sign flag but the spec'sy > (p−1)/2does not. Unreachable in a prime-order subgroup, so this is cleanliness rather than a bug — but the simpler form sidesteps the question. Same forcompressG2:yc1 > half || (yc1 === 0n && yc0 > half). -
Transcript duplication.
Keccak256CompressedTranscriptis a near-verbatim copy ofKeccak256Transcript; only the point serialiser and buffer stride differ. A shared base with an overridable point writer would stop the two drifting — they have to agree on scalar encoding forever. -
computeW3fallthrough (src/fflonk_setup.js:534). Thebls12381branch derives its exponent fromFr.p, but the fallback retains bn128's hardcodedorderRsub1, so a third curve would silently get a wrongw3rather than an error. Since your comment correctly notes these values get baked into deployed verifiers, an explicitelse throwfor unrecognised curves seems safer than an implicit bn128 default. -
getOmegaCubicRootcoupling.178956971nisinv(3) mod 2^28and is only valid alongside the literal28on the following line. The comment explains the arithmetic well; deriving it, or assertingFr.eq(Fr.exp(firstRoot, 3n), Fr.w[28]), would stop the pair being edited apart. -
Export output isn't self-describing.
exportCardanoProofdropsprotocolandcurve(output keys are justpi_a, pi_b, pi_c), while the VK export keeps both. Retaining them would makecardano_proof.jsondiagnosable and consistent with its sibling. -
Docs. No README coverage for
--transcriptor the four new commands. This is user-facing CLI surface in a README-driven project, so they should be listed there. -
Minor.
plonkCardanoVk/fflonkCardanoVkareasyncwith noawait;g1FromObj/g2FromObjare single-expression wrappers used once or twice; the new files carryCopyright 2022.
Requesting changes on #1 alone — plonk/fflonk prove and verify are unusable from the CLI without an explicit flag, which is release-blocking with a one-line fix. I'd like #2 to land with it, since the suite currently validates this feature exclusively on a curve where the encoding isn't injective. Everything under non-blocking is yours to take or leave.
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