Skip to content

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

Open
perturbing wants to merge 15 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 15 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.

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