Skip to content

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

Open
perturbing wants to merge 26 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 26 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 Outdated
Comment thread src/fflonk_setup.js Outdated
Comment thread src/export_cardano_proof.js
Comment thread src/export_cardano_proof.js Outdated
Comment thread src/zkey_export_cardano_verificationkey.js Outdated
Comment thread src/Keccak256CompressedTranscript.js Outdated
Comment thread cli.js
Comment thread cli.js Outdated
@perturbing

Copy link
Copy Markdown
Author

Thank you for the review @OBrezhniev , the code is much cleaner now 👍

Let me know if you want anything else done to this PR.

@perturbing
perturbing requested a review from OBrezhniev July 21, 2026 10:26

@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.

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 via G1.neg plus two toAffine/toObject round-trips (three field inversions) to compare y >= 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: at y = 0, y >= -y sets the sign flag but the spec's y > (p−1)/2 does not. Unreachable in a prime-order subgroup, so this is cleanliness rather than a bug — but the simpler form sidesteps the question. Same for compressG2: yc1 > half || (yc1 === 0n && yc0 > half).

  • Transcript duplication. Keccak256CompressedTranscript is a near-verbatim copy of Keccak256Transcript; 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.

  • computeW3 fallthrough (src/fflonk_setup.js:534). The bls12381 branch derives its exponent from Fr.p, but the fallback retains bn128's hardcoded orderRsub1, so a third curve would silently get a wrong w3 rather than an error. Since your comment correctly notes these values get baked into deployed verifiers, an explicit else throw for unrecognised curves seems safer than an implicit bn128 default.

  • getOmegaCubicRoot coupling. 178956971n is inv(3) mod 2^28 and is only valid alongside the literal 28 on the following line. The comment explains the arithmetic well; deriving it, or asserting Fr.eq(Fr.exp(firstRoot, 3n), Fr.w[28]), would stop the pair being edited apart.

  • Export output isn't self-describing. exportCardanoProof drops protocol and curve (output keys are just pi_a, pi_b, pi_c), while the VK export keeps both. Retaining them would make cardano_proof.json diagnosable and consistent with its sibling.

  • Docs. No README coverage for --transcript or the four new commands. This is user-facing CLI surface in a README-driven project, so they should be listed there.

  • Minor. plonkCardanoVk/fflonkCardanoVk are async with no await; g1FromObj/g2FromObj are single-expression wrappers used once or twice; the new files carry Copyright 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.

@perturbing

Copy link
Copy Markdown
Author

@OBrezhniev thank you again for the review. I appreciate your time to get this merged.

All review items addressed at 3d5e2fe (11 commits on top of e36bd30). Overview:

# Review item Resolution Commit
B1 --transcript breaks every PLONK/FFLONK CLI command when the flag is absent (null from the option layer) createTranscript now treats null like undefined (default transcript); {transcript: true} still throws. Added the suggested CLI-level smoke test (test/cli.test.js): spawns plonk provezkey export verificationkeyplonk verify with no flag, asserting exit 0 — fails at e36bd30, passes now. e0425ea
B2a No bls12381 coverage: unit-test compressG1/compressG2 against IETF generator vectors Added exactly the suggested vector assertions for both groups, plus infinity encodings (0xc0 + zeros) and "neg(P) flips exactly bit 5" tests. cbf7e7a
B2b Compressed transcript silently corrupts points on bn128; suite tests only bn128 Guard added in the Keccak256CompressedTranscript constructor (covers all six CLI call sites and direct class use). Deliberately name-matched (curve.name !== "bls12381") rather than derived: the ZCash encoding is specified for BLS12-381, so a future curve with 3 free bits shouldn't silently opt in — the free-bits rationale lives in the comment. Belt-and-braces: compressG1/compressG2 themselves now throw when the base field lacks 3 free high bits, so a caller that forgets the guard can't corrupt points. The bn128 compressed prove/verify tests are replaced with rejection tests, and the transcript is now pinned on bls12381 at the unit level: same inputs hash differently under the two variants, and the compressed challenge equals keccak256(compressG1(P) ‖ be(scalar)) byte-for-byte. b066503
N1 Sign derivation: single (p−1)/2 comparison instead of neg + double round-trip; > not >= at y = 0 Adopted as suggested for both G1 and G2. Re-verified byte-identical against the old implementation: 500 random scalars × {G1, G2} × {P, −P} + generators + infinity, 0 mismatches, both parities sampled. 85a52d8
N2 Transcript duplication Keccak256Transcript exposes overridable pointSize()/writePoint(); the compressed variant is now extends with just the guard and those two overrides. Scalar encoding and hashing are shared and can't drift. 9243c16
N3 computeW3 fallthrough would hand bn128 constants to a third curve bn128 is now an explicit branch; unrecognised curves throw. The historical bn128 w3 constant remains pinned by test. 0aae369
N4 getOmegaCubicRoot: 178956971n28 coupling Adopted the suggested assertion: setup now verifies firstRoot³ = Fr.w[28] and throws otherwise. d0d4197
N5 exportCardanoProof output not self-describing All three converters now emit protocol and curve, matching the VK export. Added a positive-path test: a synthetic bls12381 groth16 proof built from generator points exports to exactly the IETF vectors. 75df902
N6 Minor: unused async, single-use wrappers, Copyright 2022 async dropped from plonkCardanoVk/fflonkCardanoVk, g1FromObj/g2FromObj inlined, all five new files now say Copyright 2026 iden3 association. 8045204
N7 Docs: no README coverage for --transcript or the four commands --transcript documented in the prove (§23) and verify (§24) sections; new §27 covers the four Cardano export commands, including an explicit scope statement: bls12381 only — bn128 deliberately has no compressed form since its on-chain consumers (EIP-196 precompiles) take uncompressed points. 901c320
Bundles Rebuilt; still byte-identical to a fresh npm run build (verified deterministic across two builds). 3d5e2fe

Two notes from the rework:

  • test/plonk_circuit/proof.json turns out to be a stale fixture — it doesn't verify against verification_key.json even on upstream master, independent of this PR. The CLI smoke test therefore generates its own proof from circuit.zkey + witness.wtns rather than using the repro's fixture files.
  • Suite is now 77 passing (was 66), all PR-touched files eslint-clean.

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