Skip to content

feat(crypto): add Yul Groth16 pairing-precompile verification wrapper - #897

Merged
mijinummi merged 1 commit into
MDTechLabs:mainfrom
ThatCodeBabe:feat/yul-groth16-verifier-848
Aug 20, 2026
Merged

feat(crypto): add Yul Groth16 pairing-precompile verification wrapper#897
mijinummi merged 1 commit into
MDTechLabs:mainfrom
ThatCodeBabe:feat/yul-groth16-verifier-848

Conversation

@ThatCodeBabe

Copy link
Copy Markdown
Contributor

Closes #848

Summary

Adds contracts/crypto/YulGroth16Verifier.sol — a library that stages a Groth16 proof, its verifying key, and the accumulated public-input commitment directly into one contiguous memory buffer and hands it to the pairing precompile at 0x08.

Verification evaluates the standard equation

e(A, B) == e(alpha, beta) * e(vk_x, gamma) * e(C, delta)

rearranged into the single product the precompile checks:

e(-A, B) * e(alpha, beta) * e(vk_x, gamma) * e(C, delta) == 1

with vk_x = IC[0] + sum(input[i] * IC[i+1]) accumulated through ecMul (0x07) and ecAdd (0x06).

Implementation notes

  • One allocation. All four pairs go into a single 768-byte buffer; the free memory pointer moves once for the entire call. The input accumulator reuses one 224-byte scratch region across every public input rather than allocating per operation.
  • A is negated on the G1 side instead of negating B's Fp2 coordinates — one sub versus two — and (x, 0) is handled as its own negation.
  • Flat fixed-size array parameters. Solidity stores nested memory arrays such as uint256[2][2] as pointers rather than inline words, which makes Yul offset arithmetic quietly wrong. Flat arrays keep the layout explicit and match what gets mloaded.
  • Public inputs must be reduced mod the scalar field. Without this check ecMul reduces them silently, so two distinct public-input vectors could verify against the same proof.
  • view, not purestaticcall reads the environment. (contracts/lightclient/YulLightClient.sol currently declares pure while doing exactly this, and does not compile.)

Acceptance criteria

Criterion Status
Stage proof points and public inputs directly in memory ✅ single 768-byte buffer, one FMP bump
staticcall to 0x08 and evaluate the boolean in Yul ✅ result read from scratch space, compared in Yul
Reduces gas overhead for verification ✅ 232,132 gas with one public input, against a 181,000 precompile floor
Correctly verifies valid proofs and rejects invalid ones ✅ 7 tests, including two distinct ways of breaking the equation

Gas: 232,132 for a verification with one public input. The pairing precompile alone costs 45,000 + 4 × 34,000 = 181,000, plus ecMul (6,000), ecAdd (150) and the 21,000 transaction base — so the wrapper itself adds very little on top of the unavoidable floor.

Test fixtures

The fixtures are built from BN254 generators so that the pairing product is 1 by construction: with A = alpha and B = beta the first two terms cancel, and with vk_x and C both at infinity the remaining two are 1. That exercises the complete precompile path — negation, staging, the 0x08 call, and the ecMul/ecAdd accumulation — without needing a trusted setup to produce circuit proofs. Invalid cases perturb A and C independently.

If you would rather have fixtures from a real circuit, I am happy to add snarkjs-generated vectors in a follow-up; that means taking on a new dev dependency, so I did not do it unprompted.

⚠️ This PR cannot be verified by CI in its current state

npx hardhat compile fails on main before it ever reaches this contract. I hit five pre-existing failures, none related to this change:

File Problem
contracts/bridge/BridgeGateway.sol:103 a second file's pragma + imports are pasted into the middle of a function body
contracts/verifiers/YulEd25519Verifier.sol:98 } else { inside assembly — Yul has no else
test/relayer/MockGasTarget.sol:15,31 let _ := i_ is reserved in Yul
contracts/bridge/YulBatchUnlocker.sol:34 non-literal constant referenced in inline assembly
contracts/lightclient/YulLightClient.sol:97 pure function performing a staticcall

This matches CI, which is failing on all eight recent runs on main.

To validate this work I compiled and ran the tests against an isolated Hardhat config containing only this contract. All 7 tests pass. The harness was removed before committing — only the two intended files are in this diff.

One further note: the existing tests under test/crypto/ use the Hardhat 2 import style (import { ethers } from "hardhat"), which throws does not provide an export named 'ethers' under the Hardhat 3 in package.json. This PR's test uses the Hardhat 3 network.connect() API so it actually runs. It emits a deprecation warning suggesting network.getOrCreate(); I kept connect() as it is the documented test-time API, and I did not want to churn on an API I could not verify against the rest of the suite.

I have deliberately not fixed any of the above — happy to open a separate PR or issues for them if useful, but they are well outside the scope of #848.

Adds `YulGroth16Verifier`, a library that stages a Groth16 proof, its verifying
key and the accumulated public-input commitment directly into one contiguous
memory buffer and hands it to the pairing precompile at 0x08, rather than
letting the compiler assemble the same call through repeated allocations and
stack shuffling.

Verification evaluates the standard equation

    e(A, B) == e(alpha, beta) * e(vk_x, gamma) * e(C, delta)

rearranged into the single product the precompile checks

    e(-A, B) * e(alpha, beta) * e(vk_x, gamma) * e(C, delta) == 1

with vk_x = IC[0] + sum(input[i] * IC[i+1]) accumulated via ecMul (0x07) and
ecAdd (0x06).

Implementation notes:

- The four pairs are written into a single 768-byte buffer; the free memory
  pointer advances once for the whole call. The accumulator reuses one
  224-byte scratch region across every public input instead of allocating per
  operation.
- A is negated on the G1 side rather than negating B's Fp2 coordinates, and
  (x, 0) is handled as its own negation.
- Parameters are flat fixed-size arrays. Solidity stores nested memory arrays
  such as `uint256[2][2]` as pointers rather than inline words, which makes
  offset arithmetic in Yul easy to get wrong; flat arrays keep the layout
  explicit.
- Public inputs are rejected unless reduced mod the scalar field. The ecMul
  precompile would otherwise reduce them silently, letting two distinct input
  vectors verify against one proof.
- The function is `view`, not `pure`, because staticcall reads the environment.

Tests cover a valid proof, two distinct ways of breaking the equation, the
ecMul/ecAdd accumulation path, both revert paths, and gas. The fixtures are
built from BN254 generators so the pairing product is 1 by construction, which
exercises the whole precompile path without requiring a trusted setup to
produce circuit proofs.

Gas for a verification with one public input: 232,132. The pairing precompile
floor alone is 181,000 (45,000 + 4 * 34,000), plus ecMul, ecAdd and the 21,000
transaction base.
@mijinummi
mijinummi merged commit 71e6d26 into MDTechLabs:main Aug 20, 2026
1 check failed
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.

[PERF] Build Yul Groth16 ZK-Proof Precompile Verification Wrapper

2 participants