zkvm: a point at infinity is not a point this verifier will read

Three of the four verifier failures were one mistake: BigInt(nil) assigns
through a nil receiver. msmCPU panicked on every call, the Groth16 linear
combination on every proof carrying a public input, and PLONK unconditionally.

Removing the PLONK panic uncovered what it had been hiding. gnark decodes
all-zero bytes to the point at infinity without error, reports it as in the
prime-order subgroup, and a pairing check drops every term whose argument is
infinity — so an element at infinity removes itself from the equation, and a
check with every term removed is a product of nothing, which is one, which is
true. loadVerifyingKeys installs an all-zero placeholder for each circuit an
operator did not key. So on a chain keyed for transfers, a shield proof was
judged against zeros and every one of them verified. Mint from nothing.

checkG1 and checkG2 are now the one place that decides what a usable point is —
in the subgroup and not the identity — and every decoder routes through them, so
a decoded proof carries the invariant instead of each caller re-deriving it. The
precompile had the same hole from the other direction: it takes its verifying
key from calldata, so the caller chose it, and a degenerate key made Run answer
0x01.

The witness bound was off by one. K holds a point per public input plus the
constant term, so it covers len(K)-1 inputs, and a peer-chosen count of exactly
len(K) read one past the end — a remote kill from a well-formed message. It was
off by one in two places; the second was a duplicate of the whole pairing check
on a GPU path that also overwrote the CPU's linear combination with an unverified
GPU result. A GPU returning a wrong point flips a verdict on the nodes that have
one, which is a fork rather than a slow node, so that copy is gone and one
deterministic check serves both the single and batch paths.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Hanzo AI
2026-08-25 12:37:40 -07:00
parent fea1038479
commit 9dc59df3bf
5 changed files with 494 additions and 169 deletions
+7 -79
View File
@@ -6,6 +6,7 @@ package zkvm
import (
"errors"
"fmt"
"math/big"
"github.com/consensys/gnark-crypto/ecc/bn254"
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
@@ -108,9 +109,10 @@ func msmWithSession(session *accel.Session, scalars []fr.Element, bases []bn254.
// msmCPU computes MSM sequentially on CPU.
func msmCPU(scalars []fr.Element, bases []bn254.G1Affine) bn254.G1Affine {
var result bn254.G1Affine
for i, s := range scalars {
var scalar big.Int
for i := range scalars {
var term bn254.G1Affine
term.ScalarMultiplication(&bases[i], s.BigInt(nil))
term.ScalarMultiplication(&bases[i], scalars[i].BigInt(&scalar))
result.Add(&result, &term)
}
return result
@@ -175,16 +177,6 @@ func batchVerifyProofsGPU(pv *ProofVerifier, txs []*Transaction) []error {
continue
}
// Subgroup checks on proof points
if !grothProof.Ar.IsInSubGroup() || !grothProof.Krs.IsInSubGroup() {
results[i] = errors.New("zkvm: Groth16 proof G1 point not in prime-order subgroup")
continue
}
if !grothProof.Bs.IsInSubGroup() {
results[i] = errors.New("zkvm: Groth16 proof G2 point not in prime-order subgroup")
continue
}
vk, err := deserializeVerifyingKey(vkBytes)
if err != nil {
results[i] = fmt.Errorf("deserialize vk: %w", err)
@@ -206,78 +198,14 @@ func batchVerifyProofsGPU(pv *ProofVerifier, txs []*Transaction) []error {
batch = append(batch, batchEntry{index: i, proof: grothProof, vk: vk, wit: witness})
}
// If no GPU or only 1 proof, verify sequentially
if len(batch) <= 1 || !accel.Available() {
for _, e := range batch {
results[e.index] = verifyGroth16Pairing(e.proof, e.vk, e.wit)
}
return results
}
// GPU batch path: accelerate MSM per proof, verify pairings
// One pairing check serves both doors: a block of one transaction and a
// block of many must reach the same verdict on the same proof.
for _, e := range batch {
results[e.index] = verifyGroth16PairingGPU(e.proof, e.vk, e.wit, pv.log)
results[e.index] = verifyGroth16Pairing(e.proof, e.vk, e.wit)
}
return results
}
// verifyGroth16PairingGPU is identical to verifyGroth16Pairing but uses GPU MSM
// for the public input linear combination step.
func verifyGroth16PairingGPU(proof *Groth16Proof, vk *Groth16VerifyingKey, witness []fr.Element, logger log.Logger) error {
if len(witness) > len(vk.K) {
return errors.New("too many public inputs")
}
// GPU-accelerated MSM for public input LC: K[0] + sum(witness_i * K[i+1])
// Build scalars=[1, w0, w1, ...] and bases=[K[0], K[1], K[2], ...]
scalars := make([]fr.Element, len(witness)+1)
bases := make([]bn254.G1Affine, len(witness)+1)
scalars[0].SetOne()
bases[0].Set(&vk.K[0])
for i, w := range witness {
scalars[i+1].Set(&w)
bases[i+1].Set(&vk.K[i+1])
}
publicInputLC, err := msmGPU(scalars, bases, logger)
if err != nil {
return fmt.Errorf("GPU MSM failed: %w", err)
}
// Pairing check (same as CPU path)
leftSide, err := bn254.Pair([]bn254.G1Affine{proof.Ar}, []bn254.G2Affine{proof.Bs})
if err != nil {
return fmt.Errorf("pairing A*B failed: %w", err)
}
alphaBeta, err := bn254.Pair([]bn254.G1Affine{vk.Alpha}, []bn254.G2Affine{vk.Beta})
if err != nil {
return fmt.Errorf("pairing alpha*beta failed: %w", err)
}
pubGamma, err := bn254.Pair([]bn254.G1Affine{publicInputLC}, []bn254.G2Affine{vk.Gamma})
if err != nil {
return fmt.Errorf("pairing pubInput*gamma failed: %w", err)
}
cDelta, err := bn254.Pair([]bn254.G1Affine{proof.Krs}, []bn254.G2Affine{vk.Delta})
if err != nil {
return fmt.Errorf("pairing C*delta failed: %w", err)
}
var rightSide bn254.GT
rightSide.Set(&alphaBeta)
rightSide.Mul(&rightSide, &pubGamma)
rightSide.Mul(&rightSide, &cDelta)
if !leftSide.Equal(&rightSide) {
return errors.New("pairing check failed: proof is invalid")
}
return nil
}
// poseidonHashGPU computes Poseidon hash of inputs using GPU acceleration.
// Falls back to SHA-256 if GPU is unavailable.
func poseidonHashGPU(inputs [][]byte) ([]byte, error) {
+43 -16
View File
@@ -40,6 +40,7 @@ var (
errInputTooShort = errors.New("input too short")
errInvalidPoint = errors.New("invalid elliptic curve point")
errSubgroupCheck = errors.New("point not in correct subgroup")
errPointAtInfinity = errors.New("point at infinity")
errPairingFailed = errors.New("pairing computation failed")
errProofInvalid = errors.New("proof verification failed")
errNotImplemented = errors.New("verifier not yet available")
@@ -321,6 +322,32 @@ type groth16VK struct {
K []bn254.G1Affine
}
// A pairing drops every term whose argument is the point at infinity, so a
// key or proof element at infinity removes itself from the equation: a key of
// all-infinity points turns the check into 1 == 1 and reports any proof valid.
// gnark decodes all-zero bytes to infinity and calls it in-subgroup, so the
// subgroup test alone does not catch it. checkG1 and checkG2 are the one place
// that decides what a usable point is.
func checkG1(p *bn254.G1Affine) error {
if !p.IsInSubGroup() {
return errSubgroupCheck
}
if p.IsInfinity() {
return errPointAtInfinity
}
return nil
}
func checkG2(p *bn254.G2Affine) error {
if !p.IsInSubGroup() {
return errSubgroupCheck
}
if p.IsInfinity() {
return errPointAtInfinity
}
return nil
}
func parseGroth16Proof(data []byte) (*groth16Proof, error) {
if len(data) < 256 {
return nil, errInputTooShort
@@ -329,20 +356,20 @@ func parseGroth16Proof(data []byte) (*groth16Proof, error) {
if err := p.Ar.Unmarshal(data[0:64]); err != nil {
return nil, fmt.Errorf("Ar: %w", err)
}
if !p.Ar.IsInSubGroup() {
return nil, errSubgroupCheck
if err := checkG1(&p.Ar); err != nil {
return nil, fmt.Errorf("Ar: %w", err)
}
if err := p.Bs.Unmarshal(data[64:192]); err != nil {
return nil, fmt.Errorf("Bs: %w", err)
}
if !p.Bs.IsInSubGroup() {
return nil, errSubgroupCheck
if err := checkG2(&p.Bs); err != nil {
return nil, fmt.Errorf("Bs: %w", err)
}
if err := p.Krs.Unmarshal(data[192:256]); err != nil {
return nil, fmt.Errorf("Krs: %w", err)
}
if !p.Krs.IsInSubGroup() {
return nil, errSubgroupCheck
if err := checkG1(&p.Krs); err != nil {
return nil, fmt.Errorf("Krs: %w", err)
}
return p, nil
}
@@ -359,32 +386,32 @@ func parseVerifyingKey(data []byte) (*groth16VK, error) {
if err := vk.Alpha.Unmarshal(data[off : off+64]); err != nil {
return nil, fmt.Errorf("Alpha: %w", err)
}
if !vk.Alpha.IsInSubGroup() {
return nil, fmt.Errorf("Alpha: %w", errSubgroupCheck)
if err := checkG1(&vk.Alpha); err != nil {
return nil, fmt.Errorf("Alpha: %w", err)
}
off += 64
if err := vk.Beta.Unmarshal(data[off : off+128]); err != nil {
return nil, fmt.Errorf("Beta: %w", err)
}
if !vk.Beta.IsInSubGroup() {
return nil, fmt.Errorf("Beta: %w", errSubgroupCheck)
if err := checkG2(&vk.Beta); err != nil {
return nil, fmt.Errorf("Beta: %w", err)
}
off += 128
if err := vk.Gamma.Unmarshal(data[off : off+128]); err != nil {
return nil, fmt.Errorf("Gamma: %w", err)
}
if !vk.Gamma.IsInSubGroup() {
return nil, fmt.Errorf("Gamma: %w", errSubgroupCheck)
if err := checkG2(&vk.Gamma); err != nil {
return nil, fmt.Errorf("Gamma: %w", err)
}
off += 128
if err := vk.Delta.Unmarshal(data[off : off+128]); err != nil {
return nil, fmt.Errorf("Delta: %w", err)
}
if !vk.Delta.IsInSubGroup() {
return nil, fmt.Errorf("Delta: %w", errSubgroupCheck)
if err := checkG2(&vk.Delta); err != nil {
return nil, fmt.Errorf("Delta: %w", err)
}
off += 128
@@ -400,8 +427,8 @@ func parseVerifyingKey(data []byte) (*groth16VK, error) {
if err := vk.K[i].Unmarshal(data[off : off+64]); err != nil {
return nil, fmt.Errorf("K[%d]: %w", i, err)
}
if !vk.K[i].IsInSubGroup() {
return nil, fmt.Errorf("K[%d]: %w", i, errSubgroupCheck)
if err := checkG1(&vk.K[i]); err != nil {
return nil, fmt.Errorf("K[%d]: %w", i, err)
}
off += 64
}
+38
View File
@@ -182,6 +182,44 @@ func TestGroth16VerifierRejectsInvalidProof(t *testing.T) {
}
}
// TestGroth16VerifierRefusesADegenerateKey. The verifying key arrives in the
// precompile's own input, so a caller picks it. A pairing skips every term
// whose argument is the point at infinity, and gnark decodes all-zero bytes to
// infinity and calls it in-subgroup — so a key of infinity points leaves the
// multi-pairing empty, which equals one, which reads as "valid" for a proof
// nobody checked. Such a key must be refused at parse.
func TestGroth16VerifierRefusesADegenerateKey(t *testing.T) {
_, _, g1, g2 := bn254.Generators()
// Alpha, Beta, Gamma, Delta at infinity; K holds real points so the
// public-input bound does not fire first.
vk := &groth16VK{K: []bn254.G1Affine{g1, g1}}
var w fr.Element
w.SetRandom()
// Ar at infinity makes the one surviving term vanish as well.
proof := &groth16Proof{Bs: g2, Krs: g1}
input := serializeGroth16Input(vk, proof, []fr.Element{w})
v := &Groth16Verifier{}
res, err := v.Run(input)
if err == nil && len(res) == 1 && res[0] == 0x01 {
t.Fatal("Groth16Verifier accepted a proof against a key of infinity points")
}
if len(res) != 1 || res[0] != 0x00 {
t.Fatalf("expected 0x00 for a degenerate key, got res=%x err=%v", res, err)
}
if _, err := parseVerifyingKey(input[4 : 4+binary.BigEndian.Uint32(input[:4])]); err == nil {
t.Fatal("parseVerifyingKey accepted a key of infinity points")
}
if _, err := parseGroth16Proof(make([]byte, 256)); err == nil {
t.Fatal("parseGroth16Proof accepted a proof of infinity points")
}
}
func TestPLONKVerifierGas(t *testing.T) {
v := &PLONKVerifier{}
if g := v.RequiredGas(nil); g != plonkGas {
+94 -74
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"math/big"
"sync"
"github.com/luxfi/accel"
@@ -476,14 +477,6 @@ func (pv *ProofVerifier) verifyGroth16WithGnark(proof *ZKProof, vkBytes []byte)
return fmt.Errorf("failed to deserialize proof: %w", err)
}
// Subgroup checks on proof points — prevents small-subgroup attacks
if !grothProof.Ar.IsInSubGroup() || !grothProof.Krs.IsInSubGroup() {
return errors.New("zkvm: Groth16 proof G1 point not in prime-order subgroup")
}
if !grothProof.Bs.IsInSubGroup() {
return errors.New("zkvm: Groth16 proof G2 point not in prime-order subgroup")
}
// Deserialize public witness (public inputs)
witness := make([]fr.Element, 0, len(proof.PublicInputs))
for _, inputBytes := range proof.PublicInputs {
@@ -502,38 +495,28 @@ func (pv *ProofVerifier) verifyGroth16WithGnark(proof *ZKProof, vkBytes []byte)
// verifyGroth16Pairing performs the Groth16 pairing check
// Verifies: e(A, B) = e(alpha, beta) * e(sum(pubInput_i * K_i), gamma) * e(C, delta)
// Uses GPU MSM for the public input linear combination when available.
//
// K carries one point per public input plus the constant term K[0], so a key
// of n points speaks about n-1 public inputs. The witness length arrives with
// the transaction and a peer chooses it, so it is bounded against the key
// before any K point is read.
func verifyGroth16Pairing(proof *Groth16Proof, vk *Groth16VerifyingKey, witness []fr.Element) error {
if len(witness) > len(vk.K) {
return errors.New("too many public inputs")
if len(witness)+1 > len(vk.K) {
return fmt.Errorf("public inputs: %d supplied, verifying key holds %d K points",
len(witness), len(vk.K))
}
// Compute public input linear combination: K[0] + sum(witness_i * K[i+1])
// GPU MSM path when available and enough inputs to justify overhead
// Public input linear combination: K[0] + sum(witness_i * K[i+1]).
// Every node has to reach the same point from the same key and witness,
// so the sum stays on the CPU rather than being handed to an accelerator
// whose answer is never compared against it.
var publicInputLC bn254.G1Affine
if accel.Available() && len(witness) > 2 {
scalars := make([]fr.Element, len(witness)+1)
bases := make([]bn254.G1Affine, len(witness)+1)
scalars[0].SetOne()
bases[0].Set(&vk.K[0])
for i, w := range witness {
scalars[i+1].Set(&w)
bases[i+1].Set(&vk.K[i+1])
}
publicInputLC = msmCPU(scalars, bases) // msmGPU needs logger; use CPU MSM helper
// For inline GPU without logger, try session directly
if session, err := accel.DefaultSession(); err == nil {
if r, err := msmWithSession(session, scalars, bases); err == nil {
publicInputLC = r
}
}
} else {
publicInputLC.Set(&vk.K[0])
for i, w := range witness {
var term bn254.G1Affine
term.ScalarMultiplication(&vk.K[i+1], w.BigInt(nil))
publicInputLC.Add(&publicInputLC, &term)
}
publicInputLC.Set(&vk.K[0])
var scalar big.Int
for i := range witness {
var term bn254.G1Affine
term.ScalarMultiplication(&vk.K[i+1], witness[i].BigInt(&scalar))
publicInputLC.Add(&publicInputLC, &term)
}
// Pairing check: e(A, B) == e(alpha, beta) * e(publicInputLC, gamma) * e(C, delta)
@@ -569,40 +552,67 @@ func verifyGroth16Pairing(proof *Groth16Proof, vk *Groth16VerifyingKey, witness
return nil
}
// validateVerifyingKey performs subgroup checks on verifying key elliptic curve points
// This is CRITICAL for trusted setup validation - ensures points are in correct subgroup
// A point this verifier reads has to be in the prime-order subgroup and must
// not be the point at infinity. gnark encodes infinity as all-zero bytes and
// reports it as in-subgroup, and a pairing drops any term whose argument is
// infinity — so an element at infinity silently removes itself from the
// equation and leaves a weaker check than the one written down. checkG1 and
// checkG2 are the one place that decides what a usable point is; every
// decoder below routes through them.
var (
errOffSubgroup = errors.New("point not in the prime-order subgroup")
errAtInfinity = errors.New("point at infinity")
)
func checkG1(p *bn254.G1Affine) error {
if !p.IsInSubGroup() {
return errOffSubgroup
}
if p.IsInfinity() {
return errAtInfinity
}
return nil
}
func checkG2(p *bn254.G2Affine) error {
if !p.IsInSubGroup() {
return errOffSubgroup
}
if p.IsInfinity() {
return errAtInfinity
}
return nil
}
// validateVerifyingKey checks every point of a Groth16 verifying key.
// A trusted setup never produces infinity for alpha, beta, gamma, delta or K,
// so a key that carries one is not a setup output and its pairing equation
// would collapse to something weaker.
func validateVerifyingKey(vk *Groth16VerifyingKey) error {
// Validate Alpha is in G1 subgroup
if !vk.Alpha.IsInSubGroup() {
return errors.New("Alpha point not in G1 subgroup")
if err := checkG1(&vk.Alpha); err != nil {
return fmt.Errorf("Alpha: %w", err)
}
// Validate Beta is in G2 subgroup
if !vk.Beta.IsInSubGroup() {
return errors.New("Beta point not in G2 subgroup")
if err := checkG2(&vk.Beta); err != nil {
return fmt.Errorf("Beta: %w", err)
}
// Validate Gamma is in G2 subgroup
if !vk.Gamma.IsInSubGroup() {
return errors.New("Gamma point not in G2 subgroup")
if err := checkG2(&vk.Gamma); err != nil {
return fmt.Errorf("Gamma: %w", err)
}
// Validate Delta is in G2 subgroup
if !vk.Delta.IsInSubGroup() {
return errors.New("Delta point not in G2 subgroup")
if err := checkG2(&vk.Delta); err != nil {
return fmt.Errorf("Delta: %w", err)
}
// Validate all K points are in G1 subgroup
for i := range vk.K {
if !vk.K[i].IsInSubGroup() {
return fmt.Errorf("K[%d] point not in G1 subgroup", i)
if err := checkG1(&vk.K[i]); err != nil {
return fmt.Errorf("K[%d]: %w", i, err)
}
}
return nil
}
// deserializeGroth16Proof deserializes a Groth16 proof from bytes
// deserializeGroth16Proof deserializes a Groth16 proof from bytes.
// A proof it returns has already passed the point checks, so callers never
// repeat them.
func deserializeGroth16Proof(data []byte) (*Groth16Proof, error) {
// Expected format: Ar (64 bytes) | Bs (128 bytes) | Krs (64 bytes) = 256 bytes
if len(data) < 256 {
@@ -616,18 +626,27 @@ func deserializeGroth16Proof(data []byte) (*Groth16Proof, error) {
if err := proof.Ar.Unmarshal(data[offset : offset+64]); err != nil {
return nil, fmt.Errorf("failed to unmarshal Ar: %w", err)
}
if err := checkG1(&proof.Ar); err != nil {
return nil, fmt.Errorf("Ar: %w", err)
}
offset += 64
// Deserialize Bs (G2 point, 128 bytes compressed)
if err := proof.Bs.Unmarshal(data[offset : offset+128]); err != nil {
return nil, fmt.Errorf("failed to unmarshal Bs: %w", err)
}
if err := checkG2(&proof.Bs); err != nil {
return nil, fmt.Errorf("Bs: %w", err)
}
offset += 128
// Deserialize Krs (G1 point, 64 bytes compressed)
if err := proof.Krs.Unmarshal(data[offset : offset+64]); err != nil {
return nil, fmt.Errorf("failed to unmarshal Krs: %w", err)
}
if err := checkG1(&proof.Krs); err != nil {
return nil, fmt.Errorf("Krs: %w", err)
}
return proof, nil
}
@@ -836,9 +855,10 @@ func verifyPLONKPairing(proof *PLONKProof, vk *PLONKVerifyingKey, publicInputs [
u.SetBytes(uBytes[:32])
// Compute: [W_z]_1 + u·[W_{zw}]_1
var scalar big.Int
var leftG1 bn254.G1Affine
var uWzw bn254.G1Affine
uWzw.ScalarMultiplication(&proof.WzwOpening, u.BigInt(nil))
uWzw.ScalarMultiplication(&proof.WzwOpening, u.BigInt(&scalar))
leftG1.Add(&proof.WzOpening, &uWzw)
// Compute: z·[W_z]_1 + u·(zω)·[W_{zw}]_1
@@ -846,9 +866,9 @@ func verifyPLONKPairing(proof *PLONKProof, vk *PLONKVerifyingKey, publicInputs [
zOmega.Mul(&z, &vk.Omega)
var zWz, uzwWzw bn254.G1Affine
zWz.ScalarMultiplication(&proof.WzOpening, z.BigInt(nil))
uzwWzw.ScalarMultiplication(&proof.WzwOpening, zOmega.BigInt(nil))
uzwWzw.ScalarMultiplication(&uzwWzw, u.BigInt(nil))
zWz.ScalarMultiplication(&proof.WzOpening, z.BigInt(&scalar))
uzwWzw.ScalarMultiplication(&proof.WzwOpening, zOmega.BigInt(&scalar))
uzwWzw.ScalarMultiplication(&uzwWzw, u.BigInt(&scalar))
var rightG1 bn254.G1Affine
rightG1.Add(&zWz, &uzwWzw)
@@ -898,8 +918,8 @@ func deserializePLONKProof(data []byte) (*PLONKProof, error) {
if err := pt.Unmarshal(data[offset : offset+64]); err != nil {
return nil, fmt.Errorf("failed to unmarshal G1 point %d: %w", i, err)
}
if !pt.IsInSubGroup() {
return nil, fmt.Errorf("PLONK proof G1 point %d not in prime-order subgroup", i)
if err := checkG1(pt); err != nil {
return nil, fmt.Errorf("PLONK proof G1 point %d: %w", i, err)
}
offset += 64
}
@@ -934,8 +954,8 @@ func deserializePLONKVerifyingKey(data []byte) (*PLONKVerifyingKey, error) {
if err := vk.G1.Unmarshal(data[offset : offset+64]); err != nil {
return nil, fmt.Errorf("failed to unmarshal G1: %w", err)
}
if !vk.G1.IsInSubGroup() {
return nil, errors.New("PLONK VK G1 generator not in prime-order subgroup")
if err := checkG1(&vk.G1); err != nil {
return nil, fmt.Errorf("PLONK VK G1 generator: %w", err)
}
offset += 64
@@ -943,8 +963,8 @@ func deserializePLONKVerifyingKey(data []byte) (*PLONKVerifyingKey, error) {
if err := vk.G2.Unmarshal(data[offset : offset+128]); err != nil {
return nil, fmt.Errorf("failed to unmarshal G2: %w", err)
}
if !vk.G2.IsInSubGroup() {
return nil, errors.New("PLONK VK G2 generator not in prime-order subgroup")
if err := checkG2(&vk.G2); err != nil {
return nil, fmt.Errorf("PLONK VK G2 generator: %w", err)
}
offset += 128
@@ -952,8 +972,8 @@ func deserializePLONKVerifyingKey(data []byte) (*PLONKVerifyingKey, error) {
if err := vk.G2Alpha.Unmarshal(data[offset : offset+128]); err != nil {
return nil, fmt.Errorf("failed to unmarshal G2Alpha: %w", err)
}
if !vk.G2Alpha.IsInSubGroup() {
return nil, errors.New("PLONK VK G2Alpha not in prime-order subgroup")
if err := checkG2(&vk.G2Alpha); err != nil {
return nil, fmt.Errorf("PLONK VK G2Alpha: %w", err)
}
offset += 128
@@ -968,8 +988,8 @@ func deserializePLONKVerifyingKey(data []byte) (*PLONKVerifyingKey, error) {
if err := pt.Unmarshal(data[offset : offset+64]); err != nil {
return nil, fmt.Errorf("failed to unmarshal selector %d: %w", i, err)
}
if !pt.IsInSubGroup() {
return nil, fmt.Errorf("PLONK VK selector %d not in prime-order subgroup", i)
if err := checkG1(pt); err != nil {
return nil, fmt.Errorf("PLONK VK selector %d: %w", i, err)
}
offset += 64
}
@@ -983,8 +1003,8 @@ func deserializePLONKVerifyingKey(data []byte) (*PLONKVerifyingKey, error) {
if err := pt.Unmarshal(data[offset : offset+64]); err != nil {
return nil, fmt.Errorf("failed to unmarshal permutation %d: %w", i, err)
}
if !pt.IsInSubGroup() {
return nil, fmt.Errorf("PLONK VK permutation %d not in prime-order subgroup", i)
if err := checkG1(pt); err != nil {
return nil, fmt.Errorf("PLONK VK permutation %d: %w", i, err)
}
offset += 64
}
+312
View File
@@ -0,0 +1,312 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"encoding/binary"
"math/big"
"strings"
"testing"
"github.com/consensys/gnark-crypto/ecc/bn254"
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
"github.com/luxfi/log"
)
// Groth16 and PLONK verification are the only things standing between a peer's
// bytes and shielded value. What follows pins the three ways that guard has
// been walked past: arithmetic that dies on a nil scalar, a public-input count
// the key cannot cover, and a curve point at infinity that deletes its own
// term from the pairing.
// groth16Key serializes a Groth16 verifying key of numK generator points:
// Alpha(G1) | Beta(G2) | Gamma(G2) | Delta(G2) | numK(4) | K[numK](G1).
func groth16Key(numK uint32) []byte {
_, _, g1, g2 := bn254.Generators()
out := make([]byte, 0, 452+int(numK)*64)
out = append(out, g1.Marshal()...)
out = append(out, g2.Marshal()...)
out = append(out, g2.Marshal()...)
out = append(out, g2.Marshal()...)
n := make([]byte, 4)
binary.BigEndian.PutUint32(n, numK)
out = append(out, n...)
for i := uint32(0); i < numK; i++ {
out = append(out, g1.Marshal()...)
}
return out
}
// groth16Frame is Ar‖Bs‖Krs of curve generators: 256 bytes that decode and
// pass every point check, so verification reaches the pairing.
func groth16Frame() []byte {
_, _, g1, g2 := bn254.Generators()
out := make([]byte, 0, 256)
out = append(out, g1.Marshal()...)
out = append(out, g2.Marshal()...)
out = append(out, g1.Marshal()...)
return out
}
// plonkKey is G1 | G2 | [alpha]_2 | 5 selectors | 3 permutations, all
// generators, padded to the 1024 bytes the decoder requires.
func plonkKey() []byte {
_, _, g1, g2 := bn254.Generators()
out := make([]byte, 0, 1024)
out = append(out, g1.Marshal()...)
out = append(out, g2.Marshal()...)
out = append(out, g2.Marshal()...)
for i := 0; i < 8; i++ {
out = append(out, g1.Marshal()...)
}
return append(out, make([]byte, 1024-len(out))...)
}
// plonkFrame is 9 G1 generators followed by 5 zero evaluations: 736 bytes.
func plonkFrame() []byte {
_, _, g1, _ := bn254.Generators()
out := make([]byte, 0, 736)
for i := 0; i < 9; i++ {
out = append(out, g1.Marshal()...)
}
return append(out, make([]byte, 160)...)
}
func keyedVerifier(t *testing.T, system string, keys map[string][]byte) *ProofVerifier {
t.Helper()
pv, err := NewProofVerifier(ZConfig{
ProofSystem: system,
ProofCacheSize: 16,
VerifyingKeys: keys,
}, log.NoLog{})
if err != nil {
t.Fatalf("NewProofVerifier: %v", err)
}
return pv
}
// TestMSMAgreesWithRepeatedAddition. The multi-scalar multiplication carries
// the public-input linear combination, so everything a classical proof is
// judged on rests on it equalling the sum it stands for.
func TestMSMAgreesWithRepeatedAddition(t *testing.T) {
_, _, g1, _ := bn254.Generators()
scalars := make([]fr.Element, 4)
bases := make([]bn254.G1Affine, 4)
for i := range scalars {
scalars[i].SetUint64(uint64(i + 3))
bases[i].Set(&g1)
}
var want bn254.G1Affine
for i := range scalars {
var term bn254.G1Affine
term.ScalarMultiplication(&bases[i], scalars[i].BigInt(new(big.Int)))
want.Add(&want, &term)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("computing an MSM must not panic: %v", r)
}
}()
if got := msmCPU(scalars, bases); !got.Equal(&want) {
t.Fatalf("msmCPU = %v, want %v", got, want)
}
got, err := msmGPU(scalars, bases, log.NoLog{})
if err != nil {
t.Fatalf("msmGPU: %v", err)
}
if !got.Equal(&want) {
t.Fatalf("msmGPU = %v, want %v — the accelerated path disagrees with the plain one", got, want)
}
}
// TestWitnessMustFitTheVerifyingKey. A peer chooses how many public inputs its
// transaction carries; the key fixes how many the circuit has. K holds one
// point per input plus the constant term, so a witness of len(K) inputs reads
// K[len(K)] — one past the end. That must be an error, never a panic.
func TestWitnessMustFitTheVerifyingKey(t *testing.T) {
_, _, g1, _ := bn254.Generators()
key := func(n int) *Groth16VerifyingKey {
vk := &Groth16VerifyingKey{K: make([]bn254.G1Affine, n)}
for i := range vk.K {
vk.K[i].Set(&g1)
}
return vk
}
for _, n := range []int{0, 1, 2, 4} {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("a peer-chosen public-input count must be an error, not a panic: %v", r)
}
}()
if err := verifyGroth16Pairing(&Groth16Proof{}, key(n), make([]fr.Element, n)); err == nil {
t.Fatalf("%d public inputs against %d K points must be refused", n, n)
}
}()
}
// A witness the key does cover reaches the pairing and is judged there.
// Without this the refusals above could be a verifier that refuses
// everything.
proof, err := deserializeGroth16Proof(groth16Frame())
if err != nil {
t.Fatalf("deserializeGroth16Proof: %v", err)
}
vk, err := deserializeVerifyingKey(groth16Key(3))
if err != nil {
t.Fatalf("deserializeVerifyingKey: %v", err)
}
err = verifyGroth16Pairing(proof, vk, make([]fr.Element, 2))
if err == nil || !strings.Contains(err.Error(), "pairing check failed") {
t.Fatalf("a covered witness must be judged by the pairing, got: %v", err)
}
}
// TestGroth16ReachesItsPairing. A non-strict chain holding real keys is a
// supported deployment and its whole point is that a groth16 proof is accepted
// or rejected on the arithmetic. Any proof that decodes must produce a verdict.
func TestGroth16ReachesItsPairing(t *testing.T) {
pv := keyedVerifier(t, "groth16", map[string][]byte{
string(TransactionTypeTransfer): groth16Key(4),
})
if !pv.VerifyingKeysLoaded() {
t.Fatal("precondition: the chain holds a real verifying key")
}
nullifier := make([]byte, 32)
nullifier[0] = 0xA1
commitment := make([]byte, 32)
commitment[0] = 0xC1
tx := &Transaction{
Type: TransactionTypeTransfer,
Version: 1,
Nullifiers: [][]byte{nullifier},
Outputs: []*ShieldedOutput{{Commitment: commitment}},
Proof: &ZKProof{
ProofType: "groth16",
ProofData: groth16Frame(),
PublicInputs: [][]byte{nullifier, commitment},
},
}
tx.ID = tx.ComputeID()
var err error
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("verifying a well-formed groth16 proof must not panic: %v", r)
}
}()
err = pv.VerifyTransactionProof(tx)
}()
// The proof satisfies no statement, so the verdict is "rejected" — but a
// verdict is what has to come back.
if err == nil || !strings.Contains(err.Error(), "verification failed") {
t.Fatalf("expected a pairing verdict, got: %v", err)
}
}
// TestPLONKReachesItsPairing is the same property for the other classical
// system a non-strict chain may be configured with.
func TestPLONKReachesItsPairing(t *testing.T) {
pv := keyedVerifier(t, "plonk", map[string][]byte{
string(TransactionTypeTransfer): plonkKey(),
})
nullifier := make([]byte, 32)
nullifier[0] = 0xA1
commitment := make([]byte, 32)
commitment[0] = 0xC1
tx := &Transaction{
Type: TransactionTypeTransfer,
Version: 1,
Nullifiers: [][]byte{nullifier},
Outputs: []*ShieldedOutput{{Commitment: commitment}},
Proof: &ZKProof{
ProofType: "plonk",
ProofData: plonkFrame(),
PublicInputs: [][]byte{nullifier, commitment},
},
}
tx.ID = tx.ComputeID()
var err error
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("verifying a well-formed PLONK proof must not panic: %v", r)
}
}()
err = pv.VerifyTransactionProof(tx)
}()
if err == nil || !strings.Contains(err.Error(), "PLONK") {
t.Fatalf("expected a PLONK verdict, got: %v", err)
}
}
// TestInfinityIsNotAUsablePoint. gnark decodes all-zero bytes to the point at
// infinity and reports it in-subgroup, and a pairing skips every term whose
// argument is infinity. A proof or key element at infinity therefore deletes
// itself from the equation, so no decoder may hand one on.
func TestInfinityIsNotAUsablePoint(t *testing.T) {
if _, err := deserializeGroth16Proof(make([]byte, 256)); err == nil {
t.Fatal("a Groth16 proof of infinity points must not decode")
}
zeroKey, err := deserializeVerifyingKey(make([]byte, 1024))
if err == nil {
if err := validateVerifyingKey(zeroKey); err == nil {
t.Fatal("a Groth16 key of infinity points must not validate")
}
}
if _, err := deserializePLONKProof(make([]byte, 736)); err == nil {
t.Fatal("a PLONK proof of infinity points must not decode")
}
if _, err := deserializePLONKVerifyingKey(make([]byte, 1024)); err == nil {
t.Fatal("a PLONK key of infinity points must not decode")
}
}
// TestCircuitWithoutAKeyIsRefused. A chain is keyed per circuit; the circuits
// the operator did not key get an all-zero placeholder. Those zeros decode to
// infinity, and a PLONK key of infinity points makes both sides of the pairing
// empty — every proof valid, value minted from nothing. A circuit with no key
// must be refused instead.
func TestCircuitWithoutAKeyIsRefused(t *testing.T) {
pv := keyedVerifier(t, "plonk", map[string][]byte{
string(TransactionTypeTransfer): plonkKey(), // shield and unshield keyed with zeros
})
commitment := make([]byte, 32)
commitment[0] = 0xC1
tx := &Transaction{
Type: TransactionTypeShield,
Version: 1,
Outputs: []*ShieldedOutput{{Commitment: commitment}},
Proof: &ZKProof{
ProofType: "plonk",
ProofData: plonkFrame(),
PublicInputs: [][]byte{commitment},
},
}
tx.ID = tx.ComputeID()
if err := pv.VerifyTransactionProof(tx); err == nil {
t.Fatal("a shield proof judged against an unkeyed circuit must be refused, not accepted")
}
}