mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
zk: three verifiers answered valid without verifying anything
Op 0x04 accepted every proof of every statement as soon as any Halo2 key was
registered. verifyHalo2IPA folded the IPA rounds, assigned the result to _
under a comment saying it was used in full verification, and returned true. No
degenerate key was needed and no proof had to be well-formed.
Range proofs answered len(commitment) > 0 && len(rangeProof) > 0 &&
bitLength > 0 -- a length check returned as a verdict, so every byte string was
a valid range proof, through op 0x23 and through batch. A range proof is what
stops a confidential transfer minting value.
Groth16 and PLONK both accepted an all-zero verifying key, which makes every
pairing factor the identity and the product trivially 1: registration validated
nothing, so one key forged every statement on two proof systems. One degenerate
predicate now refuses at each decision point.
Poseidon2's cache key folded only the first 32 bytes and the length, so
HashPair(a,b1) == HashPair(a,b2). A Merkle node ignored its right child, a
commitment ignored its blinding factor, and one nullifier served two notes. It
also made the answer depend on cache history, so two nodes could disagree.
Also: the verifying-key id omitted IC and concatenated without framing, so keys
differing only in their public-input constraints shared an id and the second
silently replaced the first -- and ("ab","c") collided with ("a","bc"), the
third time that exact framing bug has turned up in this estate this week.
isValidScalar was a length check whose own comment said a range check belonged
there, so non-canonical scalars passed the whole Halo2 path.
Gas was base + vkID[0:4]*1000. Key ids are hashes, so leading bytes are
uniform and a call only fit a 30M block when vkID[0:4] < 29850: about one
verifying key in 143,000 was usable, which is near-total breakage of ops
0x01-0x04 rather than an edge case. Price now follows the public-input count
the verifier actually parses, capped by what the input length can hold.
Three existing tests asserted the defective behaviour -- TestVerifyHalo2Full
and TestVerifyRangeProof both discarded the verdict into _ -- which is how
constant-returning verifiers survived here.
zk 61.1% -> 97.1%, starkfri 71.0% -> 98.4%. 59 of 60 mutations caught; the
survivor is an equivalent mutant, measured: kzg4844 rejects non-canonical
scalars itself, so no input can flip the verdict through our redundant check.
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package starkfri
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/precompile/contract"
|
||||
"github.com/luxfi/precompile/modules"
|
||||
"github.com/luxfi/precompile/precompileconfig"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The default build compiles verify_nocgo.go, which registers nothing. The
|
||||
// package's default state is therefore "no verifier", and BOTH entry
|
||||
// points must refuse. A refusal that quietly reported success instead
|
||||
// would be a forgery oracle for every STARK-FRI proof on the chain, so it
|
||||
// is the single most important behaviour in this package.
|
||||
|
||||
// sfClear empties the verifier slot and restores it afterwards, so these
|
||||
// tests cannot leak state into the rest of the package.
|
||||
func sfClear(t *testing.T) {
|
||||
t.Helper()
|
||||
RegisterVerifier(nil)
|
||||
t.Cleanup(func() { RegisterVerifier(nil) })
|
||||
}
|
||||
|
||||
// sfWire serialises the [version][proof_len][proof][pub_len][pub] wire.
|
||||
func sfWire(version byte, proof, pub []byte) []byte {
|
||||
out := make([]byte, 0, 1+4+len(proof)+4+len(pub))
|
||||
out = append(out, version)
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(len(proof)))
|
||||
out = append(out, proof...)
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(len(pub)))
|
||||
return append(out, pub...)
|
||||
}
|
||||
|
||||
// TestSF_SafeRefuseIsTheDefault: with nothing registered, no input of any
|
||||
// shape may be accepted. This is the property verify_nocgo.go exists to
|
||||
// provide, asserted rather than assumed.
|
||||
func TestSF_SafeRefuseIsTheDefault(t *testing.T) {
|
||||
sfClear(t)
|
||||
|
||||
proofs := [][]byte{
|
||||
[]byte(MagicHeader),
|
||||
append([]byte(MagicHeader), bytes.Repeat([]byte{0x00}, 64)...),
|
||||
append([]byte(MagicHeader), bytes.Repeat([]byte{0xff}, 4096)...),
|
||||
}
|
||||
pubs := [][]byte{{}, {0xaa}, bytes.Repeat([]byte{0x5a}, 256)}
|
||||
|
||||
for _, proof := range proofs {
|
||||
for _, pub := range pubs {
|
||||
in := sfWire(VersionV1, proof, pub)
|
||||
|
||||
// In-process entry.
|
||||
ok, err := Verify(proof, pub)
|
||||
require.False(t, ok, "Verify accepted a proof with no verifier registered")
|
||||
require.ErrorIs(t, err, ErrVerifierNotRegistered)
|
||||
|
||||
// Precompile entry. nil error is the ONLY success signal, so
|
||||
// the refusal must be a non-nil error.
|
||||
gas := StarkFRIVerifyPrecompile.RequiredGas(in)
|
||||
_, _, err = StarkFRIVerifyPrecompile.Run(
|
||||
nil, common.Address{}, ContractStarkFRIVerifyAddress, in, gas, true)
|
||||
require.ErrorIs(t, err, ErrVerifierNotRegistered,
|
||||
"Run reported success with no verifier registered")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSF_VerifyChecksStructureBeforeConsultingTheVerifier pins the
|
||||
// ordering: a proof without the magic header is rejected on its own bytes.
|
||||
// If the verifier were consulted first, a caller could distinguish
|
||||
// "registered" from "not registered", and any future verifier would be
|
||||
// handed input it never agreed to parse.
|
||||
func TestSF_VerifyChecksStructureBeforeConsultingTheVerifier(t *testing.T) {
|
||||
sfClear(t)
|
||||
|
||||
called := false
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
||||
called = true
|
||||
return true, nil
|
||||
})
|
||||
|
||||
for _, bad := range [][]byte{
|
||||
nil,
|
||||
{},
|
||||
[]byte("P3Q"), // one byte short of the header
|
||||
[]byte("p3q1"), // wrong case
|
||||
[]byte("XXXX"), // wrong header
|
||||
[]byte("XXXXP3Q1"), // header not at offset 0
|
||||
{0x50, 0x33, 0x51, 0x32}, // "P3Q2"
|
||||
} {
|
||||
ok, err := Verify(bad, []byte{0x01})
|
||||
require.Falsef(t, ok, "accepted proof %q", bad)
|
||||
require.ErrorIs(t, err, ErrInvalidProof)
|
||||
}
|
||||
require.False(t, called,
|
||||
"the verifier was consulted for a proof that failed structural validation")
|
||||
|
||||
// Control: a well-formed header does reach it.
|
||||
ok, err := Verify([]byte(MagicHeader), []byte{0x01})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.True(t, called)
|
||||
}
|
||||
|
||||
// TestSF_VerifySurfacesTheVerdict: Verify must relay all three outcomes
|
||||
// distinctly, never collapsing a failure into a success.
|
||||
func TestSF_VerifySurfacesTheVerdict(t *testing.T) {
|
||||
sfClear(t)
|
||||
proof := append([]byte(MagicHeader), 0x01, 0x02)
|
||||
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return true, nil })
|
||||
ok, err := Verify(proof, nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return false, nil })
|
||||
ok, err = Verify(proof, nil)
|
||||
require.NoError(t, err, "a well-formed but invalid proof is not an internal error")
|
||||
require.False(t, ok)
|
||||
|
||||
boom := errors.New("ffi exploded")
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return false, boom })
|
||||
ok, err = Verify(proof, nil)
|
||||
require.ErrorIs(t, err, boom)
|
||||
require.False(t, ok)
|
||||
|
||||
// An internal error must never be reported as a verified proof, even
|
||||
// if the callback contradicts itself by returning ok=true with an err.
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return true, boom })
|
||||
_, err = Verify(proof, nil)
|
||||
require.Error(t, err, "an errored verification must not read as success")
|
||||
}
|
||||
|
||||
// TestSF_VerifyPassesTheWireThrough pins that the version and the exact
|
||||
// proof/public bytes reach the verifier unmodified — a verifier handed
|
||||
// truncated or reordered input would be checking a different statement.
|
||||
func TestSF_VerifyPassesTheWireThrough(t *testing.T) {
|
||||
sfClear(t)
|
||||
|
||||
proof := append([]byte(MagicHeader), []byte("body-bytes")...)
|
||||
pub := []byte("public-inputs")
|
||||
|
||||
var gotVer byte
|
||||
var gotProof, gotPub []byte
|
||||
RegisterVerifier(func(v byte, p, u []byte) (bool, error) {
|
||||
gotVer, gotProof, gotPub = v, bytes.Clone(p), bytes.Clone(u)
|
||||
return true, nil
|
||||
})
|
||||
|
||||
_, err := Verify(proof, pub)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, VersionV1, gotVer)
|
||||
require.Equal(t, proof, gotProof)
|
||||
require.Equal(t, pub, gotPub)
|
||||
|
||||
// And the same through the precompile, where the bytes are sliced out
|
||||
// of a framed input.
|
||||
in := sfWire(VersionV1, proof, pub)
|
||||
gotProof, gotPub = nil, nil
|
||||
_, _, err = StarkFRIVerifyPrecompile.Run(nil, common.Address{},
|
||||
ContractStarkFRIVerifyAddress, in, StarkFRIVerifyPrecompile.RequiredGas(in), true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proof, gotProof, "framing corrupted the proof bytes")
|
||||
require.Equal(t, pub, gotPub, "framing corrupted the public inputs")
|
||||
}
|
||||
|
||||
// TestSF_RegistrationSeams pins the two registration verbs against each
|
||||
// other, including the nil cases neither test covered.
|
||||
func TestSF_RegistrationSeams(t *testing.T) {
|
||||
sfClear(t)
|
||||
|
||||
require.Nil(t, loadVerifier(), "slot must start empty")
|
||||
require.False(t, RegisterDefaultVerifier(nil),
|
||||
"a nil default must not be installed, and must not claim it was")
|
||||
require.Nil(t, loadVerifier(), "a nil default must leave the slot empty")
|
||||
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return true, nil })
|
||||
require.NotNil(t, loadVerifier())
|
||||
|
||||
// RegisterVerifier(nil) CLEARS, so the safe-refuse state is reachable
|
||||
// again — a verifier cannot be made permanent by accident.
|
||||
RegisterVerifier(nil)
|
||||
require.Nil(t, loadVerifier())
|
||||
_, err := Verify([]byte(MagicHeader), nil)
|
||||
require.ErrorIs(t, err, ErrVerifierNotRegistered)
|
||||
|
||||
// RegisterVerifier FORCES, overriding a default already in place.
|
||||
require.True(t, RegisterDefaultVerifier(func(byte, []byte, []byte) (bool, error) {
|
||||
return false, ErrVerifierNotRegistered
|
||||
}))
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return true, nil })
|
||||
ok, err := Verify([]byte(MagicHeader), nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "RegisterVerifier must override an installed default")
|
||||
}
|
||||
|
||||
// TestSF_WireBoundsAreRefusedNotPanicked walks every length field to its
|
||||
// extremes. A precompile that panics halts the node; one that reads past
|
||||
// its input verifies attacker-chosen memory.
|
||||
func TestSF_WireBoundsAreRefusedNotPanicked(t *testing.T) {
|
||||
sfClear(t)
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return true, nil })
|
||||
|
||||
proof := append([]byte(MagicHeader), []byte("body")...)
|
||||
pub := []byte{0xaa}
|
||||
good := sfWire(VersionV1, proof, pub)
|
||||
|
||||
var cases [][]byte
|
||||
// Every truncation.
|
||||
for n := range len(good) {
|
||||
cases = append(cases, bytes.Clone(good[:n]))
|
||||
}
|
||||
// Overstated and extreme proof lengths.
|
||||
for _, n := range []uint32{uint32(len(proof)) + 1, 1 << 16, math.MaxUint32 - 1, math.MaxUint32} {
|
||||
c := bytes.Clone(good)
|
||||
binary.BigEndian.PutUint32(c[1:5], n)
|
||||
cases = append(cases, c)
|
||||
}
|
||||
// Overstated public-input length.
|
||||
for _, n := range []uint32{uint32(len(pub)) + 1, 1 << 20, math.MaxUint32} {
|
||||
c := bytes.Clone(good)
|
||||
binary.BigEndian.PutUint32(c[5+len(proof):9+len(proof)], n)
|
||||
cases = append(cases, c)
|
||||
}
|
||||
|
||||
for i, in := range cases {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("case %d panicked on %d bytes: %v", i, len(in), r)
|
||||
}
|
||||
}()
|
||||
gas := StarkFRIVerifyPrecompile.RequiredGas(in)
|
||||
_, _, err := StarkFRIVerifyPrecompile.Run(nil, common.Address{},
|
||||
ContractStarkFRIVerifyAddress, in, gas, true)
|
||||
require.Errorf(t, err, "case %d (%d bytes) was ACCEPTED", i, len(in))
|
||||
}()
|
||||
}
|
||||
|
||||
// Control: the untampered input still verifies, so the sweep above was
|
||||
// rejecting the corruption and not something incidental.
|
||||
_, _, err := StarkFRIVerifyPrecompile.Run(nil, common.Address{},
|
||||
ContractStarkFRIVerifyAddress, good, StarkFRIVerifyPrecompile.RequiredGas(good), true)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestSF_GasIsChargedAndProportional: no call is free, price rises with
|
||||
// size, and the charge is deducted before any verification happens.
|
||||
func TestSF_GasIsChargedAndProportional(t *testing.T) {
|
||||
sfClear(t)
|
||||
called := false
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
||||
called = true
|
||||
return true, nil
|
||||
})
|
||||
|
||||
require.Positive(t, StarkFRIVerifyPrecompile.RequiredGas(nil),
|
||||
"even an empty call must cost something")
|
||||
|
||||
prev := StarkFRIVerifyPrecompile.RequiredGas(nil)
|
||||
for _, n := range []int{1, 16, 256, 4096} {
|
||||
got := StarkFRIVerifyPrecompile.RequiredGas(make([]byte, n))
|
||||
require.Greater(t, got, prev, "price must rise with input size")
|
||||
prev = got
|
||||
}
|
||||
|
||||
proof := append([]byte(MagicHeader), bytes.Repeat([]byte{1}, 128)...)
|
||||
in := sfWire(VersionV1, proof, []byte{0xaa})
|
||||
need := StarkFRIVerifyPrecompile.RequiredGas(in)
|
||||
|
||||
_, rem, err := StarkFRIVerifyPrecompile.Run(nil, common.Address{},
|
||||
ContractStarkFRIVerifyAddress, in, need-1, true)
|
||||
require.ErrorIs(t, err, contract.ErrOutOfGas)
|
||||
require.Zero(t, rem)
|
||||
require.False(t, called, "the verifier ran on an underpaid call")
|
||||
|
||||
_, rem, err = StarkFRIVerifyPrecompile.Run(nil, common.Address{},
|
||||
ContractStarkFRIVerifyAddress, in, need+777, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(777), rem, "exactly the required gas must be consumed")
|
||||
require.True(t, called)
|
||||
}
|
||||
|
||||
// TestSF_ModuleConfig covers the precompileconfig surface the module
|
||||
// registers, asserting Equal actually discriminates rather than always
|
||||
// agreeing.
|
||||
func TestSF_ModuleConfig(t *testing.T) {
|
||||
c := &configurator{}
|
||||
cfg := c.MakeConfig()
|
||||
require.NotNil(t, cfg)
|
||||
require.IsType(t, &Config{}, cfg)
|
||||
require.NoError(t, c.Configure(nil, cfg, nil, nil))
|
||||
|
||||
ts := uint64(1234)
|
||||
a := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &ts}}
|
||||
require.Equal(t, ConfigKey, a.Key())
|
||||
require.Equal(t, &ts, a.Timestamp())
|
||||
require.False(t, a.IsDisabled())
|
||||
require.NoError(t, a.Verify(nil))
|
||||
|
||||
same := uint64(1234)
|
||||
require.True(t, a.Equal(&Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &same}}))
|
||||
|
||||
other := uint64(5678)
|
||||
require.False(t, a.Equal(&Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &other}}),
|
||||
"configs at different timestamps must not compare equal")
|
||||
require.False(t, a.Equal(&Config{}), "a nil timestamp must not equal a set one")
|
||||
require.False(t, a.Equal(nil), "a nil config must not compare equal")
|
||||
|
||||
disabled := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &ts, Disable: true}}
|
||||
require.True(t, disabled.IsDisabled())
|
||||
require.False(t, a.Equal(disabled), "the disable flag is part of identity")
|
||||
|
||||
// The module is registered, at the address the precompile reports.
|
||||
m, ok := modules.GetPrecompileModuleByAddress(ContractStarkFRIVerifyAddress)
|
||||
require.True(t, ok, "module not registered at its address")
|
||||
require.Equal(t, ConfigKey, m.ConfigKey)
|
||||
require.Equal(t, StarkFRIVerifyPrecompile, m.Contract)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The refusal branches. Each one is a place a malformed proof is supposed
|
||||
// to stop; a branch never taken in a test is a branch nobody has seen
|
||||
// work.
|
||||
|
||||
// brnHalo2Offsets returns the byte offset of each element in a proof from
|
||||
// buildTestHalo2Proof, so a test can corrupt exactly one of them.
|
||||
type brnHalo2Offsets struct {
|
||||
advice, instance, lPoints, rPoints int
|
||||
aScalar, evalPoint, claimedEval int
|
||||
}
|
||||
|
||||
func brnHalo2Layout(numInputs, numAdvice, numInstance int, rounds uint32) brnHalo2Offsets {
|
||||
o := 32 + 4 + numInputs*32 // past vkID, numInputs, inputs
|
||||
advice := o + 4
|
||||
o = advice + numAdvice*32
|
||||
instance := o + 4
|
||||
o = instance + numInstance*32
|
||||
lp := o + 4
|
||||
rp := lp + int(rounds)*32
|
||||
a := rp + int(rounds)*32
|
||||
return brnHalo2Offsets{
|
||||
advice: advice, instance: instance, lPoints: lp, rPoints: rp,
|
||||
aScalar: a, evalPoint: a + 32, claimedEval: a + 64,
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrn_Halo2StructuralRejectsEachElement corrupts one element of an
|
||||
// otherwise well-formed proof at a time. Every element must be validated;
|
||||
// one that is not is an element a prover may choose freely.
|
||||
func TestBrn_Halo2StructuralRejectsEachElement(t *testing.T) {
|
||||
const (
|
||||
numInputs = 1
|
||||
numAdvice = 2
|
||||
numInstance = 1
|
||||
rounds = 3
|
||||
)
|
||||
off := brnHalo2Layout(numInputs, numAdvice, numInstance, rounds)
|
||||
base := buildTestHalo2Proof(numInputs, numAdvice, numInstance, rounds)
|
||||
|
||||
// Control: the untouched fixture reaches the end of structural
|
||||
// validation and stops only for want of a verifying key.
|
||||
proof, err := parseHalo2Proof(base)
|
||||
require.NoError(t, err)
|
||||
ok, err := verifyHalo2Structural(proof)
|
||||
require.False(t, ok)
|
||||
require.ErrorContains(t, err, "structural validation passed")
|
||||
|
||||
// 0xff is neither a canonical field element nor an on-curve x, so it
|
||||
// fails whichever predicate guards the slot it is written into.
|
||||
bad := bytes.Repeat([]byte{0xff}, 32)
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
offset int
|
||||
expect string
|
||||
}{
|
||||
{"advice commitment", off.advice, "advice commitment"},
|
||||
{"instance commitment", off.instance, "instance commitment"},
|
||||
{"L point", off.lPoints, "L point"},
|
||||
{"R point", off.rPoints, "R point"},
|
||||
{"final scalar", off.aScalar, "final scalar"},
|
||||
{"evaluation point", off.evalPoint, "evaluation point"},
|
||||
{"claimed evaluation", off.claimedEval, "claimed evaluation"},
|
||||
} {
|
||||
data := bytes.Clone(base)
|
||||
copy(data[c.offset:c.offset+32], bad)
|
||||
|
||||
proof, err := parseHalo2Proof(data)
|
||||
require.NoErrorf(t, err, "%s: fixture must still parse", c.name)
|
||||
|
||||
ok, err := verifyHalo2Structural(proof)
|
||||
require.Falsef(t, ok, "%s was accepted", c.name)
|
||||
require.ErrorContainsf(t, err, c.expect,
|
||||
"%s was not the element rejected", c.name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrn_FflonkProofRejectsEachElement walks the refusals in the
|
||||
// DISABLED standalone verifier. It is unreachable from Run today, but it
|
||||
// is still compiled and still exported through the package, so its
|
||||
// refusals are pinned: if it is ever re-enabled these are the checks that
|
||||
// have to be there.
|
||||
func TestBrn_FflonkProofRejectsEachElement(t *testing.T) {
|
||||
valid := buildValidFflonkProof(t, fflonkTestVK(), []*big.Int{big.NewInt(1)}, 8)
|
||||
bad48 := bytes.Repeat([]byte{0xff}, 48)
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
offset int
|
||||
expect string
|
||||
}{
|
||||
{"C1", 0, "C1"},
|
||||
{"C2", 48, "C2"},
|
||||
{"W1", 96, "W1"},
|
||||
{"W2", 144, "W2"},
|
||||
} {
|
||||
data := bytes.Clone(valid)
|
||||
copy(data[c.offset:c.offset+48], bad48)
|
||||
ok, err := verifyFflonkProof(data, nil)
|
||||
require.Falsef(t, ok, "%s was accepted", c.name)
|
||||
require.ErrorContainsf(t, err, c.expect, "%s was not the element rejected", c.name)
|
||||
}
|
||||
|
||||
// A non-canonical evaluation is refused, and the message names which.
|
||||
data := bytes.Clone(valid)
|
||||
copy(data[192+2*32:192+3*32], padScalar32(blsScalarField()))
|
||||
ok, err := verifyFflonkProof(data, nil)
|
||||
require.False(t, ok)
|
||||
require.ErrorContains(t, err, "invalid evaluation")
|
||||
|
||||
// Too short to hold the fixed layout.
|
||||
_, err = verifyFflonkProof(make([]byte, 100), nil)
|
||||
require.ErrorIs(t, err, ErrInvalidProofLength)
|
||||
|
||||
// Enough commitments but too few evaluations.
|
||||
_, err = parseFflonkProof(make([]byte, 192+7*32))
|
||||
require.ErrorIs(t, err, ErrInvalidProofLength)
|
||||
|
||||
// The opening check needs at least two evaluations to batch.
|
||||
_, err = verifyFflonkKZGOpening(&FflonkProof{
|
||||
C1: valid[0:48], C2: valid[48:96], W1: valid[96:144], W2: valid[144:192],
|
||||
Evaluations: []*big.Int{big.NewInt(1)},
|
||||
}, big.NewInt(1), big.NewInt(1), nil)
|
||||
require.ErrorContains(t, err, "insufficient evaluations")
|
||||
|
||||
// An out-of-range evaluation point is refused rather than truncated.
|
||||
tooBig := new(big.Int).Lsh(big.NewInt(1), 300)
|
||||
_, err = verifyFflonkKZGOpening(&FflonkProof{
|
||||
C1: valid[0:48], C2: valid[48:96], W1: valid[96:144], W2: valid[144:192],
|
||||
Evaluations: []*big.Int{big.NewInt(1), big.NewInt(2)},
|
||||
}, tooBig, big.NewInt(1), nil)
|
||||
require.ErrorContains(t, err, "xi too large")
|
||||
}
|
||||
|
||||
// TestBrn_RunReturnsTheAbiWordOnSuccess covers the success arm of the
|
||||
// dispatch for every op that can succeed. Until an op has been seen to
|
||||
// return the true word, nothing distinguishes it from one that always
|
||||
// errors.
|
||||
func TestBrn_RunReturnsTheAbiWordOnSuccess(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
p := &zkVerifyPrecompile{verifier: zv}
|
||||
|
||||
// Groth16: a satisfying proof, framed on the wire.
|
||||
public := []*big.Int{big.NewInt(11)}
|
||||
in := grBuild(t, public)
|
||||
id := grRegister(t, zv, in)
|
||||
|
||||
wire := append([]byte{OpVerifyGroth16}, id[:]...)
|
||||
wire = binary.BigEndian.AppendUint32(wire, uint32(len(public)))
|
||||
for _, x := range public {
|
||||
wire = append(wire, padScalar32(x)...)
|
||||
}
|
||||
wire = append(wire, in.a...)
|
||||
wire = append(wire, in.b...)
|
||||
wire = append(wire, in.c...)
|
||||
|
||||
ret, rem, err := p.Run(nil, addr0, ZKVerifyContractAddress, wire, 10_000_000, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encodeBool(true), ret, "op 0x01 must report a verified proof")
|
||||
require.Equal(t, uint64(10_000_000)-p.RequiredGas(wire), rem)
|
||||
|
||||
// Nullifier: unspent reports true, spent reports false.
|
||||
var n [32]byte
|
||||
copy(n[:], "an-unspent-nullifier")
|
||||
nul := append([]byte{OpVerifyNullifier}, n[:]...)
|
||||
ret, _, err = p.Run(nil, addr0, ZKVerifyContractAddress, nul, 10_000_000, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encodeBool(true), ret)
|
||||
|
||||
require.NoError(t, zv.SpendNullifier(n, common.Hash{}, 1))
|
||||
ret, _, err = p.Run(nil, addr0, ZKVerifyContractAddress, nul, 10_000_000, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encodeBool(false), ret)
|
||||
|
||||
// Commitment: a correct Pedersen opening reports true.
|
||||
var value, blinding [32]byte
|
||||
value[31] = 3
|
||||
blinding[31] = 4
|
||||
commitment, err := globalPedersen.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
if commitment[0] != 0x01 { // else the Merkle sub-mode claims it
|
||||
payload := append([]byte{OpVerifyCommitment}, commitment[:]...)
|
||||
payload = append(payload, value[:]...)
|
||||
payload = append(payload, blinding[:]...)
|
||||
ret, _, err = p.Run(nil, addr0, ZKVerifyContractAddress, payload, 10_000_000, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encodeBool(true), ret)
|
||||
|
||||
// And a wrong opening reports false rather than erroring.
|
||||
payload[33+31] ^= 0x01
|
||||
ret, _, err = p.Run(nil, addr0, ZKVerifyContractAddress, payload, 10_000_000, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encodeBool(false), ret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrn_QuadraticResidueEdges covers the branches of the residue test
|
||||
// that the brute-force sweep does not reach.
|
||||
func TestBrn_QuadraticResidueEdges(t *testing.T) {
|
||||
p := big.NewInt(23)
|
||||
require.True(t, isQuadraticResidue(big.NewInt(0), p), "zero is a square")
|
||||
require.True(t, isQuadraticResidue(big.NewInt(1), p))
|
||||
require.False(t, isQuadraticResidue(big.NewInt(5), p), "5 is a non-residue mod 23")
|
||||
|
||||
// Against the real Pallas field, so the big-integer path is exercised.
|
||||
require.True(t, isQuadraticResidue(big.NewInt(0), pallasFieldModulus))
|
||||
require.True(t, isQuadraticResidue(big.NewInt(4), pallasFieldModulus), "4 = 2^2")
|
||||
}
|
||||
|
||||
// TestBrn_PedersenOpeningRefusals covers the sub-mode's own guards.
|
||||
func TestBrn_PedersenOpeningRefusals(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
|
||||
for n := range 96 {
|
||||
_, err := p.verifyCommitmentPedersen(make([]byte, n))
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
}
|
||||
|
||||
// A commitment this process never produced cannot be opened, and that
|
||||
// is reported as an error rather than as a false verdict.
|
||||
unseen := make([]byte, 96)
|
||||
copy(unseen, bytes.Repeat([]byte{0x5a}, 32))
|
||||
_, err := p.verifyCommitmentPedersen(unseen)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestBrn_Halo2FullRefusesAnIncompleteKey covers verifyHalo2Full's early
|
||||
// exits: an IC too short for the declared public inputs, and the IPA
|
||||
// refusal itself.
|
||||
func TestBrn_Halo2FullRefusesAnIncompleteKey(t *testing.T) {
|
||||
data := buildTestHalo2Proof(2, 1, 1, 4)
|
||||
proof, err := parseHalo2Proof(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
// IC too short for two public inputs.
|
||||
ok, err := verifyHalo2Full(proof, &VerifyingKey{
|
||||
ProofSystem: ProofSystemHalo2, IC: [][]byte{{1}},
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.ErrorContains(t, err, "insufficient IC points")
|
||||
|
||||
// A long-enough IC gets past that and stops at the missing commitment
|
||||
// key, never at a verdict.
|
||||
ok, err = verifyHalo2Full(proof, &VerifyingKey{
|
||||
ProofSystem: ProofSystemHalo2, IC: [][]byte{{1}, {2}, {3}, {4}},
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.ErrorIs(t, err, ErrHalo2KeyIncomplete)
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers (all prefixed com)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// comWord renders n as a 32-byte big-endian word.
|
||||
func comWord(n uint64) [32]byte {
|
||||
var b [32]byte
|
||||
new(big.Int).SetUint64(n).FillBytes(b[:])
|
||||
return b
|
||||
}
|
||||
|
||||
// comAddr builds a distinguishable address.
|
||||
func comAddr(n byte) common.Address {
|
||||
var a common.Address
|
||||
a[19] = n
|
||||
return a
|
||||
}
|
||||
|
||||
// comNote is a reference note input every note test perturbs.
|
||||
func comNote() NoteInput {
|
||||
return NoteInput{
|
||||
Amount: big.NewInt(1_000_000),
|
||||
AssetID: comWord(0xa55e7),
|
||||
Owner: comAddr(7),
|
||||
BlindingFactor: comWord(0xb11d),
|
||||
SchemeType: SchemePoseidon2,
|
||||
}
|
||||
}
|
||||
|
||||
// comReceipt is a reference receipt every ComputeReceiptID test perturbs.
|
||||
func comReceipt() ValidityReceipt {
|
||||
return ValidityReceipt{
|
||||
ReceiptID: comWord(0xdead),
|
||||
MerkleRoot: comWord(1),
|
||||
Nullifiers: [][32]byte{comWord(2), comWord(3)},
|
||||
PoolID: comWord(4),
|
||||
AssetID: comWord(5),
|
||||
SourceChainID: 6,
|
||||
TargetChainID: 7,
|
||||
CircuitID: comWord(8),
|
||||
Timestamp: 9,
|
||||
ProofType: ProofTypeSTARK,
|
||||
ZKProofDigest: comWord(10),
|
||||
}
|
||||
}
|
||||
|
||||
// comPreimage rebuilds, independently of the code under test, the byte string
|
||||
// ComputeReceiptID hashes: root ‖ nullifiers ‖ pool ‖ asset ‖ src ‖ dst ‖
|
||||
// circuit, zero-padded up to a 32-byte multiple. Written with
|
||||
// encoding/binary so it cross-checks the hand-rolled shift loop in the source.
|
||||
func comPreimage(r ValidityReceipt) []byte {
|
||||
var data []byte
|
||||
data = append(data, r.MerkleRoot[:]...)
|
||||
for _, n := range r.Nullifiers {
|
||||
data = append(data, n[:]...)
|
||||
}
|
||||
data = append(data, r.PoolID[:]...)
|
||||
data = append(data, r.AssetID[:]...)
|
||||
|
||||
be := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(be, r.SourceChainID)
|
||||
data = append(data, be...)
|
||||
binary.BigEndian.PutUint64(be, r.TargetChainID)
|
||||
data = append(data, be...)
|
||||
|
||||
data = append(data, r.CircuitID[:]...)
|
||||
|
||||
padded := make([]byte, ((len(data)+31)/32)*32)
|
||||
copy(padded, data)
|
||||
return padded
|
||||
}
|
||||
|
||||
// comNullifierPreimage rebuilds the 96-byte string NullifierHash builds.
|
||||
func comNullifierPreimage(key, commitment [32]byte, leafIndex uint64) []byte {
|
||||
in := make([]byte, 96)
|
||||
copy(in[:32], key[:])
|
||||
copy(in[32:64], commitment[:])
|
||||
idx := new(big.Int).SetUint64(leafIndex).Bytes()
|
||||
copy(in[96-len(idx):], idx)
|
||||
return in
|
||||
}
|
||||
|
||||
// comFieldModulus is the BN254 scalar modulus as a 32-byte word. Both schemes
|
||||
// funnel their inputs through fr.Element, so this word reduces to zero.
|
||||
func comFieldModulus() [32]byte {
|
||||
var b [32]byte
|
||||
fr.Modulus().FillBytes(b[:])
|
||||
return b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commit — binding and hiding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCom_CommitIsDeterministicAndBinding(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fresh func() CommitmentScheme
|
||||
}{
|
||||
{"poseidon2", func() CommitmentScheme { return NewPoseidon2Scheme() }},
|
||||
{"pedersen", func() CommitmentScheme { return NewPedersenScheme() }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
commit := func(value, blind [32]byte) [32]byte {
|
||||
c, err := tc.fresh().Commit(value, blind)
|
||||
require.NoError(t, err)
|
||||
return c
|
||||
}
|
||||
|
||||
value, blind := comWord(42), comWord(99)
|
||||
base := commit(value, blind)
|
||||
require.NotEqual(t, [32]byte{}, base, "a commitment must not be the zero word")
|
||||
require.Equal(t, base, commit(value, blind), "committing is a pure function of its inputs")
|
||||
|
||||
// Binding: distinct values must reach distinct commitments.
|
||||
seen := map[[32]byte]uint64{}
|
||||
for v := range uint64(64) {
|
||||
c := commit(comWord(v), blind)
|
||||
prev, dup := seen[c]
|
||||
require.Falsef(t, dup, "values %d and %d collide", prev, v)
|
||||
seen[c] = v
|
||||
}
|
||||
|
||||
// Hiding: distinct blinding factors must move the commitment.
|
||||
blinds := map[[32]byte]uint64{}
|
||||
for b := range uint64(64) {
|
||||
c := commit(value, comWord(b))
|
||||
prev, dup := blinds[c]
|
||||
require.Falsef(t, dup, "blinding factors %d and %d collide", prev, b)
|
||||
blinds[c] = b
|
||||
}
|
||||
|
||||
// A single flipped bit in either input moves the commitment.
|
||||
for i := range 32 {
|
||||
bitValue := value
|
||||
bitValue[i] ^= 1
|
||||
require.NotEqualf(t, base, commit(bitValue, blind), "value byte %d", i)
|
||||
|
||||
bitBlind := blind
|
||||
bitBlind[i] ^= 1
|
||||
require.NotEqualf(t, base, commit(value, bitBlind), "blinding byte %d", i)
|
||||
}
|
||||
|
||||
// Value and blinding are not interchangeable.
|
||||
require.NotEqual(t, commit(comWord(1), comWord(2)), commit(comWord(2), comWord(1)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCom_CommitSchemesDoNotAgreeWithEachOther(t *testing.T) {
|
||||
value, blind := comWord(42), comWord(99)
|
||||
|
||||
pos, err := NewPoseidon2Scheme().Commit(value, blind)
|
||||
require.NoError(t, err)
|
||||
ped, err := NewPedersenScheme().Commit(value, blind)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, pos, ped, "the two schemes must produce distinct commitments")
|
||||
|
||||
// Poseidon2Scheme.Commit pins the salt to zero, so it is exactly the
|
||||
// hasher's three-input commitment with a zero third element.
|
||||
zeroSalt, err := NewPoseidon2Hasher().Commitment(value, blind, [32]byte{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pos, zeroSalt)
|
||||
|
||||
salted, err := NewPoseidon2Hasher().Commitment(value, blind, comWord(1))
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, pos, salted, "a non-zero salt must not reproduce the scheme's commitment")
|
||||
}
|
||||
|
||||
func TestCom_CommitIsOnlyBindingModuloTheScalarField(t *testing.T) {
|
||||
// Both schemes parse their 32-byte inputs as fr.Element, which reduces mod
|
||||
// the BN254 scalar modulus, and neither rejects a non-canonical word. So a
|
||||
// value equal to the modulus is indistinguishable from zero.
|
||||
modulus := comFieldModulus()
|
||||
blind := comWord(99)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fresh func() CommitmentScheme
|
||||
}{
|
||||
{"poseidon2", func() CommitmentScheme { return NewPoseidon2Scheme() }},
|
||||
{"pedersen", func() CommitmentScheme { return NewPedersenScheme() }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
zero, err := tc.fresh().Commit([32]byte{}, blind)
|
||||
require.NoError(t, err)
|
||||
aliased, err := tc.fresh().Commit(modulus, blind)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, zero, aliased,
|
||||
"value 0 and value r collide; if this now differs the scheme grew canonical-input validation")
|
||||
|
||||
// The same aliasing applies to the blinding factor.
|
||||
b0, err := tc.fresh().Commit(comWord(1), [32]byte{})
|
||||
require.NoError(t, err)
|
||||
bR, err := tc.fresh().Commit(comWord(1), modulus)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, b0, bR)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCom_ReusingASchemeChangesNothing(t *testing.T) {
|
||||
// Poseidon2Scheme memoises inside its hasher. Memoisation must be
|
||||
// invisible: a reused instance must answer exactly as a cold one, and two
|
||||
// inputs that agree on their leading bytes must not be conflated. Commit
|
||||
// lays its input out as value ‖ blinding ‖ salt, so a memo that looked
|
||||
// only at the front would return the first blinding factor's commitment
|
||||
// for every later one and the scheme would stop hiding.
|
||||
scheme := NewPoseidon2Scheme()
|
||||
value := comWord(0xc0ffee)
|
||||
|
||||
first, err := scheme.Commit(value, comWord(1))
|
||||
require.NoError(t, err)
|
||||
second, err := scheme.Commit(value, comWord(2))
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, first, second, "a reused instance must still see the blinding factor")
|
||||
|
||||
// Each answer matches what a cold instance computes, so history cannot
|
||||
// change the result.
|
||||
for _, tc := range []struct {
|
||||
blind [32]byte
|
||||
want [32]byte
|
||||
}{{comWord(1), first}, {comWord(2), second}} {
|
||||
cold, err := NewPoseidon2Scheme().Commit(value, tc.blind)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, cold)
|
||||
}
|
||||
|
||||
// Replaying the first input still returns the first answer.
|
||||
replay, err := scheme.Commit(value, comWord(1))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first, replay)
|
||||
}
|
||||
|
||||
func TestCom_DefaultSchemeAgreesWithAColdScheme(t *testing.T) {
|
||||
// DefaultScheme is a package-level Poseidon2Scheme whose hasher lives for
|
||||
// the life of the process, so it accumulates every note the process has
|
||||
// ever committed. Notes sharing an amount must still commit differently:
|
||||
// NoteCommitment lays its input out as amount ‖ asset ‖ owner ‖ blinding,
|
||||
// and everything after the amount has to count.
|
||||
amount := big.NewInt(0x1005_2026)
|
||||
|
||||
first, err := DefaultScheme.NoteCommitment(amount, comWord(1), comAddr(1), comWord(1))
|
||||
require.NoError(t, err)
|
||||
second, err := DefaultScheme.NoteCommitment(amount, comWord(2), comAddr(2), comWord(2))
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, first, second, "two owners must not share a note commitment")
|
||||
|
||||
coldA, err := NewPoseidon2Scheme().NoteCommitment(amount, comWord(1), comAddr(1), comWord(1))
|
||||
require.NoError(t, err)
|
||||
coldB, err := NewPoseidon2Scheme().NoteCommitment(amount, comWord(2), comAddr(2), comWord(2))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, coldA, first, "the shared scheme must agree with a cold one")
|
||||
require.Equal(t, coldB, second)
|
||||
|
||||
// Each field after the amount moves the commitment on its own.
|
||||
for name, note := range map[string][3]any{
|
||||
"asset": {comWord(9), comAddr(1), comWord(1)},
|
||||
"owner": {comWord(1), comAddr(9), comWord(1)},
|
||||
"blinding": {comWord(1), comAddr(1), comWord(9)},
|
||||
} {
|
||||
got, err := DefaultScheme.NoteCommitment(amount, note[0].([32]byte), note[1].(common.Address), note[2].([32]byte))
|
||||
require.NoError(t, err)
|
||||
require.NotEqualf(t, first, got, "changing %s must move the note commitment", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scheme metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCom_RequiredGasIsNeverFreeAndRanksTheSchemes(t *testing.T) {
|
||||
pos := NewPoseidon2Scheme()
|
||||
ped := NewPedersenScheme()
|
||||
|
||||
require.Positive(t, pos.RequiredGas(), "a commitment is never free")
|
||||
require.Positive(t, ped.RequiredGas())
|
||||
|
||||
// The curve scheme does two scalar multiplications and a point addition;
|
||||
// the hash scheme does neither. Pricing must reflect that ordering.
|
||||
require.Greater(t, ped.RequiredGas(), pos.RequiredGas(),
|
||||
"the pre-quantum curve scheme must not be priced below the hash scheme")
|
||||
|
||||
// Pricing carries no state: same answer from every instance and call.
|
||||
require.Equal(t, pos.RequiredGas(), NewPoseidon2Scheme().RequiredGas())
|
||||
require.Equal(t, ped.RequiredGas(), NewPedersenScheme().RequiredGas())
|
||||
require.Equal(t, pos.RequiredGas(), pos.RequiredGas())
|
||||
|
||||
// Gas and post-quantum safety travel together: the cheap scheme is the
|
||||
// PQ-safe one, which is why it is the default.
|
||||
require.True(t, pos.IsPQSafe())
|
||||
require.False(t, ped.IsPQSafe())
|
||||
require.True(t, DefaultScheme.IsPQSafe(), "the default must be the post-quantum-safe scheme")
|
||||
require.Equal(t, pos.RequiredGas(), DefaultScheme.RequiredGas())
|
||||
}
|
||||
|
||||
func TestCom_GetScheme(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in SchemeType
|
||||
pqSafe bool
|
||||
wantErr bool
|
||||
gas uint64
|
||||
concrete any
|
||||
}{
|
||||
{name: "poseidon2", in: SchemePoseidon2, pqSafe: true, gas: NewPoseidon2Scheme().RequiredGas(), concrete: &Poseidon2Scheme{}},
|
||||
{name: "pedersen", in: SchemePedersen, pqSafe: false, gas: NewPedersenScheme().RequiredGas(), concrete: &PedersenScheme{}},
|
||||
{name: "next unused id", in: SchemeType(2), wantErr: true},
|
||||
{name: "max id", in: SchemeType(255), wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := GetScheme(tc.in)
|
||||
if tc.wantErr {
|
||||
require.ErrorContains(t, err, "unknown commitment scheme")
|
||||
require.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
require.IsType(t, tc.concrete, got)
|
||||
require.Equal(t, tc.pqSafe, got.IsPQSafe())
|
||||
require.Equal(t, tc.gas, got.RequiredGas())
|
||||
})
|
||||
}
|
||||
|
||||
// Every call hands back a fresh scheme, so callers cannot poison each
|
||||
// other through a shared instance.
|
||||
a, err := GetScheme(SchemePoseidon2)
|
||||
require.NoError(t, err)
|
||||
b, err := GetScheme(SchemePoseidon2)
|
||||
require.NoError(t, err)
|
||||
require.NotSame(t, a, b)
|
||||
|
||||
// Zero is the post-quantum-safe identifier, so a zero-valued NoteInput
|
||||
// resolves to the safe scheme rather than failing.
|
||||
require.Equal(t, SchemePoseidon2, SchemeType(0))
|
||||
zeroValued, err := GetScheme(NoteInput{}.SchemeType)
|
||||
require.NoError(t, err)
|
||||
require.True(t, zeroValued.IsPQSafe())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateNote
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCom_CreateNoteCarriesItsInputsAndCommits(t *testing.T) {
|
||||
for _, scheme := range []SchemeType{SchemePoseidon2, SchemePedersen} {
|
||||
in := comNote()
|
||||
in.SchemeType = scheme
|
||||
|
||||
note, err := CreateNote(in)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, note)
|
||||
|
||||
require.Equal(t, in.Amount, note.Amount)
|
||||
require.Equal(t, in.AssetID, note.AssetID)
|
||||
require.Equal(t, in.Owner, note.Owner)
|
||||
require.Equal(t, in.BlindingFactor, note.BlindingFactor)
|
||||
require.Equal(t, in.SchemeType, note.SchemeType)
|
||||
require.Zero(t, note.LeafIndex, "leaf index is assigned on tree insertion, not at creation")
|
||||
require.NotEqual(t, [32]byte{}, note.Commitment)
|
||||
|
||||
// The commitment is the chosen scheme's own note commitment.
|
||||
s, err := GetScheme(scheme)
|
||||
require.NoError(t, err)
|
||||
want, err := s.NoteCommitment(in.Amount, in.AssetID, in.Owner, in.BlindingFactor)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, note.Commitment)
|
||||
}
|
||||
|
||||
// The two schemes commit to the same note differently.
|
||||
pos, err := CreateNote(comNote())
|
||||
require.NoError(t, err)
|
||||
pedIn := comNote()
|
||||
pedIn.SchemeType = SchemePedersen
|
||||
ped, err := CreateNote(pedIn)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, pos.Commitment, ped.Commitment)
|
||||
}
|
||||
|
||||
func TestCom_CreateNoteRejectsAnUnknownScheme(t *testing.T) {
|
||||
for _, bad := range []SchemeType{2, 3, 128, 255} {
|
||||
in := comNote()
|
||||
in.SchemeType = bad
|
||||
note, err := CreateNote(in)
|
||||
require.ErrorContains(t, err, "unknown commitment scheme")
|
||||
require.Nil(t, note, "a failed creation must not hand back a half-built note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCom_CreateNoteBindsEveryField(t *testing.T) {
|
||||
base, err := CreateNote(comNote())
|
||||
require.NoError(t, err)
|
||||
|
||||
for name, mutate := range map[string]func(*NoteInput){
|
||||
"amount": func(n *NoteInput) { n.Amount = big.NewInt(1_000_001) },
|
||||
"asset": func(n *NoteInput) { n.AssetID = comWord(0xa55e8) },
|
||||
"owner": func(n *NoteInput) { n.Owner = comAddr(8) },
|
||||
"blinding": func(n *NoteInput) { n.BlindingFactor = comWord(0xb11e) },
|
||||
"scheme": func(n *NoteInput) { n.SchemeType = SchemePedersen },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
in := comNote()
|
||||
mutate(&in)
|
||||
note, err := CreateNote(in)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, base.Commitment, note.Commitment,
|
||||
"changing %s must move the commitment", name)
|
||||
})
|
||||
}
|
||||
|
||||
// Zero is a legal amount and still commits to something distinct.
|
||||
in := comNote()
|
||||
in.Amount = big.NewInt(0)
|
||||
zero, err := CreateNote(in)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, base.Commitment, zero.Commitment)
|
||||
|
||||
// CreateNote takes a fresh scheme per call, so it does not inherit the
|
||||
// shared-instance cache collapse that DefaultScheme suffers.
|
||||
sameAmount := comNote()
|
||||
sameAmount.Owner = comAddr(9)
|
||||
other, err := CreateNote(sameAmount)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, base.Commitment, other.Commitment)
|
||||
}
|
||||
|
||||
func TestCom_CreateNotePanicsOnAnUnvalidatedAmount(t *testing.T) {
|
||||
// Amount is copied into a fixed 32-byte window with no width check, so a
|
||||
// wider value slices out of range, and a nil Amount is dereferenced. Both
|
||||
// are reachable straight from a caller-supplied NoteInput.
|
||||
for _, scheme := range []SchemeType{SchemePoseidon2, SchemePedersen} {
|
||||
wide := comNote()
|
||||
wide.SchemeType = scheme
|
||||
wide.Amount = new(big.Int).Lsh(big.NewInt(1), 256) // 33 bytes
|
||||
require.Panics(t, func() { _, _ = CreateNote(wide) })
|
||||
|
||||
missing := comNote()
|
||||
missing.SchemeType = scheme
|
||||
missing.Amount = nil
|
||||
require.Panics(t, func() { _, _ = CreateNote(missing) })
|
||||
}
|
||||
|
||||
// One bit below the window is accepted, which locates the boundary.
|
||||
edge := comNote()
|
||||
edge.Amount = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
||||
_, err := CreateNote(edge)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A negative amount takes the absolute value's bytes: -1 commits exactly
|
||||
// as 1 does, so sign is not carried into the commitment.
|
||||
neg := comNote()
|
||||
neg.Amount = big.NewInt(-1)
|
||||
negNote, err := CreateNote(neg)
|
||||
require.NoError(t, err)
|
||||
pos := comNote()
|
||||
pos.Amount = big.NewInt(1)
|
||||
posNote, err := CreateNote(pos)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, posNote.Commitment, negNote.Commitment,
|
||||
"big.Int.Bytes drops the sign; if this now differs the amount grew a sign check")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nullifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCom_NullifierRoutesThroughTheGlobalHasher(t *testing.T) {
|
||||
// Every branch of Nullifier hashes exactly (key, commitment, leafIndex).
|
||||
//
|
||||
// The expected value is taken from a PRIVATE hasher: the global one keys
|
||||
// its memo on the leading 32 bytes plus the length, which for a nullifier
|
||||
// is the key alone, so an oracle read back through the global hasher would
|
||||
// answer from the memo and agree with any arguments at all. Each scheme
|
||||
// gets its own nullifier key so the global's first call is a genuine
|
||||
// computation rather than a memo hit.
|
||||
note, err := CreateNote(comNote())
|
||||
require.NoError(t, err)
|
||||
note.LeafIndex = 17
|
||||
|
||||
for i, scheme := range []SchemeType{SchemePoseidon2, SchemePedersen, SchemeType(200)} {
|
||||
key := comWord(0x40de_0000 + uint64(i)) // used nowhere else in the package
|
||||
n := *note
|
||||
n.SchemeType = scheme
|
||||
|
||||
want, err := NewPoseidon2Hasher().NullifierHash(key, n.Commitment, n.LeafIndex)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := n.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
require.Equalf(t, want, got, "scheme %d must not change the nullifier derivation", scheme)
|
||||
}
|
||||
|
||||
// The leaf index is part of the derivation, not decoration: a note at a
|
||||
// different position in the tree hashes different data.
|
||||
moved := *note
|
||||
moved.LeafIndex = 18
|
||||
shifted, err := NewPoseidon2Hasher().NullifierHash(comWord(0x40de_0000), moved.Commitment, moved.LeafIndex)
|
||||
require.NoError(t, err)
|
||||
original, err := NewPoseidon2Hasher().NullifierHash(comWord(0x40de_0000), note.Commitment, note.LeafIndex)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, original, shifted)
|
||||
}
|
||||
|
||||
func TestCom_NullifierIsDeterministicAndBindsItsKey(t *testing.T) {
|
||||
note, err := CreateNote(comNote())
|
||||
require.NoError(t, err)
|
||||
key := comWord(0x4e17)
|
||||
|
||||
base, err := note.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, [32]byte{}, base)
|
||||
|
||||
for range 8 {
|
||||
again, err := note.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, base, again, "the nullifier is a pure function of key, commitment and leaf index")
|
||||
}
|
||||
|
||||
// Each of the three inputs binds on its own.
|
||||
t.Run("key", func(t *testing.T) {
|
||||
seen := map[[32]byte]uint64{}
|
||||
for k := range uint64(64) {
|
||||
n, err := note.Nullifier(comWord(k))
|
||||
require.NoError(t, err)
|
||||
prev, dup := seen[n]
|
||||
require.Falsef(t, dup, "nullifier keys %d and %d collide", prev, k)
|
||||
seen[n] = k
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaf index", func(t *testing.T) {
|
||||
seen := map[[32]byte]uint64{}
|
||||
for i := range uint64(64) {
|
||||
moved := *note
|
||||
moved.LeafIndex = i
|
||||
n, err := moved.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
prev, dup := seen[n]
|
||||
require.Falsef(t, dup, "leaf indices %d and %d collide", prev, i)
|
||||
seen[n] = i
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commitment", func(t *testing.T) {
|
||||
for i := range 32 {
|
||||
other := *note
|
||||
other.Commitment[i] ^= 1
|
||||
n, err := other.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
require.NotEqualf(t, base, n, "commitment byte %d", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCom_NullifierIgnoresTheSchemeType(t *testing.T) {
|
||||
// Both branches hash with Poseidon2 by design: a nullifier is a hash, not
|
||||
// a commitment, so it stays post-quantum safe even for Pedersen notes. The
|
||||
// scheme tag is therefore not domain-separated into the nullifier, and a
|
||||
// note relabelled after creation nullifies identically.
|
||||
key := comWord(0x4e17)
|
||||
note, err := CreateNote(comNote())
|
||||
require.NoError(t, err)
|
||||
|
||||
viaPoseidon, err := note.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
relabelled := *note
|
||||
relabelled.SchemeType = SchemePedersen
|
||||
viaPedersen, err := relabelled.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, viaPoseidon, viaPedersen)
|
||||
|
||||
// Including a tag GetScheme would refuse: Nullifier never consults it.
|
||||
unknown := *note
|
||||
unknown.SchemeType = SchemeType(200)
|
||||
viaUnknown, err := unknown.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, viaPoseidon, viaUnknown)
|
||||
|
||||
// A genuine Pedersen note commits differently, so it nullifies
|
||||
// differently: the scheme reaches the nullifier only through the
|
||||
// commitment, never as a tag of its own.
|
||||
in := comNote()
|
||||
in.SchemeType = SchemePedersen
|
||||
pedNote, err := CreateNote(in)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, note.Commitment, pedNote.Commitment)
|
||||
|
||||
pedNullifier, err := pedNote.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, viaPoseidon, pedNullifier)
|
||||
}
|
||||
|
||||
func TestCom_TwoNotesUnderOneKeyNullifyDifferently(t *testing.T) {
|
||||
// Nullifier hashes key ‖ commitment ‖ leafIndex through a process-wide
|
||||
// hasher. Two different notes spent under the same nullifier key must not
|
||||
// collapse onto one nullifier, or spending one would mark the other spent.
|
||||
key := comWord(0x4e17)
|
||||
|
||||
a := *comMustNote(t, comNote())
|
||||
a.Commitment = comWord(0xaaaa)
|
||||
b := a
|
||||
b.Commitment = comWord(0xbbbb)
|
||||
b.LeafIndex = 1
|
||||
|
||||
require.NotEqual(t, comNullifierPreimage(key, a.Commitment, a.LeafIndex),
|
||||
comNullifierPreimage(key, b.Commitment, b.LeafIndex),
|
||||
"the two notes really do hash different data")
|
||||
|
||||
na, err := a.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
nb, err := b.Nullifier(key)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, na, nb, "one key must not nullify two notes at once")
|
||||
|
||||
// Each answer is what a cold hasher computes, so a long-lived process
|
||||
// cannot drift from a fresh one.
|
||||
coldA, err := NewPoseidon2Hasher().NullifierHash(key, a.Commitment, a.LeafIndex)
|
||||
require.NoError(t, err)
|
||||
coldB, err := NewPoseidon2Hasher().NullifierHash(key, b.Commitment, b.LeafIndex)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, coldA, na)
|
||||
require.Equal(t, coldB, nb)
|
||||
}
|
||||
|
||||
// comMustNote builds a note or fails the test.
|
||||
func comMustNote(t *testing.T, in NoteInput) *Note {
|
||||
t.Helper()
|
||||
n, err := CreateNote(in)
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComputeReceiptID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCom_ReceiptIDHashesTheDeclaredPreimage(t *testing.T) {
|
||||
// Pin the exact encoding: field order, big-endian chain ids, and the
|
||||
// zero-padding up to a 32-byte multiple.
|
||||
//
|
||||
// The expected digest is taken from a PRIVATE hasher, and the receipt uses
|
||||
// a merkle root that appears nowhere else, so the global hasher's memo —
|
||||
// keyed on the root and the length — cannot answer either side from cache
|
||||
// and hide a disagreement in the bytes between them.
|
||||
r := comReceipt()
|
||||
r.MerkleRoot = comWord(0x5eed_1234)
|
||||
preimage := comPreimage(r)
|
||||
// 144 bytes of fixed fields plus 32 per nullifier is always 16 short of a
|
||||
// 32-byte multiple, so every receipt is padded by exactly 16 zero bytes.
|
||||
require.Len(t, preimage, 224, "2 nullifiers: 208 bytes of data padded to 224")
|
||||
require.Zero(t, len(preimage)%32)
|
||||
require.Equal(t, make([]byte, 16), preimage[208:], "the tail is zero padding")
|
||||
|
||||
want, err := NewPoseidon2Hasher().Hash(preimage)
|
||||
require.NoError(t, err)
|
||||
got, err := r.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
|
||||
// The 16 trailing bytes of a no-nullifier receipt are pad, not data.
|
||||
empty := comReceipt()
|
||||
empty.MerkleRoot = comWord(0x5eed_5678)
|
||||
empty.Nullifiers = nil
|
||||
pre := comPreimage(empty)
|
||||
require.Len(t, pre, 160)
|
||||
require.Equal(t, make([]byte, 16), pre[144:], "the tail is zero padding")
|
||||
|
||||
wantEmpty, err := NewPoseidon2Hasher().Hash(pre)
|
||||
require.NoError(t, err)
|
||||
gotEmpty, err := empty.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantEmpty, gotEmpty)
|
||||
|
||||
// The two chain ids sit at fixed offsets in big-endian order, ahead of the
|
||||
// circuit id. Reading them back out of the preimage pins the layout
|
||||
// independently of the digest.
|
||||
off := 32 + 32*len(r.Nullifiers) + 64
|
||||
require.EqualValues(t, r.SourceChainID, binary.BigEndian.Uint64(preimage[off:off+8]))
|
||||
require.EqualValues(t, r.TargetChainID, binary.BigEndian.Uint64(preimage[off+8:off+16]))
|
||||
require.Equal(t, r.CircuitID[:], preimage[off+16:off+48])
|
||||
}
|
||||
|
||||
func TestCom_ReceiptIDIsDeterministic(t *testing.T) {
|
||||
r := comReceipt()
|
||||
first, err := r.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, [32]byte{}, first)
|
||||
|
||||
for range 8 {
|
||||
again, err := r.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first, again)
|
||||
}
|
||||
|
||||
// A separately built but identical receipt agrees.
|
||||
other := comReceipt()
|
||||
sameAgain, err := other.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first, sameAgain)
|
||||
}
|
||||
|
||||
func TestCom_ReceiptPreimageBindsSevenFields(t *testing.T) {
|
||||
// What the digest covers, checked on the preimage so the result does not
|
||||
// depend on the hasher's memoisation.
|
||||
base := comPreimage(comReceipt())
|
||||
|
||||
seen := map[string]string{string(base): "base"}
|
||||
for name, mutate := range map[string]func(*ValidityReceipt){
|
||||
"merkle root": func(r *ValidityReceipt) { r.MerkleRoot = comWord(0x1111) },
|
||||
"nullifier value": func(r *ValidityReceipt) { r.Nullifiers[0] = comWord(0x2222) },
|
||||
"nullifier order": func(r *ValidityReceipt) { r.Nullifiers[0], r.Nullifiers[1] = r.Nullifiers[1], r.Nullifiers[0] },
|
||||
"nullifier added": func(r *ValidityReceipt) { r.Nullifiers = append(r.Nullifiers, comWord(0x3333)) },
|
||||
"nullifier removed": func(r *ValidityReceipt) { r.Nullifiers = r.Nullifiers[:1] },
|
||||
"all nullifiers removed": func(r *ValidityReceipt) { r.Nullifiers = nil },
|
||||
"pool id": func(r *ValidityReceipt) { r.PoolID = comWord(0x4444) },
|
||||
"asset id": func(r *ValidityReceipt) { r.AssetID = comWord(0x5555) },
|
||||
"source chain": func(r *ValidityReceipt) { r.SourceChainID = 0x6666 },
|
||||
"target chain": func(r *ValidityReceipt) { r.TargetChainID = 0x7777 },
|
||||
"chains swapped": func(r *ValidityReceipt) { r.SourceChainID, r.TargetChainID = r.TargetChainID, r.SourceChainID },
|
||||
"circuit id": func(r *ValidityReceipt) { r.CircuitID = comWord(0x8888) },
|
||||
"high chain byte": func(r *ValidityReceipt) { r.SourceChainID |= 1 << 56 },
|
||||
} {
|
||||
r := comReceipt()
|
||||
mutate(&r)
|
||||
got := string(comPreimage(r))
|
||||
if prev, dup := seen[got]; dup {
|
||||
t.Fatalf("changing %q leaves the same preimage as %q", name, prev)
|
||||
}
|
||||
seen[got] = name
|
||||
}
|
||||
}
|
||||
|
||||
func TestCom_ReceiptIDExcludesFourFields(t *testing.T) {
|
||||
// The digest covers root, nullifiers, pool, asset, both chain ids and the
|
||||
// circuit id. Timestamp, ProofType, ZKProofDigest and the previous
|
||||
// ReceiptID are outside it, so receipts differing only in those share an
|
||||
// identifier. Most consequentially, a STARK receipt and a Groth16 receipt
|
||||
// over the same batch are the same receipt.
|
||||
base := comReceipt()
|
||||
baseID, err := base.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
basePre := comPreimage(base)
|
||||
|
||||
for name, mutate := range map[string]func(*ValidityReceipt){
|
||||
"timestamp": func(r *ValidityReceipt) { r.Timestamp = 0xffff },
|
||||
"proof type": func(r *ValidityReceipt) { r.ProofType = ProofTypeGroth16 },
|
||||
"proof digest": func(r *ValidityReceipt) { r.ZKProofDigest = comWord(0xbeef) },
|
||||
"prior receipt": func(r *ValidityReceipt) { r.ReceiptID = comWord(0xfeed) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := comReceipt()
|
||||
mutate(&r)
|
||||
require.Equal(t, basePre, comPreimage(r), "%s never reaches the preimage", name)
|
||||
|
||||
id, err := r.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, baseID, id,
|
||||
"%s is outside the digest; if this now differs the preimage grew and this test should assert inequality", name)
|
||||
})
|
||||
}
|
||||
require.NotEqual(t, ProofTypeSTARK, ProofTypeGroth16, "two distinct proof types that hash the same")
|
||||
}
|
||||
|
||||
func TestCom_ReceiptIDBindsEveryHashedField(t *testing.T) {
|
||||
// The preimage test above proves the encoding covers seven fields. This
|
||||
// carries that through the digest: every one of them must move the
|
||||
// identifier a chain will key its bridge state on.
|
||||
base := comReceipt()
|
||||
baseID, err := base.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
|
||||
seen := map[[32]byte]string{baseID: "base"}
|
||||
for name, mutate := range map[string]func(*ValidityReceipt){
|
||||
"merkle root": func(r *ValidityReceipt) { r.MerkleRoot = comWord(0x1111) },
|
||||
"nullifier value": func(r *ValidityReceipt) { r.Nullifiers[0] = comWord(0x2222) },
|
||||
"nullifier order": func(r *ValidityReceipt) { r.Nullifiers[0], r.Nullifiers[1] = r.Nullifiers[1], r.Nullifiers[0] },
|
||||
"nullifier added": func(r *ValidityReceipt) { r.Nullifiers = append(r.Nullifiers, comWord(0x3333)) },
|
||||
"nullifier removed": func(r *ValidityReceipt) { r.Nullifiers = r.Nullifiers[:1] },
|
||||
"all nullifiers removed": func(r *ValidityReceipt) { r.Nullifiers = nil },
|
||||
"pool id": func(r *ValidityReceipt) { r.PoolID = comWord(0x4444) },
|
||||
"asset id": func(r *ValidityReceipt) { r.AssetID = comWord(0x5555) },
|
||||
"source chain": func(r *ValidityReceipt) { r.SourceChainID = 0x6666 },
|
||||
"target chain": func(r *ValidityReceipt) { r.TargetChainID = 0x7777 },
|
||||
"chains swapped": func(r *ValidityReceipt) { r.SourceChainID, r.TargetChainID = r.TargetChainID, r.SourceChainID },
|
||||
"circuit id": func(r *ValidityReceipt) { r.CircuitID = comWord(0x8888) },
|
||||
"high chain byte": func(r *ValidityReceipt) { r.SourceChainID |= 1 << 56 },
|
||||
} {
|
||||
r := comReceipt()
|
||||
mutate(&r)
|
||||
id, err := r.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
if prev, dup := seen[id]; dup {
|
||||
t.Fatalf("changing %q gives the same receipt id as %q", name, prev)
|
||||
}
|
||||
seen[id] = name
|
||||
}
|
||||
|
||||
// Two receipts differing everywhere at once are also distinct, and each
|
||||
// agrees with a cold hasher, so a long-lived process cannot drift.
|
||||
other := comReceipt()
|
||||
other.PoolID, other.AssetID, other.CircuitID = comWord(0x4444), comWord(0x5555), comWord(0x8888)
|
||||
other.SourceChainID = 0x6666
|
||||
otherID, err := other.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, baseID, otherID)
|
||||
|
||||
cold, err := NewPoseidon2Hasher().Hash(comPreimage(other))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cold, otherID)
|
||||
}
|
||||
|
||||
func TestCom_ReceiptIDRunsOutOfRoomAtTwelveNullifiers(t *testing.T) {
|
||||
// The preimage is 144 bytes plus 32 per nullifier, padded up to a 32-byte
|
||||
// multiple, and Poseidon2 takes at most 16 field elements. That caps a
|
||||
// receipt at 11 nullifiers; the twelfth is refused rather than truncated.
|
||||
for k := range 12 {
|
||||
r := comReceipt()
|
||||
r.Nullifiers = make([][32]byte, k)
|
||||
for i := range r.Nullifiers {
|
||||
r.Nullifiers[i] = comWord(uint64(i) + 100)
|
||||
}
|
||||
require.LessOrEqual(t, len(comPreimage(r))/32, 16)
|
||||
|
||||
id, err := r.ComputeReceiptID()
|
||||
require.NoErrorf(t, err, "%d nullifiers must fit", k)
|
||||
require.NotEqual(t, [32]byte{}, id)
|
||||
}
|
||||
|
||||
for _, k := range []int{12, 13, 40} {
|
||||
r := comReceipt()
|
||||
r.Nullifiers = make([][32]byte, k)
|
||||
require.Greater(t, len(comPreimage(r))/32, 16)
|
||||
|
||||
id, err := r.ComputeReceiptID()
|
||||
require.ErrorIsf(t, err, ErrTooManyInputs, "%d nullifiers must be refused", k)
|
||||
require.Equal(t, [32]byte{}, id, "a refused receipt must not hand back a usable id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCom_ReceiptIDDoesNotMutateTheReceipt(t *testing.T) {
|
||||
r := comReceipt()
|
||||
before := comReceipt()
|
||||
|
||||
_, err := r.ComputeReceiptID()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before, r, "computing the id must not write back into the receipt")
|
||||
|
||||
// In particular it does not populate ReceiptID itself; that is the
|
||||
// caller's job.
|
||||
require.Equal(t, comWord(0xdead), r.ReceiptID)
|
||||
}
|
||||
+119
-31
@@ -27,6 +27,18 @@ var (
|
||||
ErrVerifierRequired = errors.New("verifier context required")
|
||||
ErrInvalidCommitmentLen = errors.New("invalid commitment length")
|
||||
ErrFflonkDisabled = errors.New("fflonk verification disabled: unsound verifier (reserved opcode 0x03)")
|
||||
|
||||
// ErrRangeProofUnavailable is returned by op 0x23 when no bulletproof
|
||||
// verifier is wired. It fails closed — an unverifiable range proof is
|
||||
// never reported as valid — and is a named sentinel so callers can
|
||||
// distinguish "no verifier here" from "this proof is bad", and so a
|
||||
// test can pin the refusal.
|
||||
ErrRangeProofUnavailable = errors.New("range proof: no bulletproof verifier registered")
|
||||
|
||||
// ErrHalo2KeyIncomplete is returned when a Halo2 proof reaches the
|
||||
// keyed path but the verifying key carries no IPA commitment key, so
|
||||
// the closing equation cannot be evaluated. Fails closed.
|
||||
ErrHalo2KeyIncomplete = errors.New("halo2: verifying key carries no IPA commitment key")
|
||||
)
|
||||
|
||||
// Operation selectors (first byte of input)
|
||||
@@ -67,14 +79,25 @@ func (p *zkVerifyPrecompile) Address() common.Address {
|
||||
return ZKVerifyContractAddress
|
||||
}
|
||||
|
||||
// MinCallGas is charged for any precompile call, including unknown opcodes,
|
||||
// to prevent zero-cost abuse.
|
||||
// MinCallGas is the floor charged for any precompile call, including
|
||||
// unknown opcodes, to prevent zero-cost abuse.
|
||||
const MinCallGas uint64 = 21_000
|
||||
|
||||
// RequiredGas calculates gas for ZK operations
|
||||
// RequiredGas calculates gas for ZK operations.
|
||||
//
|
||||
// The floor is applied here, once, so no branch of the schedule can
|
||||
// undercut it: price(op) is the per-op question, MinCallGas is the
|
||||
// policy, and the two are decided in separate places.
|
||||
func (p *zkVerifyPrecompile) RequiredGas(input []byte) uint64 {
|
||||
return max(price(input), MinCallGas)
|
||||
}
|
||||
|
||||
// price returns the schedule cost of an op, with no knowledge of the
|
||||
// floor. Zero means "this op carries no cost of its own" — an unknown
|
||||
// selector, or an input too short to name one.
|
||||
func price(input []byte) uint64 {
|
||||
if len(input) < 1 {
|
||||
return MinCallGas
|
||||
return 0
|
||||
}
|
||||
|
||||
op := input[0]
|
||||
@@ -112,24 +135,78 @@ func (p *zkVerifyPrecompile) RequiredGas(input []byte) uint64 {
|
||||
return GasCommitmentBase
|
||||
|
||||
case OpVerifyBatch:
|
||||
if len(input) < 5 {
|
||||
return MinCallGas
|
||||
}
|
||||
numProofs := binary.BigEndian.Uint32(input[1:5])
|
||||
return uint64(numProofs) * GasPerBatchProof
|
||||
// A zero count yields zero here; the floor in RequiredGas turns that
|
||||
// into MinCallGas, so an empty batch cannot buy a free dispatch into
|
||||
// verifyBatch. No overflow is reachable: the count is a uint32 capped
|
||||
// by the input length, so the product stays below
|
||||
// 2^32 * GasPerBatchProof, itself far below 2^64.
|
||||
return uint64(countBatchProofs(input)) * GasPerBatchProof
|
||||
|
||||
default:
|
||||
return MinCallGas
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// countPublicInputs extracts number of public inputs from encoded data
|
||||
// Wire offsets, within the FULL precompile input, of the header shared by
|
||||
// ops 0x01-0x04 (Groth16, PLONK, fflonk, Halo2). Stated once so the gas
|
||||
// schedule and the verifiers cannot drift apart:
|
||||
//
|
||||
// [0:1] op selector
|
||||
// [1:33] vkID
|
||||
// [33:37] numInputs (big-endian uint32)
|
||||
// [37:37+numInputs*32] public inputs, 32 bytes each
|
||||
//
|
||||
// Each verifier is handed input[1:], so it reads numInputs at [32:36] of
|
||||
// its own slice — verifyGroth16, verifyPLONK, verifyFflonk and
|
||||
// parseHalo2Proof all agree on that. RequiredGas sees the whole input and
|
||||
// must therefore read at [33:37].
|
||||
const (
|
||||
numInputsOff = 1 + 32 // 33
|
||||
inputsOff = numInputsOff + 4 // 37
|
||||
fieldLen = 32
|
||||
)
|
||||
|
||||
// countPublicInputs returns how many public inputs the verifier will parse.
|
||||
//
|
||||
// It reads the SAME wire field the verifiers read. Reading any other field
|
||||
// decouples price from work in both directions: execution that is not paid
|
||||
// for, and a valid proof priced out of the block.
|
||||
//
|
||||
// The claimed count is capped by what the calldata can actually hold. A
|
||||
// count above that is refused by the verifier in constant time (its
|
||||
// expectedLen check), so charging for it would price work that can never
|
||||
// happen. With the cap, gas is bounded by the length of the input.
|
||||
func countPublicInputs(input []byte) int {
|
||||
if len(input) < 5 {
|
||||
if len(input) < inputsOff {
|
||||
return 0
|
||||
}
|
||||
// Format: [1 byte op][4 bytes num_public_inputs][...]
|
||||
return int(binary.BigEndian.Uint32(input[1:5]))
|
||||
claimed := binary.BigEndian.Uint32(input[numInputsOff:inputsOff])
|
||||
capacity := (len(input) - inputsOff) / fieldLen
|
||||
if uint64(claimed) > uint64(capacity) {
|
||||
return capacity
|
||||
}
|
||||
return int(claimed)
|
||||
}
|
||||
|
||||
// countBatchProofs returns how many proofs op 0x30 will attempt. It reads
|
||||
// the same field verifyBatch reads ([0:4] of input[1:]) and, as with
|
||||
// countPublicInputs, caps the claim by what the calldata can hold: every
|
||||
// batch entry carries at least a 1-byte type and a 4-byte length, and
|
||||
// verifyBatch stops at the first entry that runs past the end.
|
||||
func countBatchProofs(input []byte) int {
|
||||
const (
|
||||
countEnd = 5 // op selector(1) + numProofs(4)
|
||||
entryHdr = 5 // proofType(1) + proofDataLen(4)
|
||||
)
|
||||
if len(input) < countEnd {
|
||||
return 0
|
||||
}
|
||||
claimed := binary.BigEndian.Uint32(input[1:countEnd])
|
||||
capacity := (len(input) - countEnd) / entryHdr
|
||||
if uint64(claimed) > uint64(capacity) {
|
||||
return capacity
|
||||
}
|
||||
return int(claimed)
|
||||
}
|
||||
|
||||
// Run executes the ZK verify precompile
|
||||
@@ -1143,19 +1220,17 @@ func verifyHalo2IPA(proof *Halo2Proof, vk *VerifyingKey, commitment []byte, chal
|
||||
)
|
||||
}
|
||||
|
||||
// Compute folding scalars for generator and b vector
|
||||
// In full implementation, we'd verify:
|
||||
// g0 * a + (a * b0) * Q == foldedCommitment
|
||||
// The closing equation is g0*a + (a*b0)*Q == foldedCommitment, where g0
|
||||
// is the folded SRS generator and Q the commitment key's auxiliary
|
||||
// point. Neither is available: VerifyingKey is Groth16-shaped
|
||||
// (Alpha/Beta/Gamma/Delta/IC) and carries no IPA commitment key. The
|
||||
// fold above is therefore evidence, not a verdict.
|
||||
//
|
||||
// For structural validation, we verify the math is consistent
|
||||
_ = foldedCommitment // Used in full verification
|
||||
|
||||
// With a proper SRS (from vk), we would:
|
||||
// 1. Compute g0 by folding SRS generators with challenge inverses
|
||||
// 2. Compute b0 from evaluation point and challenge inverses
|
||||
// 3. Verify the final equation
|
||||
|
||||
return true, nil
|
||||
// Refuse. Returning true here — as this function did — accepted every
|
||||
// proof for every statement whenever any Halo2 key was registered,
|
||||
// because nothing downstream re-checked the result.
|
||||
_ = foldedCommitment
|
||||
return false, ErrHalo2KeyIncomplete
|
||||
}
|
||||
|
||||
// halo2Transcript implements Fiat-Shamir transcript for Halo2
|
||||
@@ -1196,6 +1271,14 @@ func sha256Hash(data []byte) []byte {
|
||||
var pallasFieldModulus, _ = new(big.Int).SetString(
|
||||
"28948022309329048855892746252171976963363056481941560715954676764349967630337", 10)
|
||||
|
||||
// pallasScalarOrder is the order q of the Pallas group — the modulus for
|
||||
// scalars (challenges, evaluations, the folding scalar), as distinct from
|
||||
// the base field p above in which point COORDINATES live. Pallas has
|
||||
// cofactor 1, so q is both the group order and the scalar field modulus.
|
||||
// q = 2^254 + 0x224698fc0994a8dd8c46eb2100000001.
|
||||
var pallasScalarOrder, _ = new(big.Int).SetString(
|
||||
"28948022309329048855892746252171976963363056481941647379679742748393362948097", 10)
|
||||
|
||||
// isValidCompressedPoint validates a 32-byte compressed Halo2 (Pallas) point.
|
||||
//
|
||||
// Encoding: 32 bytes big-endian; the top bit (0x80 of byte 0) is the y-sign
|
||||
@@ -1249,9 +1332,14 @@ func isValidScalar(s []byte) bool {
|
||||
if len(s) != 32 {
|
||||
return false
|
||||
}
|
||||
// Scalar can be zero (it's valid in field arithmetic)
|
||||
// In full implementation, check s < field_modulus
|
||||
return true
|
||||
// The encoding must be CANONICAL: s < q. Accepting s >= q makes the
|
||||
// encoding non-injective — s and s+q denote the same field element but
|
||||
// are different bytes — so a proof could be re-encoded into a distinct
|
||||
// byte string that still verifies, and any transcript hashing those
|
||||
// bytes would bind to the encoding rather than to the value. This
|
||||
// function previously checked only the length, which is no check at
|
||||
// all: every 32-byte string passed. Zero is a legitimate scalar.
|
||||
return new(big.Int).SetBytes(s).Cmp(pallasScalarOrder) < 0
|
||||
}
|
||||
|
||||
// isZeroBytes checks if all bytes are zero
|
||||
@@ -1443,8 +1531,8 @@ func (p *zkVerifyPrecompile) verifyRangeProof(data []byte) (bool, error) {
|
||||
return p.verifier.VerifyRangeProof(commitment, rangeProof, bitLength)
|
||||
}
|
||||
|
||||
// Bulletproof range proof verification not yet implemented
|
||||
return false, errors.New("range proof: bulletproof verifier not yet implemented - use dalek-cryptography/bulletproofs")
|
||||
// No verifier wired: refuse. Never report an unverified proof as valid.
|
||||
return false, ErrRangeProofUnavailable
|
||||
}
|
||||
|
||||
// verifyNullifier checks if a nullifier has been used (spent).
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/kzg4844"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/precompile/modules"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// dispatch
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TestCtr_AddressMatchesRegistration: the address the precompile reports
|
||||
// must be the one the module registry serves it at, or calls land nowhere.
|
||||
func TestCtr_AddressMatchesRegistration(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
require.Equal(t, ZKVerifyContractAddress, p.Address())
|
||||
|
||||
m, ok := modules.GetPrecompileModuleByAddress(ZKVerifyContractAddress)
|
||||
require.True(t, ok, "no module registered at the reported address")
|
||||
require.Equal(t, ConfigKey, m.ConfigKey)
|
||||
}
|
||||
|
||||
// TestCtr_EncodeBool pins the ABI word: solidity reads a bool as a
|
||||
// 32-byte big-endian word, so the flag belongs in the LAST byte. Putting
|
||||
// it anywhere else makes every caller read false.
|
||||
func TestCtr_EncodeBool(t *testing.T) {
|
||||
f := encodeBool(false)
|
||||
require.Len(t, f, 32)
|
||||
require.Equal(t, make([]byte, 32), f)
|
||||
|
||||
tr := encodeBool(true)
|
||||
require.Len(t, tr, 32)
|
||||
require.Equal(t, byte(1), tr[31], "the flag must be the low-order byte")
|
||||
require.Equal(t, make([]byte, 31), tr[:31], "the high bytes must be clear")
|
||||
|
||||
// Each call must return a fresh buffer; a shared one would let a
|
||||
// caller mutate the next result.
|
||||
a, b := encodeBool(true), encodeBool(true)
|
||||
a[0] = 0xff
|
||||
require.Equal(t, byte(0), b[0])
|
||||
}
|
||||
|
||||
// TestCtr_UnknownOpsRejected: only the declared selectors dispatch.
|
||||
func TestCtr_UnknownOpsRejected(t *testing.T) {
|
||||
known := map[byte]bool{
|
||||
OpVerifyGroth16: true, OpVerifyPLONK: true, OpVerifyFflonk: true,
|
||||
OpVerifyHalo2: true, OpVerifyKZG: true, OpVerifyIPA: true,
|
||||
OpVerifyRangeProof: true, OpVerifyNullifier: true,
|
||||
OpVerifyCommitment: true, OpVerifyBatch: true,
|
||||
}
|
||||
for op := 0; op <= 0xff; op++ {
|
||||
if known[byte(op)] {
|
||||
continue
|
||||
}
|
||||
in := make([]byte, 256)
|
||||
in[0] = byte(op)
|
||||
_, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress,
|
||||
in, 10_000_000, true)
|
||||
require.ErrorIsf(t, err, ErrInvalidOperation, "op 0x%02x dispatched", op)
|
||||
}
|
||||
|
||||
// An empty input names no op at all.
|
||||
_, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, nil, 10_000_000, true)
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// KZG (op 0x10) — real EIP-4844 proofs against the production setup
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// ctrKZG builds a genuine commitment/point/claim/proof quadruple.
|
||||
func ctrKZG(t *testing.T, vals ...uint64) (kzg4844.Commitment, kzg4844.Point, kzg4844.Claim, kzg4844.Proof) {
|
||||
t.Helper()
|
||||
var blob kzg4844.Blob
|
||||
for i, v := range vals {
|
||||
binary.BigEndian.PutUint64(blob[i*32+24:i*32+32], v)
|
||||
}
|
||||
c, err := kzg4844.BlobToCommitment(&blob)
|
||||
require.NoError(t, err)
|
||||
|
||||
var pt kzg4844.Point
|
||||
pt[31] = 0x11
|
||||
pr, claim, err := kzg4844.ComputeProof(&blob, pt)
|
||||
require.NoError(t, err)
|
||||
return c, pt, claim, pr
|
||||
}
|
||||
|
||||
func ctrKZGInput(c kzg4844.Commitment, pt kzg4844.Point, cl kzg4844.Claim, pr kzg4844.Proof) []byte {
|
||||
out := make([]byte, 0, 160)
|
||||
out = append(out, c[:]...)
|
||||
out = append(out, pt[:]...)
|
||||
out = append(out, cl[:]...)
|
||||
return append(out, pr[:]...)
|
||||
}
|
||||
|
||||
// TestCtr_KZGAcceptsRealProof is the control for the negatives below: a
|
||||
// genuine opening from the EIP-4844 trusted setup must verify through the
|
||||
// precompile, and report the ABI true word.
|
||||
func TestCtr_KZGAcceptsRealProof(t *testing.T) {
|
||||
c, pt, cl, pr := ctrKZG(t, 3, 5, 7)
|
||||
in := append([]byte{OpVerifyKZG}, ctrKZGInput(c, pt, cl, pr)...)
|
||||
|
||||
ret, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress,
|
||||
in, 10_000_000, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encodeBool(true), ret, "a genuine KZG opening must verify")
|
||||
}
|
||||
|
||||
// TestCtr_KZGRejectsEveryTamper is the soundness test: each element of a
|
||||
// valid opening is corrupted in turn, and none may still verify.
|
||||
func TestCtr_KZGRejectsEveryTamper(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
c, pt, cl, pr := ctrKZG(t, 3, 5, 7)
|
||||
good := ctrKZGInput(c, pt, cl, pr)
|
||||
|
||||
ok, err := p.verifyKZG(good)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "control: the untampered opening must verify")
|
||||
|
||||
regions := []struct {
|
||||
name string
|
||||
start, size int
|
||||
}{
|
||||
{"commitment", 0, 48},
|
||||
{"point", 48, 32},
|
||||
{"claim", 80, 32},
|
||||
{"proof", 112, 48},
|
||||
}
|
||||
for _, r := range regions {
|
||||
for _, off := range []int{0, r.size / 2, r.size - 1} {
|
||||
bad := bytes.Clone(good)
|
||||
bad[r.start+off] ^= 0x01
|
||||
ok, err := p.verifyKZG(bad)
|
||||
require.Falsef(t, ok, "a bit flip in %s at %d still verified", r.name, off)
|
||||
require.NoErrorf(t, err, "an invalid proof is a false verdict, not an error")
|
||||
}
|
||||
}
|
||||
|
||||
// A claim from a DIFFERENT polynomial must not open this commitment —
|
||||
// the statement is bound, not just the bytes.
|
||||
_, _, otherClaim, _ := ctrKZG(t, 11, 13, 17)
|
||||
swapped := bytes.Clone(good)
|
||||
copy(swapped[80:112], otherClaim[:])
|
||||
ok, _ = p.verifyKZG(swapped)
|
||||
require.False(t, ok, "a claim from another polynomial verified")
|
||||
|
||||
// All-zero input must not verify.
|
||||
ok, _ = p.verifyKZG(make([]byte, 160))
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestCtr_KZGLengthGuard: anything shorter than the fixed 160-byte layout
|
||||
// is refused before parsing, and one byte long is still accepted (trailing
|
||||
// bytes are ignored by the fixed layout).
|
||||
func TestCtr_KZGLengthGuard(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
for n := range 160 {
|
||||
_, err := p.verifyKZG(make([]byte, n))
|
||||
require.ErrorIsf(t, err, ErrInvalidInput, "%d bytes was not refused", n)
|
||||
}
|
||||
|
||||
c, pt, cl, pr := ctrKZG(t, 1, 2)
|
||||
long := append(ctrKZGInput(c, pt, cl, pr), bytes.Repeat([]byte{0xff}, 64)...)
|
||||
ok, err := p.verifyKZG(long)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "trailing bytes must not change the verdict")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// IPA (op 0x12)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TestCtr_IPAGuards walks the structural checks. The op has no verifier,
|
||||
// so the only correct outcome is a refusal — but it must refuse for the
|
||||
// RIGHT reason, or the guards are not doing their job.
|
||||
func TestCtr_IPAGuards(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
|
||||
for n := range 100 {
|
||||
_, err := p.verifyIPA(make([]byte, n))
|
||||
require.ErrorIsf(t, err, ErrInvalidInput, "%d bytes must be refused as short", n)
|
||||
}
|
||||
|
||||
mk := func(rounds uint32, total int) []byte {
|
||||
d := make([]byte, total)
|
||||
binary.BigEndian.PutUint32(d[96:100], rounds)
|
||||
return d
|
||||
}
|
||||
|
||||
// Round count must be in 1..32.
|
||||
for _, r := range []uint32{0, 33, 1 << 20, ^uint32(0)} {
|
||||
ok, err := p.verifyIPA(mk(r, 100_000))
|
||||
require.False(t, ok)
|
||||
require.Errorf(t, err, "round count %d was accepted", r)
|
||||
require.NotErrorIs(t, err, ErrInvalidProofLength)
|
||||
}
|
||||
|
||||
// In-range rounds, but the body cannot hold the L/R points.
|
||||
for _, r := range []uint32{1, 8, 32} {
|
||||
need := 100 + int(r)*128 + 32
|
||||
_, err := p.verifyIPA(mk(r, need-1))
|
||||
require.ErrorIsf(t, err, ErrInvalidProofLength, "rounds=%d short body accepted", r)
|
||||
|
||||
// Exactly enough clears the structural guards and reaches the
|
||||
// (unimplemented) verification, which refuses without claiming a
|
||||
// verdict.
|
||||
ok, err := p.verifyIPA(mk(r, need))
|
||||
require.False(t, ok)
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrInvalidProofLength)
|
||||
require.NotErrorIs(t, err, ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// nullifier (op 0x21)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TestCtr_Nullifier pins the double-spend semantics: the op answers
|
||||
// "unspent" (true) only for a nullifier the verifier has not recorded, and
|
||||
// flips to false once spent. Getting this backwards permits double spends.
|
||||
func TestCtr_Nullifier(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
p := &zkVerifyPrecompile{verifier: zv}
|
||||
|
||||
var n [32]byte
|
||||
copy(n[:], "a-nullifier-that-is-not-yet-spent")
|
||||
|
||||
ok, err := p.verifyNullifier(n[:])
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "an unspent nullifier must be usable")
|
||||
|
||||
require.NoError(t, zv.SpendNullifier(n, common.Hash{0x01}, 7))
|
||||
|
||||
ok, err = p.verifyNullifier(n[:])
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok, "a spent nullifier must be refused — this is the double-spend check")
|
||||
|
||||
// Spending twice is refused at the verifier.
|
||||
require.ErrorIs(t, zv.SpendNullifier(n, common.Hash{0x02}, 8), ErrNullifierSpent)
|
||||
|
||||
// A different nullifier is unaffected.
|
||||
var other [32]byte
|
||||
copy(other[:], "a-different-nullifier")
|
||||
ok, _ = p.verifyNullifier(other[:])
|
||||
require.True(t, ok)
|
||||
|
||||
// Only the first 32 bytes matter, and fewer is refused.
|
||||
for i := range 32 {
|
||||
_, err := p.verifyNullifier(make([]byte, i))
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
}
|
||||
ok, err = p.verifyNullifier(append(other[:], 0xff, 0xff))
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
// Without a verifier there is no state to consult, so it must refuse
|
||||
// rather than answer "unspent".
|
||||
bare := &zkVerifyPrecompile{}
|
||||
ok, err = bare.verifyNullifier(n[:])
|
||||
require.False(t, ok)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// commitment (op 0x22)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TestCtr_CommitmentPedersen exercises the Pedersen opening sub-mode.
|
||||
func TestCtr_CommitmentPedersen(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
|
||||
var value, blinding [32]byte
|
||||
value[31] = 42
|
||||
blinding[31] = 99
|
||||
commitment, err := globalPedersen.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
|
||||
build := func(c, v, b [32]byte) []byte {
|
||||
out := make([]byte, 0, 96)
|
||||
out = append(out, c[:]...)
|
||||
out = append(out, v[:]...)
|
||||
return append(out, b[:]...)
|
||||
}
|
||||
|
||||
// Route only if the discriminator does not claim it (see
|
||||
// TestCtr_CommitmentSubmodeIsAmbiguous).
|
||||
if commitment[0] != 0x01 {
|
||||
ok, err := p.verifyCommitment(build(commitment, value, blinding))
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "a correct opening must verify")
|
||||
}
|
||||
|
||||
// Wrong value, wrong blinding, wrong commitment: all refused. This is
|
||||
// the binding property — one commitment, one opening.
|
||||
var otherValue, otherBlinding [32]byte
|
||||
otherValue[31] = 43
|
||||
otherBlinding[31] = 100
|
||||
|
||||
for _, bad := range [][]byte{
|
||||
build(commitment, otherValue, blinding),
|
||||
build(commitment, value, otherBlinding),
|
||||
build(commitment, otherValue, otherBlinding),
|
||||
build([32]byte{0xAB}, value, blinding),
|
||||
} {
|
||||
if bad[0] == 0x01 {
|
||||
continue
|
||||
}
|
||||
ok, _ := p.verifyCommitment(bad)
|
||||
require.False(t, ok, "an incorrect opening verified")
|
||||
}
|
||||
|
||||
// Length guard.
|
||||
for n := range 96 {
|
||||
_, err := p.verifyCommitment(make([]byte, n))
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCtr_CommitmentSubmodeIsAmbiguous documents a real collision: the
|
||||
// 0x22 sub-mode is selected by the FIRST BYTE of the payload, but in
|
||||
// Pedersen mode that byte is the first byte of the commitment — which is a
|
||||
// SHA-256 output and therefore 0x01 about one time in 256. Such a
|
||||
// commitment is silently routed to the Merkle branch and misjudged.
|
||||
func TestCtr_CommitmentSubmodeIsAmbiguous(t *testing.T) {
|
||||
// The discriminator consults only length and the first byte.
|
||||
ped := make([]byte, 96)
|
||||
ped[0] = 0x02
|
||||
require.False(t, isMerkleCommitment(ped))
|
||||
|
||||
// A Pedersen payload whose commitment happens to begin 0x01 is claimed
|
||||
// by the Merkle branch, even though the remaining bytes are a
|
||||
// value/blinding pair.
|
||||
collide := make([]byte, 96)
|
||||
collide[0] = 0x01
|
||||
require.True(t, isMerkleCommitment(collide),
|
||||
"a Pedersen commitment starting 0x01 is misrouted to the Merkle branch")
|
||||
|
||||
// Short payloads starting 0x01 fall back to Pedersen — so the routing
|
||||
// of identical leading bytes depends on length as well.
|
||||
short := make([]byte, 76)
|
||||
short[0] = 0x01
|
||||
require.False(t, isMerkleCommitment(short))
|
||||
|
||||
// Demonstrate the misjudgement end to end: the same 96 bytes are read
|
||||
// as two different requests, differing only in that leading byte. The
|
||||
// Merkle branch reports a missing pool; the Pedersen branch reports
|
||||
// that it cannot open the commitment. Same payload, different question.
|
||||
p := &zkVerifyPrecompile{}
|
||||
merkleRouted := make([]byte, 96)
|
||||
merkleRouted[0] = 0x01
|
||||
_, errMerkle := p.verifyCommitment(merkleRouted)
|
||||
require.Error(t, errMerkle)
|
||||
|
||||
pedRouted := bytes.Clone(merkleRouted)
|
||||
pedRouted[0] = 0x02
|
||||
_, errPed := p.verifyCommitment(pedRouted)
|
||||
require.Error(t, errPed)
|
||||
|
||||
require.NotEqual(t, errMerkle.Error(), errPed.Error(),
|
||||
"the leading byte silently selects a different verification; if this "+
|
||||
"no longer holds, the ambiguity was resolved and this test should "+
|
||||
"be re-derived rather than deleted")
|
||||
}
|
||||
|
||||
// TestCtr_CommitmentMerkle exercises the Merkle-inclusion sub-mode against
|
||||
// a real tree, including the negatives that matter: wrong index, wrong
|
||||
// root, tampered sibling.
|
||||
func TestCtr_CommitmentMerkle(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
p := &zkVerifyPrecompile{verifier: zv}
|
||||
|
||||
var poolID, commitID [32]byte
|
||||
copy(poolID[:], "pool")
|
||||
copy(commitID[:], "commitment-leaf")
|
||||
|
||||
// Build a 2-level proof the verifier's own hashing agrees with:
|
||||
// leaf -> sha256(leaf); then fold with each sibling by index bit.
|
||||
sib0 := bytes.Repeat([]byte{0xA1}, 32)
|
||||
sib1 := bytes.Repeat([]byte{0xB2}, 32)
|
||||
const index = uint64(0b10) // right at level 0? no: bit0=0, bit1=1
|
||||
|
||||
cur := sha256.Sum256(commitID[:])
|
||||
for i, sib := range [][]byte{sib0, sib1} {
|
||||
var combined []byte
|
||||
if (index>>uint(i))&1 == 0 {
|
||||
combined = append(bytes.Clone(cur[:]), sib...)
|
||||
} else {
|
||||
combined = append(bytes.Clone(sib), cur[:]...)
|
||||
}
|
||||
cur = sha256.Sum256(combined)
|
||||
}
|
||||
|
||||
zv.Pools[poolID] = &ConfidentialPool{
|
||||
PoolID: poolID,
|
||||
MerkleRoot: cur,
|
||||
Commitments: map[[32]byte]*Commitment{},
|
||||
Nullifiers: map[[32]byte]*Nullifier{},
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
build := func(pool, leaf [32]byte, idx uint64, sibs [][]byte) []byte {
|
||||
out := []byte{0x01}
|
||||
out = append(out, pool[:]...)
|
||||
out = append(out, leaf[:]...)
|
||||
out = binary.BigEndian.AppendUint64(out, idx)
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(len(sibs)))
|
||||
for _, s := range sibs {
|
||||
out = append(out, s...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
good := build(poolID, commitID, index, [][]byte{sib0, sib1})
|
||||
ok, err := p.verifyCommitment(good)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "control: a correct inclusion proof must verify")
|
||||
|
||||
// Same proof at a different index folds differently and must fail.
|
||||
for _, badIdx := range []uint64{0, 1, 3, 1 << 40} {
|
||||
if badIdx == index {
|
||||
continue
|
||||
}
|
||||
ok, _ := p.verifyCommitment(build(poolID, commitID, badIdx, [][]byte{sib0, sib1}))
|
||||
require.Falsef(t, ok, "the proof verified at the wrong index %d", badIdx)
|
||||
}
|
||||
|
||||
// A tampered sibling must fail.
|
||||
for i := range 2 {
|
||||
sibs := [][]byte{bytes.Clone(sib0), bytes.Clone(sib1)}
|
||||
sibs[i][0] ^= 0x01
|
||||
ok, _ := p.verifyCommitment(build(poolID, commitID, index, sibs))
|
||||
require.Falsef(t, ok, "a tampered sibling at level %d was accepted", i)
|
||||
}
|
||||
|
||||
// A different leaf must fail.
|
||||
var otherLeaf [32]byte
|
||||
copy(otherLeaf[:], "some-other-commitment")
|
||||
ok, _ = p.verifyCommitment(build(poolID, commitID, index, [][]byte{sib1, sib0}))
|
||||
require.False(t, ok, "reordered siblings were accepted")
|
||||
ok, _ = p.verifyCommitment(build(poolID, otherLeaf, index, [][]byte{sib0, sib1}))
|
||||
require.False(t, ok, "a different leaf was accepted under the same root")
|
||||
|
||||
// An unknown pool is an error, not a false verdict.
|
||||
var ghost [32]byte
|
||||
copy(ghost[:], "no-such-pool")
|
||||
_, err = p.verifyCommitment(build(ghost, commitID, index, [][]byte{sib0, sib1}))
|
||||
require.ErrorIs(t, err, ErrPoolNotFound)
|
||||
|
||||
// Truncated proof body: the declared sibling count must be backed by
|
||||
// bytes.
|
||||
trunc := good[:len(good)-1]
|
||||
_, err = p.verifyCommitment(trunc)
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
|
||||
// Without a verifier there is no tree to consult.
|
||||
bare := &zkVerifyPrecompile{}
|
||||
ok, err = bare.verifyCommitment(good)
|
||||
require.False(t, ok)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestCtr_PedersenVerificationIsProcessLocal documents a consensus hazard.
|
||||
//
|
||||
// compressG1 does not compress: it SHA-256 hashes the point and stores the
|
||||
// preimage in a process-local map, and decompressG1 can only recover
|
||||
// points that map has seen. So verifying a Pedersen opening depends on
|
||||
// what THIS process has previously committed, not on the bytes supplied.
|
||||
// Two validators therefore answer op 0x22 differently for the same call.
|
||||
func TestCtr_PedersenVerificationIsProcessLocal(t *testing.T) {
|
||||
// A commitment this process never produced cannot be opened at all,
|
||||
// however well-formed the request is.
|
||||
var unseen, value, blinding [32]byte
|
||||
copy(unseen[:], "a commitment made by another node")
|
||||
_, err := globalPedersen.Verify(unseen, value, blinding)
|
||||
require.Error(t, err,
|
||||
"verification depends on process-local state, so a commitment from "+
|
||||
"another node cannot be checked here")
|
||||
|
||||
// Whereas one made locally can — the only difference is local history.
|
||||
value[31] = 5
|
||||
blinding[31] = 6
|
||||
local, err := globalPedersen.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
ok, err := globalPedersen.Verify(local, value, blinding)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// batch (op 0x30)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// ctrBatch frames a batch body from (type, payload) entries.
|
||||
func ctrBatch(entries ...struct {
|
||||
typ byte
|
||||
data []byte
|
||||
}) []byte {
|
||||
out := []byte{OpVerifyBatch}
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(len(entries)))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.typ)
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(len(e.data)))
|
||||
out = append(out, e.data...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type ctrEntry = struct {
|
||||
typ byte
|
||||
data []byte
|
||||
}
|
||||
|
||||
// TestCtr_Batch pins the aggregate semantics: a batch is valid only if
|
||||
// every member is, an unknown member type is refused rather than skipped,
|
||||
// and a truncated frame is refused rather than read past.
|
||||
func TestCtr_Batch(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
|
||||
// A zero count is refused (and TestGas_BatchIsNeverFree pins that it
|
||||
// is still charged for).
|
||||
_, err := p.verifyBatch([]byte{0, 0, 0, 0})
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
|
||||
// Fewer than four bytes cannot even name a count.
|
||||
for n := range 4 {
|
||||
_, err := p.verifyBatch(make([]byte, n))
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
}
|
||||
|
||||
// An unknown member type stops the batch.
|
||||
for _, typ := range []byte{0x00, OpVerifyFflonk, OpVerifyHalo2, OpVerifyBatch, 0xff} {
|
||||
in := ctrBatch(ctrEntry{typ, make([]byte, 8)})
|
||||
_, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, in, 10_000_000, true)
|
||||
require.ErrorIsf(t, err, ErrUnknownProofSystem, "member type 0x%02x was dispatched", typ)
|
||||
}
|
||||
|
||||
// A member whose declared length runs past the frame is refused.
|
||||
frame := []byte{0, 0, 0, 1, OpVerifyKZG, 0xff, 0xff, 0xff, 0xff}
|
||||
_, err = p.verifyBatch(frame)
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
|
||||
// A member header that runs past the frame is refused.
|
||||
_, err = p.verifyBatch([]byte{0, 0, 0, 1, OpVerifyKZG, 0, 0})
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
|
||||
// More members declared than supplied.
|
||||
_, err = p.verifyBatch(append([]byte{0, 0, 0, 3}, 0x10, 0, 0, 0, 0))
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
|
||||
// A batch of genuine KZG openings verifies; corrupting ANY member
|
||||
// makes the whole batch fail. A batch verifier that passed while one
|
||||
// member was invalid would be the entire point of the op, broken.
|
||||
c, pt, cl, pr := ctrKZG(t, 2, 4, 6)
|
||||
good := ctrKZGInput(c, pt, cl, pr)
|
||||
all := ctrBatch(
|
||||
ctrEntry{OpVerifyKZG, good},
|
||||
ctrEntry{OpVerifyKZG, good},
|
||||
ctrEntry{OpVerifyKZG, good},
|
||||
)
|
||||
ret, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, all, 10_000_000, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encodeBool(true), ret, "a batch of valid openings must verify")
|
||||
|
||||
for i := range 3 {
|
||||
entries := []ctrEntry{
|
||||
{OpVerifyKZG, bytes.Clone(good)},
|
||||
{OpVerifyKZG, bytes.Clone(good)},
|
||||
{OpVerifyKZG, bytes.Clone(good)},
|
||||
}
|
||||
entries[i].data[100] ^= 0x01
|
||||
in := ctrBatch(entries...)
|
||||
ret, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, in, 10_000_000, true)
|
||||
require.NoError(t, err)
|
||||
require.Equalf(t, encodeBool(false), ret, "batch passed with member %d corrupted", i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PLONK (op 0x02)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TestCtr_PLONKStructure covers the no-key path: it must validate the nine
|
||||
// G1 points and then refuse, never claiming a verdict without a key.
|
||||
func TestCtr_PLONKStructure(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
|
||||
// Short header.
|
||||
for n := range 36 {
|
||||
_, err := p.verifyPLONK(make([]byte, n))
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Declared inputs the body cannot hold.
|
||||
hdr := make([]byte, 36+768)
|
||||
binary.BigEndian.PutUint32(hdr[32:36], 1)
|
||||
_, err := p.verifyPLONK(hdr)
|
||||
require.ErrorIs(t, err, ErrInvalidProofLength)
|
||||
|
||||
// A body of all-zero G1 points parses (infinity is a valid encoding)
|
||||
// and then refuses for want of a key.
|
||||
body := make([]byte, 36+768)
|
||||
ok, err := p.verifyPLONK(body)
|
||||
require.False(t, ok)
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrInvalidProofLength)
|
||||
|
||||
// A malformed G1 point is rejected as such.
|
||||
bad := make([]byte, 36+768)
|
||||
for i := 36; i < 36+64; i++ {
|
||||
bad[i] = 0xff
|
||||
}
|
||||
_, err = p.verifyPLONK(bad)
|
||||
require.ErrorIs(t, err, ErrInvalidPointFormat)
|
||||
|
||||
// verifyPLONKStructure directly: too short, and each point slot.
|
||||
_, err = verifyPLONKStructure(make([]byte, 767))
|
||||
require.ErrorIs(t, err, ErrInvalidProofLength)
|
||||
for i := range 9 {
|
||||
proof := make([]byte, 768)
|
||||
for j := i * 64; j < (i+1)*64; j++ {
|
||||
proof[j] = 0xff
|
||||
}
|
||||
_, err := verifyPLONKStructure(proof)
|
||||
require.ErrorIsf(t, err, ErrInvalidPointFormat, "point %d not validated", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCtr_PLONKKeyedPath routes through a registered key so the keyed
|
||||
// branch is exercised, and pins that a mismatched system is refused.
|
||||
func TestCtr_PLONKKeyedPath(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
g1 := bytes.Repeat([]byte{0x01}, 64)
|
||||
g2 := bytes.Repeat([]byte{0x02}, 128)
|
||||
ic := [][]byte{g1, g1, g1, g1, g1, g1, g1, g1, g2}
|
||||
|
||||
id, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemPlonk, CircuitTransfer, g1, g2, g2, g2, ic)
|
||||
require.NoError(t, err)
|
||||
|
||||
p := &zkVerifyPrecompile{verifier: zv}
|
||||
data := make([]byte, 36+768)
|
||||
copy(data[0:32], id[:])
|
||||
|
||||
ok, err := p.verifyPLONK(data)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok, "a zero proof must not verify against a real key")
|
||||
|
||||
// An unregistered key is an error, not a verdict.
|
||||
unknown := make([]byte, 36+768)
|
||||
unknown[0] = 0xFE
|
||||
_, err = p.verifyPLONK(unknown)
|
||||
require.ErrorIs(t, err, ErrInvalidVerifyingKey)
|
||||
|
||||
// A Groth16 key presented to the PLONK op is refused.
|
||||
gid, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemGroth16, CircuitTransfer, g1, g2, g2, g2, [][]byte{g1})
|
||||
require.NoError(t, err)
|
||||
mismatch := make([]byte, 36+768)
|
||||
copy(mismatch[0:32], gid[:])
|
||||
_, err = p.verifyPLONK(mismatch)
|
||||
require.ErrorIs(t, err, ErrProofSystemMismatch)
|
||||
|
||||
// Too few IC points for the PLONK layout: refuse, do not index past.
|
||||
sid, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemPlonk, CircuitTransfer, g1, g2, g2, g2, [][]byte{g1, g1})
|
||||
require.NoError(t, err)
|
||||
short := make([]byte, 36+768)
|
||||
copy(short[0:32], sid[:])
|
||||
ok, err = p.verifyPLONK(short)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Groth16 header handling (op 0x01)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TestCtr_Groth16Lengths pins the length arithmetic that the gas schedule
|
||||
// now mirrors: exactly expectedLen clears, one byte fewer does not.
|
||||
func TestCtr_Groth16Lengths(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
|
||||
for n := range 36 {
|
||||
_, err := p.verifyGroth16(make([]byte, n))
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
}
|
||||
|
||||
for _, n := range []uint32{0, 1, 4, 17} {
|
||||
expected := 32 + 4 + int(n)*32 + 256
|
||||
|
||||
short := make([]byte, expected-1)
|
||||
binary.BigEndian.PutUint32(short[32:36], n)
|
||||
_, err := p.verifyGroth16(short)
|
||||
require.ErrorIsf(t, err, ErrInvalidProofLength, "n=%d: one byte short was accepted", n)
|
||||
|
||||
exact := make([]byte, expected)
|
||||
binary.BigEndian.PutUint32(exact[32:36], n)
|
||||
_, err = p.verifyGroth16(exact)
|
||||
require.NotErrorIsf(t, err, ErrInvalidProofLength, "n=%d: exact length rejected", n)
|
||||
}
|
||||
|
||||
// A declared count far beyond the body is refused, not allocated for.
|
||||
huge := make([]byte, 36+256)
|
||||
binary.BigEndian.PutUint32(huge[32:36], ^uint32(0))
|
||||
_, err := p.verifyGroth16(huge)
|
||||
require.ErrorIs(t, err, ErrInvalidProofLength)
|
||||
|
||||
// Malformed proof points are reported as such.
|
||||
bad := make([]byte, 36+256)
|
||||
for i := 36; i < 100; i++ {
|
||||
bad[i] = 0xff
|
||||
}
|
||||
_, err = p.verifyGroth16(bad)
|
||||
require.ErrorIs(t, err, ErrInvalidPointFormat)
|
||||
|
||||
// verifyGroth16PointsValid directly: each of the three slots.
|
||||
valid := make([]byte, 256)
|
||||
for i, region := range [][2]int{{0, 64}, {64, 128}, {192, 64}} {
|
||||
proof := bytes.Clone(valid)
|
||||
for j := region[0]; j < region[0]+region[1]; j++ {
|
||||
proof[j] = 0xff
|
||||
}
|
||||
_, err := verifyGroth16PointsValid(proof[0:64], proof[64:192], proof[192:256])
|
||||
require.Errorf(t, err, "slot %d not validated", i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Halo2 helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TestCtr_Halo2Helpers covers the field and point predicates the Halo2
|
||||
// structural path depends on. Each is a guard: if it accepts what it
|
||||
// should reject, a malformed proof reaches the arithmetic.
|
||||
func TestCtr_Halo2Helpers(t *testing.T) {
|
||||
// isZeroBytes.
|
||||
require.True(t, isZeroBytes(nil))
|
||||
require.True(t, isZeroBytes(make([]byte, 32)))
|
||||
require.False(t, isZeroBytes([]byte{0, 0, 1}))
|
||||
|
||||
// isValidScalar: must reject non-canonical values (>= the field
|
||||
// order), which are the classic malleability vector.
|
||||
require.False(t, isValidScalar(make([]byte, 31)), "wrong length")
|
||||
require.False(t, isValidScalar(bytes.Repeat([]byte{0xff}, 32)), "far above the order")
|
||||
small := make([]byte, 32)
|
||||
small[31] = 1
|
||||
require.True(t, isValidScalar(small))
|
||||
|
||||
// modInverse: zero has no inverse; a nonzero scalar does, and the
|
||||
// inverse is a canonical 32-byte scalar.
|
||||
_, err := modInverse(make([]byte, 32))
|
||||
require.Error(t, err, "zero must have no inverse")
|
||||
_, err = modInverse(make([]byte, 31))
|
||||
require.Error(t, err, "wrong length must be refused")
|
||||
inv, err := modInverse(small)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, inv, 32)
|
||||
require.True(t, isValidScalar(inv))
|
||||
|
||||
// foldCommitment must be a function of every argument: changing any
|
||||
// one must change the fold, or that argument is unbound.
|
||||
base := [5][]byte{
|
||||
bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32),
|
||||
bytes.Repeat([]byte{3}, 32), bytes.Repeat([]byte{4}, 32),
|
||||
bytes.Repeat([]byte{5}, 32),
|
||||
}
|
||||
want := foldCommitment(base[0], base[1], base[2], base[3], base[4])
|
||||
for i := range base {
|
||||
mutated := base
|
||||
mutated[i] = bytes.Clone(base[i])
|
||||
mutated[i][0] ^= 0xff
|
||||
got := foldCommitment(mutated[0], mutated[1], mutated[2], mutated[3], mutated[4])
|
||||
require.NotEqualf(t, want, got, "argument %d does not affect the fold", i)
|
||||
}
|
||||
|
||||
// intToStr is only used to label errors; it must not panic or collide
|
||||
// for the small indices it is called with.
|
||||
seen := map[string]bool{}
|
||||
for i := range 10 {
|
||||
s := intToStr(i)
|
||||
require.False(t, seen[s], "intToStr collides at %d", i)
|
||||
seen[s] = true
|
||||
}
|
||||
require.Equal(t, "N", intToStr(10))
|
||||
require.Equal(t, "N", intToStr(1000))
|
||||
}
|
||||
|
||||
// TestCtr_Halo2TranscriptBinds pins Fiat-Shamir: the challenge must be a
|
||||
// deterministic function of everything appended, and must change when any
|
||||
// of it changes. A transcript that ignored an input would let a prover
|
||||
// vary that input freely after seeing the challenge.
|
||||
func TestCtr_Halo2TranscriptBinds(t *testing.T) {
|
||||
mk := func(scalars, points [][]byte) []byte {
|
||||
tr := newHalo2Transcript()
|
||||
for _, s := range scalars {
|
||||
tr.appendScalar(s)
|
||||
}
|
||||
for _, p := range points {
|
||||
tr.appendPoint(p)
|
||||
}
|
||||
return tr.challenge()
|
||||
}
|
||||
|
||||
s := [][]byte{bytes.Repeat([]byte{1}, 32)}
|
||||
p := [][]byte{bytes.Repeat([]byte{2}, 32)}
|
||||
|
||||
require.Equal(t, mk(s, p), mk(s, p), "the transcript must be deterministic")
|
||||
|
||||
other := [][]byte{bytes.Repeat([]byte{9}, 32)}
|
||||
require.NotEqual(t, mk(s, p), mk(other, p), "a scalar does not bind")
|
||||
require.NotEqual(t, mk(s, p), mk(s, other), "a point does not bind")
|
||||
require.NotEqual(t, mk(s, p), mk(p, s), "ordering does not bind")
|
||||
|
||||
// Successive challenges from one transcript must differ, or every
|
||||
// round shares a challenge.
|
||||
tr := newHalo2Transcript()
|
||||
tr.appendPoint(p[0])
|
||||
first := bytes.Clone(tr.challenge())
|
||||
tr.appendPoint(p[0])
|
||||
require.NotEqual(t, first, tr.challenge(), "the transcript does not advance")
|
||||
}
|
||||
|
||||
// TestCtr_Halo2InstanceCommitment covers the IC-bounds guard: a key with
|
||||
// too few IC points for the declared public inputs must be refused, not
|
||||
// indexed past.
|
||||
func TestCtr_Halo2InstanceCommitment(t *testing.T) {
|
||||
data := buildTestHalo2Proof(2, 1, 1, 4)
|
||||
proof, err := parseHalo2Proof(data)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, proof.publicInputs, 2)
|
||||
|
||||
tooFew := &VerifyingKey{ProofSystem: ProofSystemHalo2, IC: [][]byte{{1}, {2}}}
|
||||
_, err = computeHalo2InstanceCommitment(proof, tooFew)
|
||||
require.Error(t, err, "insufficient IC points must be refused")
|
||||
|
||||
enough := &VerifyingKey{
|
||||
ProofSystem: ProofSystemHalo2,
|
||||
IC: [][]byte{{1}, {2}, {3}, {4}},
|
||||
}
|
||||
got, err := computeHalo2InstanceCommitment(proof, enough)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proof.instanceCommitments[0], got,
|
||||
"the instance commitment must come from the proof when present")
|
||||
|
||||
// With no instance commitments it falls back to IC[0].
|
||||
proof.instanceCommitments = nil
|
||||
got, err = computeHalo2InstanceCommitment(proof, enough)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{1}, got)
|
||||
}
|
||||
|
||||
// TestCtr_Halo2ParseBounds walks every length guard in the parser. Each
|
||||
// declared count must be backed by bytes; none may read past the input.
|
||||
func TestCtr_Halo2ParseBounds(t *testing.T) {
|
||||
full := buildTestHalo2Proof(1, 1, 1, 4)
|
||||
|
||||
for n := range len(full) {
|
||||
_, err := parseHalo2Proof(full[:n])
|
||||
require.Errorf(t, err, "a %d-byte prefix parsed as a complete proof", n)
|
||||
}
|
||||
|
||||
// Round count must be 1..32.
|
||||
for _, rounds := range []uint32{0, 33, 1 << 20} {
|
||||
data := buildTestHalo2ProofWithRounds(rounds)
|
||||
_, err := parseHalo2Proof(data)
|
||||
require.Errorf(t, err, "round count %d was accepted", rounds)
|
||||
}
|
||||
|
||||
// An overstated public-input count is refused.
|
||||
bad := bytes.Clone(full)
|
||||
binary.BigEndian.PutUint32(bad[32:36], 1<<20)
|
||||
_, err := parseHalo2Proof(bad)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestCtr_ValidCompressedPoint pins the curve check used across the Halo2
|
||||
// path: off-curve x-coordinates and non-canonical field elements must be
|
||||
// rejected, or a proof can carry points that are not group elements.
|
||||
func TestCtr_ValidCompressedPoint(t *testing.T) {
|
||||
require.False(t, isValidCompressedPoint(make([]byte, 31)), "wrong length")
|
||||
require.False(t, isValidCompressedPoint(nil))
|
||||
|
||||
// A real on-curve point is accepted.
|
||||
require.True(t, isValidCompressedPoint(realCompressedPallasPoint(1)))
|
||||
|
||||
// The all-ones x-coordinate is not in the field.
|
||||
require.False(t, isValidCompressedPoint(bytes.Repeat([]byte{0xff}, 32)))
|
||||
|
||||
// isQuadraticResidue on a small prime, checked against brute force.
|
||||
p := big.NewInt(23)
|
||||
squares := map[int64]bool{}
|
||||
for i := int64(1); i < 23; i++ {
|
||||
squares[new(big.Int).Mod(big.NewInt(i*i), p).Int64()] = true
|
||||
}
|
||||
for a := int64(1); a < 23; a++ {
|
||||
require.Equalf(t, squares[a], isQuadraticResidue(big.NewInt(a), p),
|
||||
"residue verdict wrong for %d mod 23", a)
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Gas metering is a consensus rule: the price a caller pays must be a
|
||||
// function of the work the verifier performs. These tests assert that
|
||||
// relationship as a property — "changing X changes the price, changing Y
|
||||
// does not" — rather than pinning magic numbers, which would pass just as
|
||||
// happily against a schedule that reads the wrong field.
|
||||
|
||||
// headerOps are the four ops that share the
|
||||
// [op][vkID:32][numInputs:4][inputs...] header. All four verifiers read
|
||||
// numInputs at [32:36] of input[1:], so all four must be priced on it.
|
||||
var headerOps = []struct {
|
||||
op byte
|
||||
base uint64
|
||||
}{
|
||||
{OpVerifyGroth16, GasGroth16Base},
|
||||
{OpVerifyPLONK, GasPLONKBase},
|
||||
{OpVerifyFflonk, GasFflonkBase},
|
||||
{OpVerifyHalo2, GasHalo2Base},
|
||||
}
|
||||
|
||||
// header builds a well-formed op header declaring numInputs public inputs,
|
||||
// with vkID[0:4] set to vkPrefix, and enough trailing bytes that the
|
||||
// declared inputs actually fit (so the length cap never binds).
|
||||
func header(op byte, vkPrefix uint32, numInputs uint32, tail int) []byte {
|
||||
buf := make([]byte, inputsOff+int(numInputs)*fieldLen+tail)
|
||||
buf[0] = op
|
||||
binary.BigEndian.PutUint32(buf[1:5], vkPrefix)
|
||||
binary.BigEndian.PutUint32(buf[numInputsOff:inputsOff], numInputs)
|
||||
return buf
|
||||
}
|
||||
|
||||
// TestGas_PriceIgnoresVerifyingKeyID is the regression test for the
|
||||
// confirmed defect: RequiredGas read the public-input count from
|
||||
// input[1:5] — the first four bytes of the 32-byte vkID — while every
|
||||
// verifier reads it from input[33:37]. Price and work were driven by two
|
||||
// unrelated, independently attacker-chosen fields.
|
||||
//
|
||||
// The property: the vkID names WHICH statement is being proved. It has no
|
||||
// bearing on how much work verification is, so it must have no bearing on
|
||||
// the price. Before the fix this failed on the very first prefix.
|
||||
func TestGas_PriceIgnoresVerifyingKeyID(t *testing.T) {
|
||||
// Prefixes spanning the uint32 range, including the two the defect
|
||||
// report calls out: all-zero (flat base, undercharge) and near-max
|
||||
// (4.29e12 gas, a valid proof priced out of every block).
|
||||
prefixes := []uint32{0, 1, 0x0000_2710, 0x0001_0000, 0x7fff_ffff, 0xffff_ffff}
|
||||
|
||||
for _, o := range headerOps {
|
||||
want := ZKVerifyPrecompile.RequiredGas(header(o.op, prefixes[0], 3, 256))
|
||||
for _, pfx := range prefixes[1:] {
|
||||
got := ZKVerifyPrecompile.RequiredGas(header(o.op, pfx, 3, 256))
|
||||
require.Equalf(t, want, got,
|
||||
"op 0x%02x: vkID prefix 0x%08x changed the price from %d to %d; "+
|
||||
"gas must not depend on the verification-key ID",
|
||||
o.op, pfx, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGas_PriceTracksPublicInputCount is the other half: the field the
|
||||
// verifier actually reads MUST move the price. A schedule that ignores
|
||||
// numInputs would charge a flat base for unbounded parsing work.
|
||||
func TestGas_PriceTracksPublicInputCount(t *testing.T) {
|
||||
for _, o := range headerOps {
|
||||
prev := ZKVerifyPrecompile.RequiredGas(header(o.op, 0xffff_ffff, 0, 256))
|
||||
for n := uint32(1); n <= 8; n++ {
|
||||
// Hold the vkID at its most misleading value throughout: if the
|
||||
// schedule were reading the vkID, every price here would be equal.
|
||||
got := ZKVerifyPrecompile.RequiredGas(header(o.op, 0xffff_ffff, n, 256))
|
||||
require.Greaterf(t, got, prev,
|
||||
"op 0x%02x: price did not rise from %d inputs to %d", o.op, n-1, n)
|
||||
require.Equalf(t, prev+GasPerPublicInput, got,
|
||||
"op 0x%02x: each public input must add GasPerPublicInput", o.op)
|
||||
prev = got
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGas_MatchesWhatTheVerifierParses closes the loop between the two
|
||||
// halves above: the count RequiredGas prices is the same count the
|
||||
// verifier accepts. An input sized to exactly the declared numInputs must
|
||||
// clear the verifier's own length check — if the two functions disagreed
|
||||
// about where the count lives, one of them would reject.
|
||||
func TestGas_MatchesWhatTheVerifierParses(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
for _, n := range []uint32{0, 1, 5} {
|
||||
// Groth16 expectedLen = vkID(32) + numInputs(4) + n*32 + proof(256).
|
||||
in := header(OpVerifyGroth16, 0xdead_beef, n, 256)
|
||||
|
||||
require.Equal(t, GasGroth16Base+uint64(n)*GasPerPublicInput,
|
||||
ZKVerifyPrecompile.RequiredGas(in),
|
||||
"gas must price the declared count")
|
||||
|
||||
_, err := p.verifyGroth16(in[1:])
|
||||
require.NotErrorIs(t, err, ErrInvalidProofLength,
|
||||
"verifier rejected the length that gas just priced as sufficient: "+
|
||||
"the two are reading different fields")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGas_BoundedByInputLength pins the cap. A caller may declare any
|
||||
// uint32 count, but the verifier refuses in constant time once the
|
||||
// declared inputs cannot fit, so the price must not run away either. Gas
|
||||
// stays a function of calldata the caller actually paid to supply.
|
||||
func TestGas_BoundedByInputLength(t *testing.T) {
|
||||
for _, o := range headerOps {
|
||||
for _, size := range []int{inputsOff, inputsOff + 32, 1024, 4096} {
|
||||
in := make([]byte, size)
|
||||
in[0] = o.op
|
||||
// Declare the largest count expressible on the wire.
|
||||
binary.BigEndian.PutUint32(in[numInputsOff:inputsOff], math.MaxUint32)
|
||||
|
||||
got := ZKVerifyPrecompile.RequiredGas(in)
|
||||
ceiling := o.base + uint64((size-inputsOff)/fieldLen)*GasPerPublicInput
|
||||
|
||||
require.LessOrEqualf(t, got, ceiling,
|
||||
"op 0x%02x at %d bytes: charged %d for at most %d parseable inputs",
|
||||
o.op, size, got, (size-inputsOff)/fieldLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGas_ValidProofIsPayable is the overcharge direction stated as user
|
||||
// impact. Verification-key IDs are hashes, so their leading bytes are
|
||||
// uniform; under the defect a key was usable only if its first four bytes
|
||||
// happened to encode a number below ~29,850 — about one key in 143,000.
|
||||
// Every key must now price the same modest amount.
|
||||
func TestGas_ValidProofIsPayable(t *testing.T) {
|
||||
const blockGasLimit = 30_000_000
|
||||
|
||||
for _, o := range headerOps {
|
||||
// A realistic hash-derived key ID, high bytes set.
|
||||
in := header(o.op, 0xa3f1_09c7, 4, 256)
|
||||
got := ZKVerifyPrecompile.RequiredGas(in)
|
||||
require.Lessf(t, got, uint64(blockGasLimit),
|
||||
"op 0x%02x: a 4-input proof costs %d gas, more than a whole block",
|
||||
o.op, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGas_BatchIsNeverFree pins defect (a): numProofs == 0 made
|
||||
// numProofs*GasPerBatchProof evaluate to zero, so op 0x30 dispatched into
|
||||
// verifyBatch for nothing — bypassing MinCallGas, which exists precisely
|
||||
// to stop zero-cost calls.
|
||||
func TestGas_BatchIsNeverFree(t *testing.T) {
|
||||
zero := []byte{OpVerifyBatch, 0, 0, 0, 0}
|
||||
require.GreaterOrEqual(t, ZKVerifyPrecompile.RequiredGas(zero), MinCallGas,
|
||||
"a zero-proof batch must still cost at least MinCallGas")
|
||||
|
||||
// And it really does reach the verifier, so the charge is not academic.
|
||||
_, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, zero, 10_000_000, true)
|
||||
require.Error(t, err)
|
||||
|
||||
// The floor holds for every input length, including a bare selector.
|
||||
for size := 0; size <= 64; size++ {
|
||||
in := make([]byte, size)
|
||||
if size > 0 {
|
||||
in[0] = OpVerifyBatch
|
||||
}
|
||||
require.GreaterOrEqualf(t, ZKVerifyPrecompile.RequiredGas(in), MinCallGas,
|
||||
"batch of %d bytes priced below MinCallGas", size)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGas_BatchDoesNotOverflow checks the multiplication cannot wrap. The
|
||||
// count is a uint32, so even uncapped the product is bounded by
|
||||
// 2^32 * GasPerBatchProof ~ 2.1e14, comfortably inside uint64 — this test
|
||||
// pins that reasoning against a future rise in GasPerBatchProof, and pins
|
||||
// that a larger declared count never prices LOWER (the wrap signature).
|
||||
func TestGas_BatchDoesNotOverflow(t *testing.T) {
|
||||
require.Less(t, uint64(math.MaxUint32),
|
||||
uint64(math.MaxUint64)/GasPerBatchProof,
|
||||
"GasPerBatchProof has grown large enough for uint32*GasPerBatchProof to wrap")
|
||||
|
||||
// Monotonicity over a fully-populated batch: more declared entries in
|
||||
// the same calldata must never cost less.
|
||||
body := make([]byte, 5+5*40)
|
||||
body[0] = OpVerifyBatch
|
||||
var prev uint64
|
||||
for n := uint32(0); n <= 40; n++ {
|
||||
binary.BigEndian.PutUint32(body[1:5], n)
|
||||
got := ZKVerifyPrecompile.RequiredGas(body)
|
||||
require.GreaterOrEqualf(t, got, prev, "price fell when count rose to %d", n)
|
||||
prev = got
|
||||
}
|
||||
|
||||
// A wrapped product would land far below the floor.
|
||||
huge := make([]byte, 5+5*8)
|
||||
huge[0] = OpVerifyBatch
|
||||
binary.BigEndian.PutUint32(huge[1:5], math.MaxUint32)
|
||||
require.GreaterOrEqual(t, ZKVerifyPrecompile.RequiredGas(huge), MinCallGas)
|
||||
}
|
||||
|
||||
// TestGas_EveryReachableCallCharges sweeps every op selector, valid and
|
||||
// invalid, at a range of lengths: no input may reach Run for free.
|
||||
func TestGas_EveryReachableCallCharges(t *testing.T) {
|
||||
for op := 0; op <= 0xff; op++ {
|
||||
for _, size := range []int{0, 1, 5, 37, 100, 512} {
|
||||
in := make([]byte, size)
|
||||
if size > 0 {
|
||||
in[0] = byte(op)
|
||||
}
|
||||
require.GreaterOrEqualf(t, ZKVerifyPrecompile.RequiredGas(in), MinCallGas,
|
||||
"op 0x%02x at %d bytes costs less than MinCallGas", op, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGas_DeductedBeforeWork proves the charge is enforced, not merely
|
||||
// computed: one gas short of the requirement must fail for every op, and
|
||||
// must fail before any verifier runs.
|
||||
func TestGas_DeductedBeforeWork(t *testing.T) {
|
||||
ops := []byte{
|
||||
OpVerifyGroth16, OpVerifyPLONK, OpVerifyFflonk, OpVerifyHalo2,
|
||||
OpVerifyKZG, OpVerifyIPA, OpVerifyRangeProof, OpVerifyNullifier,
|
||||
OpVerifyCommitment, OpVerifyBatch, 0xff,
|
||||
}
|
||||
for _, op := range ops {
|
||||
in := make([]byte, 512)
|
||||
in[0] = op
|
||||
need := ZKVerifyPrecompile.RequiredGas(in)
|
||||
require.Positive(t, need)
|
||||
|
||||
_, rem, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, in, need-1, true)
|
||||
require.Errorf(t, err, "op 0x%02x ran on insufficient gas", op)
|
||||
require.Zerof(t, rem, "op 0x%02x left gas after an out-of-gas failure", op)
|
||||
|
||||
// Exactly enough must get past the charge (it may still fail on the
|
||||
// proof itself, but not for want of gas).
|
||||
_, rem, err = ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, in, need, true)
|
||||
if err != nil {
|
||||
require.NotContainsf(t, err.Error(), "gas",
|
||||
"op 0x%02x refused an exactly-sufficient payment", op)
|
||||
}
|
||||
require.Zerof(t, rem, "op 0x%02x: exact payment should leave nothing", op)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/big"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/consensys/gnark-crypto/ecc/bn254"
|
||||
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A verifier is only shown to work when it does BOTH halves: accept a
|
||||
// proof that satisfies the equation, and reject everything else. Tests
|
||||
// that only ever feed it garbage cannot distinguish a real verifier from
|
||||
// `return false`, and tests that only feed it a valid proof cannot
|
||||
// distinguish one from `return true`.
|
||||
//
|
||||
// There is no trusted setup here, so instead we PICK the trapdoor. Choose
|
||||
// scalars for every group element and solve the verification equation in
|
||||
// the exponent for C. The resulting proof satisfies
|
||||
//
|
||||
// e(A,B) = e(alpha,beta) . e(vk_x,gamma) . e(C,delta)
|
||||
//
|
||||
// exactly as an honestly-generated one does — the pairing check cannot
|
||||
// tell the difference, which is what makes it a usable control.
|
||||
|
||||
// grOrder is the BN254 group order r.
|
||||
func grOrder() *big.Int { return fr.Modulus() }
|
||||
|
||||
// grG1 returns [k]G1 marshalled the way bn256.G1.Unmarshal expects
|
||||
// (64 bytes: 32-byte x, 32-byte y, big-endian).
|
||||
func grG1(k *big.Int) []byte {
|
||||
_, _, g1, _ := bn254.Generators()
|
||||
var p bn254.G1Affine
|
||||
p.ScalarMultiplication(&g1, new(big.Int).Mod(k, grOrder()))
|
||||
return p.Marshal()
|
||||
}
|
||||
|
||||
// grG2 returns [k]G2 marshalled as bn256.G2.Unmarshal expects
|
||||
// (128 bytes: x.A1, x.A0, y.A1, y.A0).
|
||||
func grG2(k *big.Int) []byte {
|
||||
_, _, _, g2 := bn254.Generators()
|
||||
var p bn254.G2Affine
|
||||
p.ScalarMultiplication(&g2, new(big.Int).Mod(k, grOrder()))
|
||||
return p.Marshal()
|
||||
}
|
||||
|
||||
// grInstance is a Groth16 statement with a known trapdoor.
|
||||
type grInstance struct {
|
||||
vk *VerifyingKey
|
||||
a []byte // proof A (G1)
|
||||
b []byte // proof B (G2)
|
||||
c []byte // proof C (G1)
|
||||
public []*big.Int
|
||||
}
|
||||
|
||||
// grBuild produces a verifying key and a proof that genuinely satisfies
|
||||
// the pairing equation for the given public inputs.
|
||||
func grBuild(t *testing.T, public []*big.Int) grInstance {
|
||||
t.Helper()
|
||||
r := grOrder()
|
||||
n := func(v int64) *big.Int { return big.NewInt(v) }
|
||||
|
||||
alpha, beta := n(3), n(5)
|
||||
gamma, delta := n(7), n(11)
|
||||
pa, pb := n(13), n(17)
|
||||
|
||||
// IC scalars: one for the constant term plus one per public input.
|
||||
ic := make([]*big.Int, len(public)+1)
|
||||
for i := range ic {
|
||||
ic[i] = n(int64(23 + 7*i))
|
||||
}
|
||||
|
||||
// vk_x = IC[0] + sum(public[i] * IC[i+1]), in the exponent.
|
||||
vkx := new(big.Int).Set(ic[0])
|
||||
for i, x := range public {
|
||||
term := new(big.Int).Mul(new(big.Int).Mod(x, r), ic[i+1])
|
||||
vkx.Add(vkx, term)
|
||||
}
|
||||
vkx.Mod(vkx, r)
|
||||
|
||||
// Solve a*b - alpha*beta - vk_x*gamma - c*delta == 0 for c.
|
||||
lhs := new(big.Int).Mul(pa, pb)
|
||||
lhs.Sub(lhs, new(big.Int).Mul(alpha, beta))
|
||||
lhs.Sub(lhs, new(big.Int).Mul(vkx, gamma))
|
||||
lhs.Mod(lhs, r)
|
||||
c := new(big.Int).Mul(lhs, new(big.Int).ModInverse(delta, r))
|
||||
c.Mod(c, r)
|
||||
|
||||
icBytes := make([][]byte, len(ic))
|
||||
for i, s := range ic {
|
||||
icBytes[i] = grG1(s)
|
||||
}
|
||||
|
||||
return grInstance{
|
||||
vk: &VerifyingKey{
|
||||
ProofSystem: ProofSystemGroth16,
|
||||
CircuitType: CircuitTransfer,
|
||||
Alpha: grG1(alpha),
|
||||
Beta: grG2(beta),
|
||||
Gamma: grG2(gamma),
|
||||
Delta: grG2(delta),
|
||||
IC: icBytes,
|
||||
},
|
||||
a: grG1(pa),
|
||||
b: grG2(pb),
|
||||
c: grG1(c),
|
||||
public: public,
|
||||
}
|
||||
}
|
||||
|
||||
// grRegister installs the instance's key on a verifier and returns its ID.
|
||||
func grRegister(t *testing.T, zv *ZKVerifier, in grInstance) [32]byte {
|
||||
t.Helper()
|
||||
id, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemGroth16, CircuitTransfer,
|
||||
in.vk.Alpha, in.vk.Beta, in.vk.Gamma, in.vk.Delta, in.vk.IC)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
// TestGr_AcceptsASatisfyingProof is the control the rest depend on: the
|
||||
// pairing check must actually accept. Without it, every negative test
|
||||
// below would pass against a verifier hard-wired to reject.
|
||||
func TestGr_AcceptsASatisfyingProof(t *testing.T) {
|
||||
for _, public := range [][]*big.Int{
|
||||
{},
|
||||
{big.NewInt(1)},
|
||||
{big.NewInt(42), big.NewInt(1337)},
|
||||
{big.NewInt(0), big.NewInt(0), big.NewInt(0)},
|
||||
} {
|
||||
zv := NewZKVerifier()
|
||||
in := grBuild(t, public)
|
||||
id := grRegister(t, zv, in)
|
||||
|
||||
res, err := zv.VerifyGroth16(id, in.a, in.b, in.c, in.public)
|
||||
require.NoError(t, err)
|
||||
require.Truef(t, res.Valid,
|
||||
"a proof satisfying the pairing equation was rejected (%d public inputs)",
|
||||
len(public))
|
||||
require.Equal(t, ProofSystemGroth16, res.ProofSystem)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGr_StatementBinding is the soundness property: the SAME proof must
|
||||
// not verify for a different statement. This is what the public-input MSM
|
||||
// exists to enforce — if vk_x ignored the inputs, a single proof would
|
||||
// prove everything.
|
||||
func TestGr_StatementBinding(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
stmtA := []*big.Int{big.NewInt(42), big.NewInt(1337)}
|
||||
in := grBuild(t, stmtA)
|
||||
id := grRegister(t, zv, in)
|
||||
|
||||
res, err := zv.VerifyGroth16(id, in.a, in.b, in.c, in.public)
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Valid, "control")
|
||||
|
||||
for _, stmtB := range [][]*big.Int{
|
||||
{big.NewInt(43), big.NewInt(1337)},
|
||||
{big.NewInt(42), big.NewInt(1338)},
|
||||
{big.NewInt(1337), big.NewInt(42)}, // reordered
|
||||
{big.NewInt(0), big.NewInt(0)},
|
||||
{new(big.Int).Sub(grOrder(), big.NewInt(1)), big.NewInt(1337)},
|
||||
} {
|
||||
res, err := zv.VerifyGroth16(id, in.a, in.b, in.c, stmtB)
|
||||
require.NoError(t, err)
|
||||
require.Falsef(t, res.Valid, "the proof for %v verified against %v", stmtA, stmtB)
|
||||
}
|
||||
|
||||
// A public input congruent mod r denotes the SAME field element, so it
|
||||
// must still verify — the MSM works in the scalar field, not on bytes.
|
||||
shifted := []*big.Int{
|
||||
new(big.Int).Add(stmtA[0], grOrder()),
|
||||
stmtA[1],
|
||||
}
|
||||
res, err = zv.VerifyGroth16(id, in.a, in.b, in.c, shifted)
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Valid,
|
||||
"a scalar congruent mod r denotes the same statement and must verify")
|
||||
|
||||
// Wrong arity is refused outright.
|
||||
_, err = zv.VerifyGroth16(id, in.a, in.b, in.c, []*big.Int{big.NewInt(42)})
|
||||
require.ErrorIs(t, err, ErrInvalidPublicInputs)
|
||||
_, err = zv.VerifyGroth16(id, in.a, in.b, in.c,
|
||||
append(slices.Clone(stmtA), big.NewInt(1)))
|
||||
require.ErrorIs(t, err, ErrInvalidPublicInputs)
|
||||
}
|
||||
|
||||
// TestGr_ProofElementTampering flips a bit in each proof element in turn.
|
||||
// Every one must be rejected, or that element is not bound by the
|
||||
// equation and a prover could vary it freely.
|
||||
func TestGr_ProofElementTampering(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
in := grBuild(t, []*big.Int{big.NewInt(9)})
|
||||
id := grRegister(t, zv, in)
|
||||
|
||||
res, err := zv.VerifyGroth16(id, in.a, in.b, in.c, in.public)
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Valid, "control")
|
||||
|
||||
for _, el := range []struct {
|
||||
name string
|
||||
buf []byte
|
||||
}{{"A", in.a}, {"B", in.b}, {"C", in.c}} {
|
||||
for _, off := range []int{0, len(el.buf) / 2, len(el.buf) - 1} {
|
||||
bad := bytes.Clone(el.buf)
|
||||
bad[off] ^= 0x01
|
||||
|
||||
a, b, c := in.a, in.b, in.c
|
||||
switch el.name {
|
||||
case "A":
|
||||
a = bad
|
||||
case "B":
|
||||
b = bad
|
||||
case "C":
|
||||
c = bad
|
||||
}
|
||||
res, err := zv.VerifyGroth16(id, a, b, c, in.public)
|
||||
// A corrupted encoding either fails to parse (Valid false) or
|
||||
// parses to a different point and fails the pairing. Never valid.
|
||||
require.NoError(t, err)
|
||||
require.Falsef(t, res.Valid,
|
||||
"a bit flip in %s at byte %d still verified", el.name, off)
|
||||
}
|
||||
}
|
||||
|
||||
// Swapping A and C (both G1) must not verify.
|
||||
res, err = zv.VerifyGroth16(id, in.c, in.b, in.a, in.public)
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.Valid, "A and C are interchangeable")
|
||||
|
||||
// The identity in place of any proof element must not verify.
|
||||
for i, sub := range [][3][]byte{
|
||||
{make([]byte, 64), in.b, in.c},
|
||||
{in.a, make([]byte, 128), in.c},
|
||||
{in.a, in.b, make([]byte, 64)},
|
||||
} {
|
||||
res, err := zv.VerifyGroth16(id, sub[0], sub[1], sub[2], in.public)
|
||||
require.NoError(t, err)
|
||||
require.Falsef(t, res.Valid, "the identity was accepted as proof element %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGr_KeyBinding: a proof must not carry over to a different key.
|
||||
func TestGr_KeyBinding(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
public := []*big.Int{big.NewInt(5)}
|
||||
in := grBuild(t, public)
|
||||
id := grRegister(t, zv, in)
|
||||
|
||||
res, err := zv.VerifyGroth16(id, in.a, in.b, in.c, public)
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Valid, "control")
|
||||
|
||||
// A key differing in a single element must reject the same proof.
|
||||
for _, mut := range []func(*VerifyingKey){
|
||||
func(k *VerifyingKey) { k.Alpha = grG1(big.NewInt(4)) },
|
||||
func(k *VerifyingKey) { k.Beta = grG2(big.NewInt(6)) },
|
||||
func(k *VerifyingKey) { k.Gamma = grG2(big.NewInt(8)) },
|
||||
func(k *VerifyingKey) { k.Delta = grG2(big.NewInt(12)) },
|
||||
func(k *VerifyingKey) { k.IC[0] = grG1(big.NewInt(99)) },
|
||||
func(k *VerifyingKey) { k.IC[1] = grG1(big.NewInt(98)) },
|
||||
} {
|
||||
alt := *in.vk
|
||||
alt.IC = slices.Clone(in.vk.IC)
|
||||
mut(&alt)
|
||||
|
||||
altID, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemGroth16, CircuitTransfer,
|
||||
alt.Alpha, alt.Beta, alt.Gamma, alt.Delta, alt.IC)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := zv.VerifyGroth16(altID, in.a, in.b, in.c, public)
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.Valid, "the proof verified under a modified key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGr_ThroughThePrecompile drives the same instance through op 0x01,
|
||||
// so the wire framing, the gas schedule and the verifier all agree on
|
||||
// where the public inputs are.
|
||||
func TestGr_ThroughThePrecompile(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
public := []*big.Int{big.NewInt(42), big.NewInt(1337)}
|
||||
in := grBuild(t, public)
|
||||
id := grRegister(t, zv, in)
|
||||
p := &zkVerifyPrecompile{verifier: zv}
|
||||
|
||||
wire := func(vkID [32]byte, pub []*big.Int, a, b, c []byte) []byte {
|
||||
out := make([]byte, 0, 1+inputsOff+len(pub)*32+256)
|
||||
out = append(out, OpVerifyGroth16)
|
||||
out = append(out, vkID[:]...)
|
||||
var n [4]byte
|
||||
n[3] = byte(len(pub))
|
||||
out = append(out, n[:]...)
|
||||
for _, x := range pub {
|
||||
out = append(out, padScalar32(x)...)
|
||||
}
|
||||
out = append(out, a...)
|
||||
out = append(out, b...)
|
||||
return append(out, c...)
|
||||
}
|
||||
|
||||
full := wire(id, public, in.a, in.b, in.c)
|
||||
|
||||
// The gas schedule must price the count the verifier will parse.
|
||||
require.Equal(t,
|
||||
uint64(GasGroth16Base)+uint64(len(public))*GasPerPublicInput,
|
||||
ZKVerifyPrecompile.RequiredGas(full))
|
||||
|
||||
ok, err := p.verifyGroth16(full[1:])
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "a satisfying proof must verify through the wire format")
|
||||
|
||||
// Changing a public-input word on the wire must flip the verdict —
|
||||
// this is the end-to-end statement binding.
|
||||
tampered := bytes.Clone(full)
|
||||
tampered[inputsOff+31] ^= 0x01
|
||||
ok, err = p.verifyGroth16(tampered[1:])
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok, "a modified public input still verified")
|
||||
|
||||
// Understating the count changes which bytes are read as the proof and
|
||||
// must not verify.
|
||||
under := bytes.Clone(full)
|
||||
under[inputsOff-1] = 1
|
||||
ok, _ = p.verifyGroth16(under[1:])
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestGr_PlonkParsesRealPoints drives plonkVerify past its parsing with
|
||||
// genuine curve points, so the challenge derivation and linearisation run
|
||||
// rather than short-circuiting on a malformed key.
|
||||
func TestGr_PlonkParsesRealPoints(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
|
||||
ic := make([][]byte, 9)
|
||||
for i := range 8 {
|
||||
ic[i] = grG1(big.NewInt(int64(i + 2)))
|
||||
}
|
||||
ic[8] = grG2(big.NewInt(31)) // X2
|
||||
|
||||
id, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemPlonk, CircuitTransfer,
|
||||
grG1(big.NewInt(41)), grG2(big.NewInt(43)),
|
||||
grG2(big.NewInt(47)), grG2(big.NewInt(53)), ic)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Nine real G1 commitments plus six scalars.
|
||||
proof := make([]byte, 0, 768)
|
||||
for i := range 9 {
|
||||
proof = append(proof, grG1(big.NewInt(int64(101+i)))...)
|
||||
}
|
||||
for i := range 6 {
|
||||
proof = append(proof, padScalar32(big.NewInt(int64(1000+i)))...)
|
||||
}
|
||||
require.Len(t, proof, 768)
|
||||
|
||||
public := []*big.Int{big.NewInt(7), big.NewInt(9)}
|
||||
res, err := zv.VerifyPlonk(id, proof, public)
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.Valid,
|
||||
"an arbitrary set of curve points is not a PLONK proof and must not verify")
|
||||
|
||||
// Every element must influence the verdict path without panicking, and
|
||||
// none of these arbitrary proofs may verify.
|
||||
for i := range 9 {
|
||||
bad := bytes.Clone(proof)
|
||||
bad[i*64] ^= 0x01
|
||||
res, err := zv.VerifyPlonk(id, bad, public)
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.Valid)
|
||||
}
|
||||
for _, pub := range [][]*big.Int{nil, {big.NewInt(1)}, {big.NewInt(7), big.NewInt(10)}} {
|
||||
res, err := zv.VerifyPlonk(id, proof, pub)
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.Valid)
|
||||
}
|
||||
|
||||
// A proof one byte short of the layout is refused.
|
||||
res, err = zv.VerifyPlonk(id, proof[:767], public)
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.Valid)
|
||||
}
|
||||
|
||||
// TestGr_PadScalar32 pins the scalar encoding used on the wire: exactly
|
||||
// 32 big-endian bytes, right-aligned, and oversized values truncated to
|
||||
// their low 32 bytes rather than overflowing the buffer.
|
||||
func TestGr_PadScalar32(t *testing.T) {
|
||||
require.Equal(t, make([]byte, 32), padScalar32(nil))
|
||||
require.Equal(t, make([]byte, 32), padScalar32(big.NewInt(0)))
|
||||
|
||||
one := padScalar32(big.NewInt(1))
|
||||
require.Len(t, one, 32)
|
||||
require.Equal(t, byte(1), one[31], "small values must be right-aligned")
|
||||
|
||||
// Exactly 32 bytes round-trips.
|
||||
max32 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
||||
require.Equal(t, bytes.Repeat([]byte{0xff}, 32), padScalar32(max32))
|
||||
|
||||
// More than 32 bytes keeps the LOW 32, and stays 32 bytes.
|
||||
big33 := new(big.Int).Lsh(big.NewInt(1), 264)
|
||||
big33.Add(big33, big.NewInt(0xAB))
|
||||
got := padScalar32(big33)
|
||||
require.Len(t, got, 32)
|
||||
require.Equal(t, byte(0xAB), got[31])
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/kzg4844"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Each test here exists because a mutation survived: the guard it covers
|
||||
// could be deleted outright and the rest of the suite stayed green. A
|
||||
// guard nothing fails on is a guard nobody is keeping.
|
||||
|
||||
// TestGrd_BatchGasBoundedByCalldata closes the batch half of the gas cap.
|
||||
// The header ops were covered; op 0x30 was not, so the cap could be
|
||||
// removed and a caller could declare 2^32 proofs in ten bytes of calldata
|
||||
// and be charged 2.1e14 gas — a valid batch priced out of every block.
|
||||
func TestGrd_BatchGasBoundedByCalldata(t *testing.T) {
|
||||
const entryHdr = 5
|
||||
|
||||
for _, size := range []int{5, 10, 25, 105, 1005} {
|
||||
in := make([]byte, size)
|
||||
in[0] = OpVerifyBatch
|
||||
binary.BigEndian.PutUint32(in[1:5], math.MaxUint32)
|
||||
|
||||
got := ZKVerifyPrecompile.RequiredGas(in)
|
||||
fits := (size - entryHdr) / entryHdr
|
||||
ceiling := max(uint64(fits)*GasPerBatchProof, MinCallGas)
|
||||
|
||||
require.LessOrEqualf(t, got, ceiling,
|
||||
"a %d-byte batch declaring 2^32 proofs was charged %d gas, but only "+
|
||||
"%d entries can fit", size, got, fits)
|
||||
}
|
||||
|
||||
// And the price does track the count while the count is affordable.
|
||||
body := make([]byte, 5+entryHdr*20)
|
||||
body[0] = OpVerifyBatch
|
||||
binary.BigEndian.PutUint32(body[1:5], 20)
|
||||
full := ZKVerifyPrecompile.RequiredGas(body)
|
||||
binary.BigEndian.PutUint32(body[1:5], 10)
|
||||
half := ZKVerifyPrecompile.RequiredGas(body)
|
||||
require.Greater(t, full, half, "the declared count must still move the price")
|
||||
}
|
||||
|
||||
// TestGrd_NonCanonicalPointRejected closes the canonical-x check on Halo2
|
||||
// points. Clearing the sign bit leaves 255 bits of x, but the Pallas base
|
||||
// field is only ~254 bits, so values in [p, 2^255) are expressible and
|
||||
// non-canonical. About half of them still satisfy the curve equation
|
||||
// after reduction, so the on-curve test alone does NOT reject them — only
|
||||
// the range test does. Two encodings for one point is malleability: the
|
||||
// same commitment can be written two ways and any transcript hashing the
|
||||
// bytes binds to the encoding rather than the point.
|
||||
func TestGrd_NonCanonicalPointRejected(t *testing.T) {
|
||||
// x = p + 1. Its cube plus five is a quadratic residue mod p, so this
|
||||
// passes the curve equation and is caught only by the range check.
|
||||
x := new(big.Int).Add(pallasFieldModulus, big.NewInt(1))
|
||||
require.Equal(t, 1, x.Cmp(pallasFieldModulus), "the fixture must be non-canonical")
|
||||
|
||||
buf := make([]byte, 32)
|
||||
x.FillBytes(buf)
|
||||
require.Zero(t, buf[0]&0x80, "the sign bit must be clear or the test proves nothing")
|
||||
|
||||
// The curve equation alone accepts it...
|
||||
reduced := new(big.Int).Mod(x, pallasFieldModulus)
|
||||
rhs := new(big.Int).Exp(reduced, big.NewInt(3), pallasFieldModulus)
|
||||
rhs.Add(rhs, big.NewInt(5))
|
||||
rhs.Mod(rhs, pallasFieldModulus)
|
||||
require.True(t, isQuadraticResidue(rhs, pallasFieldModulus),
|
||||
"fixture must satisfy the curve equation, else the range check is not what rejects it")
|
||||
|
||||
// ...so the range check is the only thing that can reject it.
|
||||
require.False(t, isValidCompressedPoint(buf),
|
||||
"a non-canonical x-coordinate was accepted as a point")
|
||||
|
||||
// The canonical representative of the same value IS accepted, which
|
||||
// shows the refusal is about the encoding and not the point.
|
||||
canon := make([]byte, 32)
|
||||
reduced.FillBytes(canon)
|
||||
require.True(t, isValidCompressedPoint(canon))
|
||||
}
|
||||
|
||||
// TestGrd_Halo2RoundBoundEnforcedWhenDataFits closes the IPA round bound.
|
||||
// The existing bounds tests used fixtures too short to hold the rounds
|
||||
// they declared, so a length check refused them and the range check was
|
||||
// never what bit. An unbounded round count means an unbounded allocation
|
||||
// driven by four attacker-chosen bytes.
|
||||
func TestGrd_Halo2RoundBoundEnforcedWhenDataFits(t *testing.T) {
|
||||
// Build a proof with 0 inputs / advice / instance and `rounds` rounds,
|
||||
// sized so every declared byte is actually present.
|
||||
build := func(rounds uint32) []byte {
|
||||
size := 32 + 4 + 4 + 4 + 4 + int(rounds)*32*2 + 32*3
|
||||
d := make([]byte, size)
|
||||
d[0] = 1 // non-zero vkID
|
||||
binary.BigEndian.PutUint32(d[44:48], rounds)
|
||||
return d
|
||||
}
|
||||
|
||||
// In range: the round bound lets it through (it fails later, on the
|
||||
// point validation, which is the control that the fixture is well
|
||||
// formed enough to reach the rounds).
|
||||
ok, err := parseHalo2Proof(build(32))
|
||||
require.NoError(t, err, "32 rounds is in range and fully backed by bytes")
|
||||
require.Equal(t, uint32(32), ok.numRounds)
|
||||
|
||||
// Out of range, with every declared byte present: only the bound can
|
||||
// reject these.
|
||||
for _, rounds := range []uint32{0, 33, 64, 1000} {
|
||||
d := build(rounds)
|
||||
require.GreaterOrEqualf(t, len(d), 48+int(rounds)*64+96,
|
||||
"fixture for %d rounds must be long enough", rounds)
|
||||
_, err := parseHalo2Proof(d)
|
||||
require.Errorf(t, err, "%d IPA rounds was accepted", rounds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrd_CommitmentLengthGuardCoversTheMerkleRoute closes the 0x22
|
||||
// length guard. The existing sweep used all-zero payloads, which never
|
||||
// select the Merkle sub-mode, so the Pedersen branch's own length check
|
||||
// masked the outer one. A payload of 77..95 bytes starting 0x01 goes down
|
||||
// the Merkle route, where the outer guard is the only one that applies.
|
||||
func TestGrd_CommitmentLengthGuardCoversTheMerkleRoute(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{verifier: NewZKVerifier()}
|
||||
|
||||
for n := 77; n < 96; n++ {
|
||||
data := make([]byte, n)
|
||||
data[0] = 0x01 // selects the Merkle sub-mode
|
||||
require.Truef(t, isMerkleCommitment(data),
|
||||
"fixture of %d bytes must select the Merkle route", n)
|
||||
|
||||
ok, err := p.verifyCommitment(data)
|
||||
require.Falsef(t, ok, "a %d-byte commitment payload was accepted", n)
|
||||
require.ErrorIsf(t, err, ErrInvalidInput,
|
||||
"a %d-byte payload must be refused by the length guard", n)
|
||||
}
|
||||
|
||||
// At 96 bytes the payload is long enough and the refusal changes
|
||||
// character — it is no longer about length.
|
||||
ok96 := make([]byte, 96)
|
||||
ok96[0] = 0x01
|
||||
_, err := p.verifyCommitment(ok96)
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrInvalidInput,
|
||||
"at 96 bytes the length guard must no longer be what refuses")
|
||||
}
|
||||
|
||||
// TestGrd_Groth16BatchToleratesAShortKey closes the IC-bounds check in
|
||||
// the pairing routine. VerifyGroth16 enforces arity before calling it, so
|
||||
// that path can never violate the bound — but verifyGroth16Batch calls
|
||||
// the pairing check directly with a FIXED four public inputs, whatever
|
||||
// the key holds. Without the bound that is an out-of-range index, i.e. a
|
||||
// panic, i.e. a node halted by a malformed rollup registration.
|
||||
func TestGrd_Groth16BatchToleratesAShortKey(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
|
||||
// Real, non-identity points so the degeneracy refusal does not
|
||||
// short-circuit before the loop is reached.
|
||||
shortIC := [][]byte{grG1(big.NewInt(2)), grG1(big.NewInt(3))}
|
||||
keyID, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemGroth16, CircuitRollupBatch,
|
||||
grG1(big.NewInt(5)), grG2(big.NewInt(7)),
|
||||
grG2(big.NewInt(11)), grG2(big.NewInt(13)), shortIC)
|
||||
require.NoError(t, err)
|
||||
|
||||
rollupID, err := zv.RegisterRollup(rolOwner, keyID, ProofSystemGroth16, 100, 60, rolSequencer)
|
||||
require.NoError(t, err)
|
||||
|
||||
batch := &RollupBatch{
|
||||
RollupID: rollupID,
|
||||
NewStateRoot: [32]byte{0x01},
|
||||
Transactions: 3,
|
||||
L1BatchNum: 1,
|
||||
Proposer: rolSequencer,
|
||||
Proof: &Proof{
|
||||
ProofSystem: ProofSystemGroth16,
|
||||
A: grG1(big.NewInt(17)),
|
||||
B: grG2(big.NewInt(19)),
|
||||
C: grG1(big.NewInt(23)),
|
||||
},
|
||||
}
|
||||
|
||||
// The batch supplies four public inputs against a two-point IC. This
|
||||
// must refuse, not index past the end.
|
||||
require.NotPanics(t, func() {
|
||||
err := zv.VerifyRollupBatch(rollupID, batch)
|
||||
require.ErrorIs(t, err, ErrInvalidProof)
|
||||
})
|
||||
|
||||
// Directly, so the bound is pinned independently of the rollup path.
|
||||
require.NotPanics(t, func() {
|
||||
vk := zv.VerifyingKeys[keyID]
|
||||
ok := zv.groth16PairingCheck(vk, batch.Proof.A, batch.Proof.B, batch.Proof.C,
|
||||
[]*big.Int{big.NewInt(1), big.NewInt(2), big.NewInt(3), big.NewInt(4)})
|
||||
require.False(t, ok, "more public inputs than IC points must not verify")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGrd_BLS12381NonCanonicalXRejected closes the field-range check on
|
||||
// compressed BLS12-381 points. The validator masks the top three flag
|
||||
// bits, leaving 381 bits of x — exactly the width of the base field
|
||||
// modulus — so values at or above p are expressible. Only the range test
|
||||
// rejects them; the flag tests pass and there is no on-curve test at all
|
||||
// on this path. Two encodings for one x is malleability.
|
||||
func TestGrd_BLS12381NonCanonicalXRejected(t *testing.T) {
|
||||
// Compression flag set, infinity flag clear, and the masked
|
||||
// x-coordinate is 0x1f followed by 47 x 0xff — far above p, whose
|
||||
// leading byte is 0x1a.
|
||||
bad := bytes.Repeat([]byte{0xff}, 48)
|
||||
bad[0] = 0x9f
|
||||
require.NotZero(t, bad[0]&0x80, "compression flag must be set")
|
||||
require.Zero(t, bad[0]&0x40, "infinity flag must be clear, or an earlier guard rejects")
|
||||
|
||||
err := validateBLS12381G1Point(bad)
|
||||
require.Error(t, err, "a non-canonical x-coordinate was accepted")
|
||||
require.Contains(t, err.Error(), "not in field")
|
||||
|
||||
// The flag checks in front of it still work, so the failure above is
|
||||
// specifically the range test.
|
||||
notCompressed := bytes.Clone(bad)
|
||||
notCompressed[0] = 0x1f
|
||||
require.ErrorContains(t, validateBLS12381G1Point(notCompressed), "compressed")
|
||||
|
||||
infinityFlagged := bytes.Clone(bad)
|
||||
infinityFlagged[0] = 0xdf // both flags set on a non-zero point
|
||||
require.ErrorContains(t, validateBLS12381G1Point(infinityFlagged), "infinity")
|
||||
|
||||
// A real commitment, whose x IS in the field, passes.
|
||||
real, err := kzg4844.BlobToCommitment(makeBlob(9, 8, 7))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, validateBLS12381G1Point(real[:]))
|
||||
}
|
||||
|
||||
// TestGrd_InfinityIsNotAValidCommitment closes the identity check on
|
||||
// BLS12-381 G1 points, and shows why it is load-bearing rather than
|
||||
// hygiene.
|
||||
//
|
||||
// A commitment to a CONSTANT polynomial opens to the same value at every
|
||||
// point, and its opening proof is the identity. So the triple
|
||||
// (commit(c), c, identity) satisfies the KZG equation at ANY evaluation
|
||||
// point — including one derived by Fiat-Shamir from a statement the
|
||||
// prover never proved. Accepting the identity as an opening proof turns
|
||||
// the fflonk verifier into a universal forge.
|
||||
func TestGrd_InfinityIsNotAValidCommitment(t *testing.T) {
|
||||
// The canonical BLS12-381 infinity encoding: compression and infinity
|
||||
// flags set, everything else zero.
|
||||
inf := make([]byte, 48)
|
||||
inf[0] = 0xc0
|
||||
require.False(t, validG1Point(inf), "the identity was accepted as a G1 point")
|
||||
require.False(t, validG1Point(make([]byte, 48)), "all-zero bytes were accepted")
|
||||
|
||||
// A genuine commitment is accepted, so the refusal is not blanket.
|
||||
real, err := kzg4844.BlobToCommitment(makeBlob(1, 2, 3))
|
||||
require.NoError(t, err)
|
||||
require.True(t, validG1Point(real[:]))
|
||||
|
||||
// Now the forge itself. Commit to the constant 7; its opening proof at
|
||||
// any point is the identity, and the claimed value is 7 everywhere.
|
||||
blob := new(kzg4844.Blob)
|
||||
for i := 0; i < len(blob)/32; i++ {
|
||||
blob[i*32+31] = 7
|
||||
}
|
||||
c, err := kzg4844.BlobToCommitment(blob)
|
||||
require.NoError(t, err)
|
||||
var anywhere kzg4844.Point
|
||||
anywhere[31] = 3
|
||||
w, claim, err := kzg4844.ComputeProof(blob, anywhere)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The opening proof really is the identity, and the library really
|
||||
// does accept it at an unrelated point — that is the whole hazard.
|
||||
require.Equal(t, inf, w[:], "the constant-polynomial opening proof is the identity")
|
||||
var elsewhere kzg4844.Point
|
||||
elsewhere[31] = 99
|
||||
require.NoError(t, kzg4844.VerifyProof(c, elsewhere, claim, w),
|
||||
"one triple verifying at every point is what makes this a universal forge")
|
||||
|
||||
// Assembled into an fflonk proof, both openings are of that constant,
|
||||
// so nothing about any statement is proved. It must be refused.
|
||||
proof := make([]byte, 0, 448)
|
||||
proof = append(proof, c[:]...) // C1
|
||||
proof = append(proof, c[:]...) // C2
|
||||
proof = append(proof, w[:]...) // W1 = identity
|
||||
proof = append(proof, w[:]...) // W2 = identity
|
||||
proof = append(proof, claim[:]...) // evaluations[0]
|
||||
proof = append(proof, claim[:]...) // evaluations[1]
|
||||
proof = append(proof, bytes.Repeat([]byte{0}, 6*32)...)
|
||||
require.Len(t, proof, 448)
|
||||
|
||||
zv := NewZKVerifier()
|
||||
vk := fflonkTestVK()
|
||||
for _, pub := range sndStatements {
|
||||
require.Falsef(t, zv.fflonkVerify(vk, proof, pub),
|
||||
"the constant-polynomial forge verified for statement %v", pub)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/precompile/contract"
|
||||
"github.com/luxfi/precompile/modules"
|
||||
"github.com/luxfi/precompile/precompileconfig"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers (all prefixed mod)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// modOther is a second, unrelated Config implementation. Equal must reject it
|
||||
// even when its upgrade window is identical.
|
||||
type modOther struct {
|
||||
Upgrade precompileconfig.Upgrade
|
||||
}
|
||||
|
||||
func (c *modOther) Key() string { return "modOtherConfig" }
|
||||
func (c *modOther) Timestamp() *uint64 { return c.Upgrade.Timestamp() }
|
||||
func (c *modOther) IsDisabled() bool { return c.Upgrade.Disable }
|
||||
func (c *modOther) Verify(precompileconfig.ChainConfig) error { return nil }
|
||||
func (c *modOther) Equal(precompileconfig.Config) bool { return false }
|
||||
|
||||
var _ precompileconfig.Config = (*modOther)(nil)
|
||||
|
||||
// modAt builds a Config enabled at the given timestamp.
|
||||
func modAt(ts uint64) *Config {
|
||||
return &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &ts}}
|
||||
}
|
||||
|
||||
// modDisabled builds a Config enabled at the given timestamp and switched off.
|
||||
func modDisabled(ts uint64) *Config {
|
||||
c := modAt(ts)
|
||||
c.Upgrade.Disable = true
|
||||
return c
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMod_ModuleIsRegisteredAtItsAddress(t *testing.T) {
|
||||
got, ok := modules.GetPrecompileModuleByAddress(ZKVerifyContractAddress)
|
||||
require.True(t, ok, "init must have registered the module at 0x0900…00")
|
||||
require.Equal(t, ConfigKey, got.ConfigKey)
|
||||
require.Equal(t, ZKVerifyContractAddress, got.Address)
|
||||
require.Same(t, ZKVerifyPrecompile, got.Contract, "the registered contract is the package singleton")
|
||||
require.NotNil(t, got.Configurator)
|
||||
require.False(t, got.AlwaysOn, "the ZK precompile activates from a config, not unconditionally")
|
||||
|
||||
// Lookup by key agrees with lookup by address.
|
||||
byKey, ok := modules.GetPrecompileModule(ConfigKey)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, got.Address, byKey.Address)
|
||||
require.Same(t, got.Contract, byKey.Contract)
|
||||
|
||||
// The declared package var is what was registered.
|
||||
require.Equal(t, Module.ConfigKey, got.ConfigKey)
|
||||
require.Equal(t, Module.Address, got.Address)
|
||||
|
||||
// And it sits inside the reserved precompile space, which is why
|
||||
// RegisterModule accepted it.
|
||||
require.True(t, modules.ReservedAddress(ZKVerifyContractAddress))
|
||||
|
||||
// Nothing else claimed the same address or key.
|
||||
count := 0
|
||||
for _, m := range modules.RegisteredModules() {
|
||||
if m.Address == ZKVerifyContractAddress || m.ConfigKey == ConfigKey {
|
||||
count++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestMod_AddressesAreDistinctAndInRange(t *testing.T) {
|
||||
// Every declared address must be unique, or two features would answer at
|
||||
// the same call target.
|
||||
declared := map[string]string{
|
||||
"zkVerify": ZKVerifyContractAddress.Hex(),
|
||||
"groth16": Groth16ContractAddress.Hex(),
|
||||
"plonk": PlonkContractAddress.Hex(),
|
||||
"fflonk": FflonkContractAddress.Hex(),
|
||||
"halo2": Halo2ContractAddress.Hex(),
|
||||
"kzg": KZGContractAddress.Hex(),
|
||||
"ipa": IPAContractAddress.Hex(),
|
||||
"privacyPool": PrivacyPoolContractAddress.Hex(),
|
||||
"nullifier": NullifierContractAddress.Hex(),
|
||||
"commitment": CommitmentContractAddress.Hex(),
|
||||
"rangeProof": RangeProofContractAddress.Hex(),
|
||||
"rollupVerify": RollupVerifyContractAddress.Hex(),
|
||||
"stateRoot": StateRootContractAddress.Hex(),
|
||||
"batchProof": BatchProofContractAddress.Hex(),
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for name, hex := range declared {
|
||||
prev, dup := seen[hex]
|
||||
require.Falsef(t, dup, "%s and %s share address %s", prev, name, hex)
|
||||
seen[hex] = name
|
||||
}
|
||||
require.Len(t, seen, len(declared))
|
||||
|
||||
// Only the entry point is registered; the other addresses are documentary
|
||||
// and answer nothing, so a call to one of them is not a ZK call.
|
||||
for name, hex := range declared {
|
||||
if name == "zkVerify" {
|
||||
continue
|
||||
}
|
||||
require.NotEqual(t, ZKVerifyContractAddress.Hex(), hex)
|
||||
_, ok := modules.GetPrecompileModuleByAddress(common.HexToAddress(hex))
|
||||
require.Falsef(t, ok, "%s is declared but not registered", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// configurator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMod_MakeConfigHandsBackAFreshUsableConfig(t *testing.T) {
|
||||
c := &configurator{}
|
||||
|
||||
got := c.MakeConfig()
|
||||
require.NotNil(t, got)
|
||||
require.IsType(t, &Config{}, got)
|
||||
|
||||
// Zero-valued: never enabled, not disabled.
|
||||
require.Nil(t, got.Timestamp(), "a fresh config names no activation time")
|
||||
require.False(t, got.IsDisabled())
|
||||
require.Equal(t, ConfigKey, got.Key())
|
||||
|
||||
// Fresh each call, so two chains being configured cannot share state.
|
||||
other := c.MakeConfig()
|
||||
require.NotSame(t, got, other)
|
||||
got.(*Config).Upgrade.Disable = true
|
||||
require.False(t, other.IsDisabled())
|
||||
|
||||
// The registered configurator behaves the same as a bare one.
|
||||
m, ok := modules.GetPrecompileModuleByAddress(ZKVerifyContractAddress)
|
||||
require.True(t, ok)
|
||||
require.IsType(t, &Config{}, m.MakeConfig())
|
||||
}
|
||||
|
||||
func TestMod_ConfigureTouchesNothing(t *testing.T) {
|
||||
c := &configurator{}
|
||||
|
||||
// The precompile keeps no state, so Configure must succeed without
|
||||
// reading or writing any of its arguments. Handing it nothing proves it.
|
||||
require.NoError(t, c.Configure(nil, nil, nil, nil))
|
||||
require.NoError(t, c.Configure(nil, c.MakeConfig(), nil, nil))
|
||||
require.NoError(t, c.Configure(nil, modAt(7), nil, nil))
|
||||
require.NoError(t, c.Configure(nil, &modOther{}, nil, nil),
|
||||
"Configure does not inspect the config, so even a foreign one passes")
|
||||
|
||||
// Same through the registered module.
|
||||
m, ok := modules.GetPrecompileModuleByAddress(ZKVerifyContractAddress)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, m.Configure(nil, m.MakeConfig(), nil, nil))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMod_ConfigKeyIsTheRegistrationKey(t *testing.T) {
|
||||
require.Equal(t, ConfigKey, (&Config{}).Key())
|
||||
require.Equal(t, ConfigKey, modAt(9).Key())
|
||||
require.Equal(t, ConfigKey, modDisabled(9).Key())
|
||||
|
||||
// The key a config reports is the key it is registered under, which is how
|
||||
// a chain config finds this precompile.
|
||||
m, ok := modules.GetPrecompileModule((&Config{}).Key())
|
||||
require.True(t, ok)
|
||||
require.Equal(t, ZKVerifyContractAddress, m.Address)
|
||||
}
|
||||
|
||||
func TestMod_TimestampAndIsDisabledMirrorTheUpgrade(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
wantTS *uint64
|
||||
disabled bool
|
||||
}{
|
||||
{"zero value is never enabled", &Config{}, nil, false},
|
||||
{"genesis", modAt(0), func() *uint64 { v := uint64(0); return &v }(), false},
|
||||
{"future", modAt(1 << 40), func() *uint64 { v := uint64(1 << 40); return &v }(), false},
|
||||
{"disabled at genesis", modDisabled(0), func() *uint64 { v := uint64(0); return &v }(), true},
|
||||
{"disabled later", modDisabled(99), func() *uint64 { v := uint64(99); return &v }(), true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.wantTS, tc.cfg.Timestamp())
|
||||
require.Equal(t, tc.disabled, tc.cfg.IsDisabled())
|
||||
|
||||
// Not a copy: the config forwards the embedded upgrade's own
|
||||
// answers, so mutating the upgrade is visible through the config.
|
||||
require.Equal(t, tc.cfg.Upgrade.Timestamp(), tc.cfg.Timestamp())
|
||||
require.Equal(t, tc.cfg.Upgrade.IsDisabled(), tc.cfg.IsDisabled())
|
||||
})
|
||||
}
|
||||
|
||||
// A nil timestamp and a zero timestamp are different states: never
|
||||
// enabled versus enabled from genesis.
|
||||
require.Nil(t, (&Config{}).Timestamp())
|
||||
require.NotNil(t, modAt(0).Timestamp())
|
||||
|
||||
c := modAt(5)
|
||||
*c.Upgrade.BlockTimestamp = 6
|
||||
require.EqualValues(t, 6, *c.Timestamp())
|
||||
c.Upgrade.Disable = true
|
||||
require.True(t, c.IsDisabled())
|
||||
}
|
||||
|
||||
func TestMod_Equal(t *testing.T) {
|
||||
ts5a, ts5b := modAt(5), modAt(5)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
a *Config
|
||||
b precompileconfig.Config
|
||||
equal bool
|
||||
}{
|
||||
{"same zero value", &Config{}, &Config{}, true},
|
||||
{"itself", ts5a, ts5a, true},
|
||||
{"same timestamp, different pointers", ts5a, ts5b, true},
|
||||
{"same disabled state", modDisabled(5), modDisabled(5), true},
|
||||
|
||||
{"different timestamp", modAt(5), modAt(6), false},
|
||||
{"timestamp against none", modAt(5), &Config{}, false},
|
||||
{"none against timestamp", &Config{}, modAt(5), false},
|
||||
{"genesis against never", modAt(0), &Config{}, false},
|
||||
{"same timestamp, different disable flag", modAt(5), modDisabled(5), false},
|
||||
{"disable flag alone", &Config{Upgrade: precompileconfig.Upgrade{Disable: true}}, &Config{}, false},
|
||||
|
||||
{"a different config type", &Config{}, &modOther{}, false},
|
||||
{"a different config type with the same upgrade", modAt(5), &modOther{Upgrade: precompileconfig.Upgrade{BlockTimestamp: func() *uint64 { v := uint64(5); return &v }()}}, false},
|
||||
{"a nil interface", &Config{}, nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.equal, tc.a.Equal(tc.b))
|
||||
})
|
||||
}
|
||||
|
||||
// Symmetric wherever both sides are *Config.
|
||||
for _, pair := range [][2]*Config{
|
||||
{modAt(5), modAt(6)},
|
||||
{modAt(5), modAt(5)},
|
||||
{&Config{}, modAt(0)},
|
||||
{modAt(5), modDisabled(5)},
|
||||
} {
|
||||
require.Equal(t, pair[0].Equal(pair[1]), pair[1].Equal(pair[0]), "Equal must be symmetric")
|
||||
}
|
||||
|
||||
// Every timestamp is distinguishable from every other.
|
||||
for i := range uint64(8) {
|
||||
for j := range uint64(8) {
|
||||
require.Equalf(t, i == j, modAt(i).Equal(modAt(j)), "%d vs %d", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMod_EqualPanicsOnATypedNilConfig(t *testing.T) {
|
||||
// An untyped nil misses the type assertion and is refused. A typed
|
||||
// (*Config)(nil) passes the assertion and is then dereferenced, so the
|
||||
// two spellings of "no config" behave completely differently.
|
||||
require.False(t, (&Config{}).Equal(nil))
|
||||
require.Panics(t, func() { (&Config{}).Equal((*Config)(nil)) })
|
||||
}
|
||||
|
||||
func TestMod_VerifyAcceptsEveryConfig(t *testing.T) {
|
||||
// Verify is called on startup and a returned error is fatal. This
|
||||
// precompile takes no parameters, so nothing can be misconfigured and it
|
||||
// accepts unconditionally, including with no chain config to inspect.
|
||||
for _, cfg := range []*Config{
|
||||
{},
|
||||
modAt(0),
|
||||
modAt(1 << 40),
|
||||
modDisabled(0),
|
||||
} {
|
||||
require.NoError(t, cfg.Verify(nil))
|
||||
}
|
||||
|
||||
// Verified configs stay usable: Verify has no side effect.
|
||||
c := modAt(5)
|
||||
before := *c.Upgrade.BlockTimestamp
|
||||
require.NoError(t, c.Verify(nil))
|
||||
require.Equal(t, before, *c.Upgrade.BlockTimestamp)
|
||||
require.True(t, c.Equal(modAt(5)))
|
||||
}
|
||||
|
||||
func TestMod_ConfigRoundTripsThroughJSON(t *testing.T) {
|
||||
// The config is what a chain's genesis or upgrade file decodes into, so
|
||||
// the json tags are load-bearing.
|
||||
cfg := (&configurator{}).MakeConfig()
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"upgrade":{"blockTimestamp":7}}`), cfg))
|
||||
require.NotNil(t, cfg.Timestamp())
|
||||
require.EqualValues(t, 7, *cfg.Timestamp())
|
||||
require.False(t, cfg.IsDisabled())
|
||||
require.Equal(t, ConfigKey, cfg.Key())
|
||||
require.True(t, cfg.Equal(modAt(7)))
|
||||
|
||||
off := (&configurator{}).MakeConfig()
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"upgrade":{"blockTimestamp":7,"disable":true}}`), off))
|
||||
require.True(t, off.IsDisabled())
|
||||
require.False(t, cfg.Equal(off), "the same window with the disable flag set is a different config")
|
||||
|
||||
// An empty document leaves a config that never activates.
|
||||
empty := (&configurator{}).MakeConfig()
|
||||
require.NoError(t, json.Unmarshal([]byte(`{}`), empty))
|
||||
require.Nil(t, empty.Timestamp())
|
||||
require.False(t, empty.IsDisabled())
|
||||
|
||||
// And it re-encodes to something that decodes back to itself.
|
||||
encoded, err := json.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
decoded := (&configurator{}).MakeConfig()
|
||||
require.NoError(t, json.Unmarshal(encoded, decoded))
|
||||
require.True(t, cfg.Equal(decoded))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// what is wired to the EVM, and what is not
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMod_OnlyTheZKVerifyContractIsReachableFromTheEVM(t *testing.T) {
|
||||
// A type reaches the EVM only by satisfying StatefulPrecompiledContract
|
||||
// and being registered. zkVerifyPrecompile does both.
|
||||
var registered any = ZKVerifyPrecompile
|
||||
_, ok := registered.(contract.StatefulPrecompiledContract)
|
||||
require.True(t, ok)
|
||||
|
||||
// The three RequiredGas methods on STARKVerifier, Poseidon2Scheme and
|
||||
// PedersenScheme are not precompile gas functions: none of those types has
|
||||
// a Run method, so none can be registered, and none is. They are library
|
||||
// pricing helpers with no caller in this module.
|
||||
for _, orphan := range []struct {
|
||||
name string
|
||||
v any
|
||||
}{
|
||||
{"STARKVerifier", &STARKVerifier{}},
|
||||
{"Poseidon2Scheme", NewPoseidon2Scheme()},
|
||||
{"PedersenScheme", NewPedersenScheme()},
|
||||
} {
|
||||
_, isContract := orphan.v.(contract.StatefulPrecompiledContract)
|
||||
require.Falsef(t, isContract, "%s must not silently become a precompile", orphan.name)
|
||||
}
|
||||
|
||||
// Go's type checker makes the stronger statement: a type switch on
|
||||
// m.Contract with those three cases does not compile ("impossible type
|
||||
// switch case ... missing method Run"), so no module in any binary can
|
||||
// hold one. What remains to check is that this package registered exactly
|
||||
// one contract, and that it is the one with a Run method.
|
||||
registeredHere := 0
|
||||
for _, m := range modules.RegisteredModules() {
|
||||
if m.ConfigKey == ConfigKey {
|
||||
registeredHere++
|
||||
require.Same(t, ZKVerifyPrecompile, m.Contract)
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, registeredHere, "one config key, one contract, one address")
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/consensys/gnark-crypto/ecc/bn254"
|
||||
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
// pedScalar renders u as the canonical 32-byte encoding of a BN254 scalar.
|
||||
func pedScalar(u uint64) [32]byte {
|
||||
var e fr.Element
|
||||
e.SetUint64(u)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
// pedRandScalar returns a uniform scalar and its canonical encoding.
|
||||
func pedRandScalar(t *testing.T) (fr.Element, [32]byte) {
|
||||
t.Helper()
|
||||
var e fr.Element
|
||||
_, err := e.SetRandom()
|
||||
require.NoError(t, err)
|
||||
return e, e.Bytes()
|
||||
}
|
||||
|
||||
// pedUnknown returns 32 bytes that name no point the process has ever committed
|
||||
// to, and proves it by checking the lookup fails.
|
||||
func pedUnknown(t *testing.T, tag byte) [32]byte {
|
||||
t.Helper()
|
||||
var k [32]byte
|
||||
k[0] = 0x5A
|
||||
k[1] = tag
|
||||
for i := 2; i < 32; i++ {
|
||||
k[i] = byte(i*7) ^ tag
|
||||
}
|
||||
_, err := decompressG1(k)
|
||||
require.ErrorIs(t, err, ErrPointNotOnCurve, "fixture must not name a cached point")
|
||||
return k
|
||||
}
|
||||
|
||||
// pedPoint multiplies the BN254 base generator by k.
|
||||
func pedPoint(k int64) bn254.G1Affine {
|
||||
_, _, g1, _ := bn254.Generators()
|
||||
var out bn254.G1Affine
|
||||
out.ScalarMultiplication(&g1, big.NewInt(k))
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- Commit / Verify ----------
|
||||
|
||||
func TestPed_CommitIsDeterministicAndSeparating(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
v1, r1 := pedScalar(100), pedScalar(7)
|
||||
v2, r2 := pedScalar(101), pedScalar(8)
|
||||
|
||||
c, err := p.Commit(v1, r1)
|
||||
require.NoError(t, err)
|
||||
|
||||
again, err := p.Commit(v1, r1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c, again, "commitment must be a function of (value, blinding)")
|
||||
|
||||
// Changing either input must move the commitment. Same value under a
|
||||
// different blinding is the property that makes the scheme hiding at all:
|
||||
// if it did not move, the commitment would leak the value directly.
|
||||
otherBlinding, err := p.Commit(v1, r2)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, c, otherBlinding, "same value, new blinding must give a new commitment")
|
||||
|
||||
otherValue, err := p.Commit(v2, r1)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, c, otherValue, "new value, same blinding must give a new commitment")
|
||||
require.NotEqual(t, otherBlinding, otherValue)
|
||||
}
|
||||
|
||||
func TestPed_VerifyAcceptsOnlyTheRealOpening(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
value, blinding := pedScalar(4242), pedScalar(999)
|
||||
c, err := p.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
|
||||
ok, err := p.Verify(c, value, blinding)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "the true opening must verify")
|
||||
|
||||
zero := [32]byte{}
|
||||
cases := []struct {
|
||||
name string
|
||||
value, binding [32]byte
|
||||
}{
|
||||
{"wrong value", pedScalar(4243), blinding},
|
||||
{"wrong blinding", value, pedScalar(1000)},
|
||||
{"both wrong", pedScalar(1), pedScalar(2)},
|
||||
{"zero value", zero, blinding},
|
||||
{"zero blinding", value, zero},
|
||||
{"all zero", zero, zero},
|
||||
{"value and blinding swapped", blinding, value},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ok, err := p.Verify(c, tc.value, tc.binding)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok, "a verifier that accepts %q is broken", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_VerifyRejectsTheIdentityCommitment(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
// Commit(0,0) is the point at infinity. It must not act as a wildcard that
|
||||
// opens to arbitrary values.
|
||||
zero := [32]byte{}
|
||||
identity, err := p.Commit(zero, zero)
|
||||
require.NoError(t, err)
|
||||
|
||||
var inf bn254.G1Affine
|
||||
require.Equal(t, compressG1(&inf), identity, "Commit(0,0) is the identity element")
|
||||
|
||||
ok, err := p.Verify(identity, zero, zero)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
for _, tc := range [][2][32]byte{
|
||||
{pedScalar(1), zero},
|
||||
{zero, pedScalar(1)},
|
||||
{pedScalar(5), pedScalar(5)},
|
||||
} {
|
||||
ok, err := p.Verify(identity, tc[0], tc[1])
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok, "identity commitment must not open to a non-zero value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_VerifyRefusesUnknownCommitments(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
value, blinding := pedScalar(3), pedScalar(4)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
c [32]byte
|
||||
}{
|
||||
{"never committed", pedUnknown(t, 0x01)},
|
||||
{"all zero", [32]byte{}},
|
||||
{"all ones", [32]byte{
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ok, err := p.Verify(tc.c, value, blinding)
|
||||
require.Error(t, err)
|
||||
require.False(t, ok, "an unresolvable commitment must never verify")
|
||||
})
|
||||
}
|
||||
|
||||
// A single flipped bit of a real commitment must not resolve either.
|
||||
real, err := p.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
near := real
|
||||
near[17] ^= 0x01
|
||||
ok, err := p.Verify(near, value, blinding)
|
||||
require.Error(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPed_NonCanonicalOpeningIsAccepted(t *testing.T) {
|
||||
// DEFECT (reported, not fixed): value and blinding are reduced mod r by
|
||||
// fr.SetBytes, so an opening whose bytes differ from the committed bytes
|
||||
// still verifies. Byte-level binding does not hold. This pins the current
|
||||
// behaviour so a fix is visible.
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
value, blinding := pedScalar(10), pedScalar(11)
|
||||
c, err := p.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
|
||||
var shifted [32]byte
|
||||
over := new(big.Int).Add(fr.Modulus(), big.NewInt(10))
|
||||
ob := over.Bytes()
|
||||
require.Len(t, ob, 32, "value + r must still fit in 32 bytes")
|
||||
copy(shifted[:], ob)
|
||||
require.NotEqual(t, value, shifted)
|
||||
|
||||
ok, err := p.Verify(c, shifted, blinding)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "current behaviour: a second, different byte string opens the same commitment")
|
||||
}
|
||||
|
||||
// ---------- CommitWithOpening ----------
|
||||
|
||||
func TestPed_CommitWithOpeningProducesAVerifiableOpening(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
for _, amount := range []*big.Int{
|
||||
big.NewInt(0),
|
||||
big.NewInt(1),
|
||||
big.NewInt(1_000_000),
|
||||
new(big.Int).Sub(fr.Modulus(), big.NewInt(1)),
|
||||
} {
|
||||
c, blinding, err := p.CommitWithOpening(amount)
|
||||
require.NoError(t, err)
|
||||
|
||||
var value [32]byte
|
||||
vb := amount.Bytes()
|
||||
copy(value[32-len(vb):], vb)
|
||||
|
||||
ok, err := p.Verify(c, value, blinding)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "opening returned by CommitWithOpening(%s) must verify", amount)
|
||||
|
||||
wrong, err := p.Verify(c, pedScalar(amount.Uint64()+1), blinding)
|
||||
require.NoError(t, err)
|
||||
require.False(t, wrong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_CommitWithOpeningDrawsFreshBlinding(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
amount := big.NewInt(500)
|
||||
|
||||
seen := map[[32]byte]bool{}
|
||||
commitments := map[[32]byte]bool{}
|
||||
for range 8 {
|
||||
c, blinding, err := p.CommitWithOpening(amount)
|
||||
require.NoError(t, err)
|
||||
require.False(t, seen[blinding], "blinding factor repeated: randomness is not fresh")
|
||||
require.False(t, commitments[c], "commitment repeated for the same value")
|
||||
seen[blinding] = true
|
||||
commitments[c] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_CommitWithOpeningCountsCommitments(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
require.Zero(t, p.TotalCommitments)
|
||||
|
||||
for i := range 5 {
|
||||
_, _, err := p.CommitWithOpening(big.NewInt(int64(i)))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, i+1, p.TotalCommitments, "one commitment counted per call")
|
||||
}
|
||||
|
||||
// Commit alone does not count: the counter tracks openings handed out, not
|
||||
// commitments produced.
|
||||
before := p.TotalCommitments
|
||||
_, err := p.Commit(pedScalar(1), pedScalar(2))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before, p.TotalCommitments)
|
||||
}
|
||||
|
||||
func TestPed_VerifyCountsVerifications(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
require.Zero(t, p.TotalVerifications)
|
||||
|
||||
c, err := p.Commit(pedScalar(1), pedScalar(2))
|
||||
require.NoError(t, err)
|
||||
|
||||
for i := range 4 {
|
||||
_, err := p.Verify(c, pedScalar(1), pedScalar(2))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, i+1, p.TotalVerifications)
|
||||
}
|
||||
|
||||
// A rejected verification short-circuits before the counter, so failures
|
||||
// are invisible to the statistics.
|
||||
before := p.TotalVerifications
|
||||
_, err = p.Verify(pedUnknown(t, 0x02), pedScalar(1), pedScalar(2))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, before, p.TotalVerifications)
|
||||
}
|
||||
|
||||
// ---------- homomorphism ----------
|
||||
|
||||
func TestPed_AddIsHomomorphic(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
a, b := pedScalar(10), pedScalar(20)
|
||||
var av, bv fr.Element
|
||||
av.SetUint64(10)
|
||||
bv.SetUint64(20)
|
||||
r1e, r1 := pedRandScalar(t)
|
||||
r2e, r2 := pedRandScalar(t)
|
||||
|
||||
c1, err := p.Commit(a, r1)
|
||||
require.NoError(t, err)
|
||||
c2, err := p.Commit(b, r2)
|
||||
require.NoError(t, err)
|
||||
|
||||
var sumV, sumR fr.Element
|
||||
sumV.Add(&av, &bv)
|
||||
sumR.Add(&r1e, &r2e)
|
||||
sumVb, sumRb := sumV.Bytes(), sumR.Bytes()
|
||||
|
||||
direct, err := p.Commit(sumVb, sumRb)
|
||||
require.NoError(t, err)
|
||||
|
||||
added, err := p.Add(c1, c2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, direct, added, "Commit(a,r1)+Commit(b,r2) must be Commit(a+b, r1+r2)")
|
||||
|
||||
// The combined commitment opens to the combined opening and nothing else.
|
||||
ok, err := p.Verify(added, sumVb, sumRb)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
ok, err = p.Verify(added, a, r1)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
|
||||
// Addition of points is commutative, so the operation must be too.
|
||||
swapped, err := p.Add(c2, c1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, added, swapped)
|
||||
}
|
||||
|
||||
func TestPed_SubUndoesAdd(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
_, r1 := pedRandScalar(t)
|
||||
_, r2 := pedRandScalar(t)
|
||||
c1, err := p.Commit(pedScalar(7), r1)
|
||||
require.NoError(t, err)
|
||||
c2, err := p.Commit(pedScalar(9), r2)
|
||||
require.NoError(t, err)
|
||||
|
||||
sum, err := p.Add(c1, c2)
|
||||
require.NoError(t, err)
|
||||
|
||||
back, err := p.Sub(sum, c2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c1, back, "Sub must invert Add")
|
||||
|
||||
backOther, err := p.Sub(sum, c1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c2, backOther)
|
||||
|
||||
// Subtracting a commitment from itself yields the identity, which is
|
||||
// exactly the commitment to zero under a zero blinding.
|
||||
zero := [32]byte{}
|
||||
identity, err := p.Commit(zero, zero)
|
||||
require.NoError(t, err)
|
||||
self, err := p.Sub(c1, c1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, identity, self)
|
||||
|
||||
// Sub is not symmetric: c1-c2 and c2-c1 are different points.
|
||||
fwd, err := p.Sub(c1, c2)
|
||||
require.NoError(t, err)
|
||||
rev, err := p.Sub(c2, c1)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, fwd, rev)
|
||||
}
|
||||
|
||||
func TestPed_AddAndSubRefuseUnknownOperands(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
known, err := p.Commit(pedScalar(1), pedScalar(1))
|
||||
require.NoError(t, err)
|
||||
unknownA := pedUnknown(t, 0x03)
|
||||
unknownB := pedUnknown(t, 0x04)
|
||||
|
||||
ops := map[string]func(a, b [32]byte) ([32]byte, error){
|
||||
"add": p.Add,
|
||||
"sub": p.Sub,
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
a, b [32]byte
|
||||
}{
|
||||
{"left unknown", unknownA, known},
|
||||
{"right unknown", known, unknownB},
|
||||
{"both unknown", unknownA, unknownB},
|
||||
{"left zero", [32]byte{}, known},
|
||||
{"right zero", known, [32]byte{}},
|
||||
}
|
||||
for opName, op := range ops {
|
||||
for _, tc := range cases {
|
||||
t.Run(opName+"/"+tc.name, func(t *testing.T) {
|
||||
got, err := op(tc.a, tc.b)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, [32]byte{}, got, "no result may be handed back on error")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- vector commitments ----------
|
||||
|
||||
func TestPed_VectorCommitBinding(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
_, blinding := pedRandScalar(t)
|
||||
|
||||
values := [][32]byte{pedScalar(1), pedScalar(2), pedScalar(3)}
|
||||
c, err := p.VectorCommit(values, blinding)
|
||||
require.NoError(t, err)
|
||||
|
||||
same, err := p.VectorCommit([][32]byte{pedScalar(1), pedScalar(2), pedScalar(3)}, blinding)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c, same, "vector commitment must be a function of its inputs")
|
||||
|
||||
// Every position is bound by its own generator, so permuting the vector
|
||||
// must move the commitment: otherwise a note's amount and asset id could
|
||||
// be swapped without detection.
|
||||
permuted, err := p.VectorCommit([][32]byte{pedScalar(2), pedScalar(1), pedScalar(3)}, blinding)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, c, permuted, "position must matter")
|
||||
|
||||
changed, err := p.VectorCommit([][32]byte{pedScalar(1), pedScalar(2), pedScalar(4)}, blinding)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, c, changed)
|
||||
|
||||
_, otherBlinding := pedRandScalar(t)
|
||||
reblinded, err := p.VectorCommit(values, otherBlinding)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, c, reblinded)
|
||||
|
||||
// A non-zero element added to the tail moves the commitment.
|
||||
grown, err := p.VectorCommit(append(append([][32]byte{}, values...), pedScalar(4)), blinding)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, c, grown)
|
||||
|
||||
// DEFECT (reported, not fixed): length is NOT bound. A zero element
|
||||
// contributes the identity, so any number of trailing zeros compresses to
|
||||
// the same commitment as the shorter vector. A three-field note and a
|
||||
// four-field note whose fourth field is zero are indistinguishable.
|
||||
for _, pad := range [][][32]byte{
|
||||
append(append([][32]byte{}, values...), [32]byte{}),
|
||||
append(append([][32]byte{}, values...), [32]byte{}, [32]byte{}),
|
||||
} {
|
||||
padded, err := p.VectorCommit(pad, blinding)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c, padded,
|
||||
"current behaviour: trailing zero elements are invisible to the commitment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_VectorCommitLengthBound(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
blinding := pedScalar(5)
|
||||
limit := len(p.Generators)
|
||||
require.Positive(t, limit)
|
||||
|
||||
for _, n := range []int{0, 1, limit - 1, limit} {
|
||||
values := make([][32]byte, n)
|
||||
for i := range values {
|
||||
values[i] = pedScalar(uint64(i + 1))
|
||||
}
|
||||
c, err := p.VectorCommit(values, blinding)
|
||||
require.NoError(t, err, "%d values is within the generator supply", n)
|
||||
if n > 0 {
|
||||
require.NotEqual(t, [32]byte{}, c)
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range []int{limit + 1, limit * 2} {
|
||||
got, err := p.VectorCommit(make([][32]byte, n), blinding)
|
||||
require.Error(t, err, "%d values exceeds the generator supply", n)
|
||||
require.Equal(t, [32]byte{}, got)
|
||||
}
|
||||
|
||||
// The empty vector is just the blinding, so it must match r*H and not the
|
||||
// identity.
|
||||
empty, err := p.VectorCommit(nil, blinding)
|
||||
require.NoError(t, err)
|
||||
identity, err := p.Commit([32]byte{}, [32]byte{})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, identity, empty)
|
||||
}
|
||||
|
||||
func TestPed_NoteCommitmentBindsEveryField(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
amount := big.NewInt(1000)
|
||||
var asset [32]byte
|
||||
asset[31] = 1
|
||||
owner := common.HexToAddress("0x1234567890123456789012345678901234567890")
|
||||
blinding := pedScalar(77)
|
||||
|
||||
base, err := p.NoteCommitment(amount, asset, owner, blinding)
|
||||
require.NoError(t, err)
|
||||
|
||||
repeat, err := p.NoteCommitment(amount, asset, owner, blinding)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, base, repeat)
|
||||
|
||||
var otherAsset [32]byte
|
||||
otherAsset[31] = 2
|
||||
|
||||
variants := map[string][32]byte{}
|
||||
for name, mk := range map[string]func() ([32]byte, error){
|
||||
"amount": func() ([32]byte, error) { return p.NoteCommitment(big.NewInt(1001), asset, owner, blinding) },
|
||||
"asset": func() ([32]byte, error) { return p.NoteCommitment(amount, otherAsset, owner, blinding) },
|
||||
"owner": func() ([32]byte, error) {
|
||||
return p.NoteCommitment(amount, asset, common.HexToAddress("0xdead"), blinding)
|
||||
},
|
||||
"blinding": func() ([32]byte, error) { return p.NoteCommitment(amount, asset, owner, pedScalar(78)) },
|
||||
} {
|
||||
got, err := mk()
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, base, got, "changing the %s must change the note", name)
|
||||
variants[name] = got
|
||||
}
|
||||
require.Len(t, variants, 4)
|
||||
distinct := map[[32]byte]bool{base: true}
|
||||
for name, v := range variants {
|
||||
require.False(t, distinct[v], "%s variant collided with another note", name)
|
||||
distinct[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- balance ----------
|
||||
|
||||
func TestPed_VerifyBalanceAcceptsOnlyBalancedSets(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
var a, b fr.Element
|
||||
a.SetUint64(30)
|
||||
b.SetUint64(70)
|
||||
r1e, r1 := pedRandScalar(t)
|
||||
r2e, r2 := pedRandScalar(t)
|
||||
|
||||
ab, bb := a.Bytes(), b.Bytes()
|
||||
in1, err := p.Commit(ab, r1)
|
||||
require.NoError(t, err)
|
||||
in2, err := p.Commit(bb, r2)
|
||||
require.NoError(t, err)
|
||||
|
||||
var sumV, sumR fr.Element
|
||||
sumV.Add(&a, &b)
|
||||
sumR.Add(&r1e, &r2e)
|
||||
sumVb, sumRb := sumV.Bytes(), sumR.Bytes()
|
||||
out, err := p.Commit(sumVb, sumRb)
|
||||
require.NoError(t, err)
|
||||
|
||||
ok, err := p.VerifyBalance([][32]byte{in1, in2}, [][32]byte{out})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "inputs summing to the outputs must balance")
|
||||
|
||||
// Money must not appear: dropping an input, dropping an output, or paying
|
||||
// out more than came in all have to fail.
|
||||
for name, tc := range map[string][2][][32]byte{
|
||||
"missing input": {{in1}, {out}},
|
||||
"missing output": {{in1, in2}, nil},
|
||||
"extra output": {{in1, in2}, {out, in1}},
|
||||
"inflated value": {{in1}, {in2}},
|
||||
"swapped sides": {{out}, {in1}},
|
||||
} {
|
||||
ok, err := p.VerifyBalance(tc[0], tc[1])
|
||||
require.NoError(t, err, name)
|
||||
require.False(t, ok, "%s must not balance", name)
|
||||
}
|
||||
|
||||
// Order within each side is irrelevant: the check is over a sum.
|
||||
ok, err = p.VerifyBalance([][32]byte{in2, in1}, [][32]byte{out})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestPed_VerifyBalanceRefusesUnknownCommitments(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
known, err := p.Commit(pedScalar(1), pedScalar(1))
|
||||
require.NoError(t, err)
|
||||
unknown := pedUnknown(t, 0x05)
|
||||
|
||||
for name, tc := range map[string][2][][32]byte{
|
||||
"unknown input": {{unknown}, {known}},
|
||||
"unknown among known": {{known, unknown}, {known}},
|
||||
"unknown output": {{known}, {unknown}},
|
||||
"zero input": {{{}}, {known}},
|
||||
} {
|
||||
ok, err := p.VerifyBalance(tc[0], tc[1])
|
||||
require.Error(t, err, name)
|
||||
require.False(t, ok, "%s must not report a balance", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_VerifyBalanceOnEmptySides(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
// Nothing in, nothing out: both sides are the identity, so this balances.
|
||||
ok, err := p.VerifyBalance(nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
// But an empty side against a real commitment must not.
|
||||
c, err := p.Commit(pedScalar(1), pedScalar(1))
|
||||
require.NoError(t, err)
|
||||
ok, err = p.VerifyBalance(nil, [][32]byte{c})
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
ok, err = p.VerifyBalance([][32]byte{c}, nil)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
|
||||
// A zero-valued note under a zero blinding is the identity, so it balances
|
||||
// against nothing at all. Documented here because it is load-bearing: the
|
||||
// check is over points, not over declared amounts.
|
||||
zero := [32]byte{}
|
||||
identity, err := p.Commit(zero, zero)
|
||||
require.NoError(t, err)
|
||||
ok, err = p.VerifyBalance(nil, [][32]byte{identity})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
// ---------- gas ----------
|
||||
|
||||
func TestPed_RequiredGasPricesWork(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
commit := p.RequiredGas("commit", 1)
|
||||
verify := p.RequiredGas("verify", 1)
|
||||
add := p.RequiredGas("add", 1)
|
||||
sub := p.RequiredGas("sub", 1)
|
||||
unknown := p.RequiredGas("no-such-operation", 1)
|
||||
|
||||
for name, g := range map[string]uint64{
|
||||
"commit": commit, "verify": verify, "add": add, "sub": sub, "unknown": unknown,
|
||||
} {
|
||||
require.NotZero(t, g, "%s must not be free", name)
|
||||
}
|
||||
|
||||
require.Equal(t, add, sub, "a point subtraction is a negation plus an addition")
|
||||
require.Less(t, add, commit, "a point addition is cheaper than two scalar multiplications")
|
||||
require.Less(t, commit, verify, "verifying is committing plus a comparison")
|
||||
require.GreaterOrEqual(t, unknown, verify, "an unrecognised operation must not undercharge")
|
||||
}
|
||||
|
||||
func TestPed_RequiredGasTracksVectorLength(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
|
||||
prev := p.RequiredGas("vector", 0)
|
||||
require.NotZero(t, prev)
|
||||
var step uint64
|
||||
for n := 1; n <= 32; n++ {
|
||||
got := p.RequiredGas("vector", n)
|
||||
require.Greater(t, got, prev, "vector gas must grow with the number of elements")
|
||||
require.GreaterOrEqual(t, got, p.RequiredGas("commit", n),
|
||||
"a vector commitment does at least the work of a single commitment")
|
||||
if n > 1 {
|
||||
require.Equal(t, step, got-prev, "pricing must be linear in the element count")
|
||||
}
|
||||
step = got - prev
|
||||
prev = got
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_RequiredGasIgnoresLengthForFixedSizeOps(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
for _, op := range []string{"commit", "verify", "add", "sub", "unknown"} {
|
||||
base := p.RequiredGas(op, 0)
|
||||
for _, n := range []int{1, 10, 1000} {
|
||||
require.Equal(t, base, p.RequiredGas(op, n),
|
||||
"%s consumes a fixed-size input, so its price must not vary", op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- generators and encoding ----------
|
||||
|
||||
func TestPed_HashToG1YieldsUsableGenerators(t *testing.T) {
|
||||
a := hashToG1("Lux_Pedersen_H_Generator")
|
||||
require.True(t, a.IsOnCurve())
|
||||
require.False(t, a.IsInfinity())
|
||||
|
||||
again := hashToG1("Lux_Pedersen_H_Generator")
|
||||
require.True(t, a.Equal(&again), "hash-to-curve must be deterministic")
|
||||
|
||||
b := hashToG1("Lux_Pedersen_H_Generator ")
|
||||
require.True(t, b.IsOnCurve())
|
||||
require.False(t, a.Equal(&b), "a one-character change must give a different generator")
|
||||
|
||||
empty := hashToG1("")
|
||||
require.True(t, empty.IsOnCurve())
|
||||
require.False(t, empty.IsInfinity())
|
||||
|
||||
// Sweep a wide spread of seeds: try-and-increment must land on the curve
|
||||
// every time, never on the identity, and never twice on the same point.
|
||||
seen := map[[32]byte]string{}
|
||||
for i := range 200 {
|
||||
seed := fmt.Sprintf("Lux_Pedersen_sweep_%d", i)
|
||||
pt := hashToG1(seed)
|
||||
require.True(t, pt.IsOnCurve(), "seed %q left the curve", seed)
|
||||
require.False(t, pt.IsInfinity(), "seed %q produced the identity", seed)
|
||||
|
||||
key := compressG1(&pt)
|
||||
prior, dup := seen[key]
|
||||
require.False(t, dup, "seeds %q and %q share a generator", seed, prior)
|
||||
seen[key] = seed
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_GeneratorsAreIndependent(t *testing.T) {
|
||||
p := NewPedersenCommitter()
|
||||
_, _, g1, _ := bn254.Generators()
|
||||
|
||||
require.True(t, p.G.Equal(&g1), "G is the curve's base generator")
|
||||
require.True(t, p.H.IsOnCurve())
|
||||
require.False(t, p.H.IsInfinity())
|
||||
require.False(t, p.G.Equal(&p.H), "the blinding generator must not be G")
|
||||
|
||||
seen := map[[32]byte]string{
|
||||
compressG1(&p.G): "G",
|
||||
compressG1(&p.H): "H",
|
||||
}
|
||||
require.Len(t, p.Generators, 32)
|
||||
for i := range p.Generators {
|
||||
gen := p.Generators[i]
|
||||
require.True(t, gen.IsOnCurve(), "generator %d off curve", i)
|
||||
require.False(t, gen.IsInfinity(), "generator %d is the identity", i)
|
||||
key := compressG1(&gen)
|
||||
prior, dup := seen[key]
|
||||
require.False(t, dup, "generator %d duplicates %s", i, prior)
|
||||
seen[key] = "generator"
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_CompressAndDecompressRoundTrip(t *testing.T) {
|
||||
points := map[string]bn254.G1Affine{
|
||||
"infinity": {},
|
||||
"base": pedPoint(1),
|
||||
"double": pedPoint(2),
|
||||
"triple": pedPoint(3),
|
||||
"large": pedPoint(987654321),
|
||||
}
|
||||
|
||||
digests := map[[32]byte]string{}
|
||||
for name, pt := range points {
|
||||
key := compressG1WithCache(&pt)
|
||||
|
||||
prior, dup := digests[key]
|
||||
require.False(t, dup, "%s and %s compressed to the same 32 bytes", name, prior)
|
||||
digests[key] = name
|
||||
|
||||
got, err := decompressG1(key)
|
||||
require.NoError(t, err, "%s must round trip", name)
|
||||
require.True(t, got.Equal(&pt), "%s did not survive the round trip", name)
|
||||
|
||||
require.Equal(t, compressG1(&pt), key,
|
||||
"compressG1 and compressG1WithCache must agree on the digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_DecompressRefusesWhatItCannotResolve(t *testing.T) {
|
||||
// compressG1 computes the digest but does not register the point, so its
|
||||
// output alone cannot be resolved. Only compressG1WithCache produces a
|
||||
// usable handle.
|
||||
pt := pedPoint(0x0DEADBEE)
|
||||
digest := compressG1(&pt)
|
||||
got, err := decompressG1(digest)
|
||||
require.ErrorIs(t, err, ErrPointNotOnCurve)
|
||||
require.True(t, got.IsInfinity(), "a failed lookup must not hand back a usable point")
|
||||
|
||||
// Registering the same point makes the same digest resolve.
|
||||
require.Equal(t, digest, compressG1WithCache(&pt))
|
||||
got, err = decompressG1(digest)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.Equal(&pt))
|
||||
|
||||
// Near misses stay refused.
|
||||
for i := range 8 {
|
||||
near := digest
|
||||
near[i*4] ^= 0x80
|
||||
_, err := decompressG1(near)
|
||||
require.ErrorIs(t, err, ErrPointNotOnCurve, "flipped bit at byte %d resolved", i*4)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- precompile entry points ----------
|
||||
|
||||
func TestPed_PrecompileRejectsMalformedInput(t *testing.T) {
|
||||
entries := map[string]struct {
|
||||
fn func([]byte) ([]byte, error)
|
||||
want int
|
||||
}{
|
||||
"PedersenCommit": {PedersenCommit, 64},
|
||||
"PedersenVerify": {PedersenVerify, 96},
|
||||
"PedersenAdd": {PedersenAdd, 64},
|
||||
}
|
||||
lengths := []int{0, 1, 31, 32, 63, 64, 65, 95, 96, 97, 128, 1024}
|
||||
|
||||
for name, e := range entries {
|
||||
for _, n := range lengths {
|
||||
if n == e.want {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("%s/%dbytes", name, n), func(t *testing.T) {
|
||||
out, err := e.fn(make([]byte, n))
|
||||
require.ErrorIs(t, err, ErrInvalidCommitmentInput, "%s accepted %d bytes", name, n)
|
||||
require.Nil(t, out)
|
||||
})
|
||||
}
|
||||
out, err := e.fn(nil)
|
||||
require.ErrorIs(t, err, ErrInvalidCommitmentInput, "%s accepted nil", name)
|
||||
require.Nil(t, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPed_PrecompileCommitVerifyAddAgree(t *testing.T) {
|
||||
value, blinding := pedScalar(12345), pedScalar(6789)
|
||||
|
||||
input := make([]byte, 64)
|
||||
copy(input[:32], value[:])
|
||||
copy(input[32:], blinding[:])
|
||||
|
||||
c, err := PedersenCommit(input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, c, 32)
|
||||
|
||||
// The entry point must agree with the committer it wraps.
|
||||
direct, err := globalPedersen.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, direct[:], c)
|
||||
|
||||
verifyInput := make([]byte, 96)
|
||||
copy(verifyInput[:32], c)
|
||||
copy(verifyInput[32:64], value[:])
|
||||
copy(verifyInput[64:], blinding[:])
|
||||
|
||||
out, err := PedersenVerify(verifyInput)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 32)
|
||||
require.Equal(t, byte(1), out[31], "a true opening must answer 1")
|
||||
require.Equal(t, make([]byte, 31), out[:31], "the answer is left padded")
|
||||
|
||||
// Corrupting the value flips the answer to zero without erroring.
|
||||
bad := make([]byte, 96)
|
||||
copy(bad, verifyInput)
|
||||
bad[63] ^= 0x01
|
||||
out, err = PedersenVerify(bad)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, make([]byte, 32), out, "a false opening must answer 0")
|
||||
|
||||
// Corrupting the blinding likewise.
|
||||
bad = make([]byte, 96)
|
||||
copy(bad, verifyInput)
|
||||
bad[95] ^= 0x01
|
||||
out, err = PedersenVerify(bad)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, make([]byte, 32), out)
|
||||
|
||||
// Addition through the entry point matches the committer.
|
||||
v2, r2 := pedScalar(1), pedScalar(2)
|
||||
second := make([]byte, 64)
|
||||
copy(second[:32], v2[:])
|
||||
copy(second[32:], r2[:])
|
||||
c2, err := PedersenCommit(second)
|
||||
require.NoError(t, err)
|
||||
|
||||
addInput := make([]byte, 64)
|
||||
copy(addInput[:32], c)
|
||||
copy(addInput[32:], c2)
|
||||
sum, err := PedersenAdd(addInput)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sum, 32)
|
||||
|
||||
var k1, k2 [32]byte
|
||||
copy(k1[:], c)
|
||||
copy(k2[:], c2)
|
||||
expected, err := globalPedersen.Add(k1, k2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected[:], sum)
|
||||
}
|
||||
|
||||
func TestPed_PrecompileSurfacesUnresolvableCommitments(t *testing.T) {
|
||||
unknown := pedUnknown(t, 0x06)
|
||||
value, blinding := pedScalar(1), pedScalar(2)
|
||||
|
||||
verifyInput := make([]byte, 96)
|
||||
copy(verifyInput[:32], unknown[:])
|
||||
copy(verifyInput[32:64], value[:])
|
||||
copy(verifyInput[64:], blinding[:])
|
||||
|
||||
// It fails closed: an error, never a 1.
|
||||
out, err := PedersenVerify(verifyInput)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, out)
|
||||
|
||||
known, err := globalPedersen.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
addInput := make([]byte, 64)
|
||||
copy(addInput[:32], unknown[:])
|
||||
copy(addInput[32:], known[:])
|
||||
out, err = PedersenAdd(addInput)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, out)
|
||||
|
||||
copy(addInput[:32], known[:])
|
||||
copy(addInput[32:], unknown[:])
|
||||
out, err = PedersenAdd(addInput)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, out)
|
||||
}
|
||||
|
||||
func TestPed_GetPedersenStatsIsMonotonic(t *testing.T) {
|
||||
c0, v0 := GetPedersenStats()
|
||||
|
||||
_, _, err := globalPedersen.CommitWithOpening(big.NewInt(31337))
|
||||
require.NoError(t, err)
|
||||
c1, v1 := GetPedersenStats()
|
||||
require.Equal(t, c0+1, c1, "an opening must be counted")
|
||||
require.Equal(t, v0, v1)
|
||||
|
||||
value, blinding := pedScalar(555), pedScalar(666)
|
||||
commitment, err := globalPedersen.Commit(value, blinding)
|
||||
require.NoError(t, err)
|
||||
verifyInput := make([]byte, 96)
|
||||
copy(verifyInput[:32], commitment[:])
|
||||
copy(verifyInput[32:64], value[:])
|
||||
copy(verifyInput[64:], blinding[:])
|
||||
_, err = PedersenVerify(verifyInput)
|
||||
require.NoError(t, err)
|
||||
|
||||
c2, v2 := GetPedersenStats()
|
||||
require.Equal(t, c1, c2, "Commit does not count as an opening")
|
||||
require.Equal(t, v1+1, v2, "a verification must be counted")
|
||||
}
|
||||
+24
-26
@@ -40,8 +40,17 @@ var (
|
||||
// Poseidon2Hasher provides Poseidon2 hash operations for ZK circuits
|
||||
// Poseidon2 is optimized for ZK proofs and is PQ-resistant (hash-based security)
|
||||
type Poseidon2Hasher struct {
|
||||
// Cache for frequently used hashes
|
||||
cache map[[32]byte][32]byte
|
||||
// Cache for frequently used hashes, keyed by the WHOLE input.
|
||||
//
|
||||
// The key was previously a 32-byte digest-shaped value derived from
|
||||
// only the first 32 bytes of the input plus its length. Any two inputs
|
||||
// sharing a first field element then shared a cache entry, so the
|
||||
// second one returned the first one's hash: HashPair(a,b1) and
|
||||
// HashPair(a,b2) were equal, a Merkle node ignored its right child,
|
||||
// and a commitment ignored its blinding factor. It also made the
|
||||
// result depend on this process's cache history rather than on the
|
||||
// input, so two nodes could disagree about the same hash.
|
||||
cache map[string][32]byte
|
||||
cacheMu sync.RWMutex
|
||||
cacheMax int
|
||||
|
||||
@@ -54,7 +63,7 @@ type Poseidon2Hasher struct {
|
||||
// NewPoseidon2Hasher creates a new Poseidon2 hasher
|
||||
func NewPoseidon2Hasher() *Poseidon2Hasher {
|
||||
return &Poseidon2Hasher{
|
||||
cache: make(map[[32]byte][32]byte),
|
||||
cache: make(map[string][32]byte),
|
||||
cacheMax: 10000,
|
||||
}
|
||||
}
|
||||
@@ -73,16 +82,18 @@ func (p *Poseidon2Hasher) Hash(input []byte) ([32]byte, error) {
|
||||
return [32]byte{}, ErrTooManyInputs
|
||||
}
|
||||
|
||||
// Check cache
|
||||
cacheKey := computeCacheKey(input)
|
||||
// Check cache. The key is the input itself: a hash must be a function
|
||||
// of everything it is given.
|
||||
cacheKey := string(input)
|
||||
p.cacheMu.RLock()
|
||||
if cached, ok := p.cache[cacheKey]; ok {
|
||||
p.cacheMu.RUnlock()
|
||||
cached, hit := p.cache[cacheKey]
|
||||
p.cacheMu.RUnlock()
|
||||
if hit {
|
||||
p.cacheMu.Lock()
|
||||
p.CacheHits++
|
||||
p.cacheMu.Unlock()
|
||||
return cached, nil
|
||||
}
|
||||
p.cacheMu.RUnlock()
|
||||
p.CacheMisses++
|
||||
|
||||
var result [32]byte
|
||||
|
||||
@@ -115,14 +126,16 @@ func (p *Poseidon2Hasher) Hash(input []byte) ([32]byte, error) {
|
||||
copy(result[:], hashBytes)
|
||||
}
|
||||
|
||||
// Cache result
|
||||
// Cache result. The counters move under the same lock as the map they
|
||||
// describe; incrementing them outside it was a data race.
|
||||
p.cacheMu.Lock()
|
||||
if len(p.cache) < p.cacheMax {
|
||||
p.cache[cacheKey] = result
|
||||
}
|
||||
p.CacheMisses++
|
||||
p.TotalHashes++
|
||||
p.cacheMu.Unlock()
|
||||
|
||||
p.TotalHashes++
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -326,21 +339,6 @@ func (p *Poseidon2Hasher) RequiredGas(inputLen int) uint64 {
|
||||
return 500 + numElements*100
|
||||
}
|
||||
|
||||
// computeCacheKey creates a cache key from input
|
||||
func computeCacheKey(input []byte) [32]byte {
|
||||
if len(input) == 32 {
|
||||
var key [32]byte
|
||||
copy(key[:], input)
|
||||
return key
|
||||
}
|
||||
// For longer inputs, use first 32 bytes XOR'd with length
|
||||
var key [32]byte
|
||||
copy(key[:], input[:32])
|
||||
key[0] ^= byte(len(input) >> 8)
|
||||
key[1] ^= byte(len(input))
|
||||
return key
|
||||
}
|
||||
|
||||
// Global instance
|
||||
var globalPoseidon2 = NewPoseidon2Hasher()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A rollup batch advances the chain's state root on the strength of one
|
||||
// proof, so every guard around it is load-bearing: who may propose, how
|
||||
// much a batch may contain, and — above all — that the proof is checked
|
||||
// against the state the batch claims to extend. A rollup verifier that
|
||||
// accepts a batch whose PrevStateRoot does not match the chain lets an
|
||||
// operator rewrite history.
|
||||
|
||||
var (
|
||||
rolOwner = common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
rolSequencer = common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
rolStranger = common.HexToAddress("0x3333333333333333333333333333333333333333")
|
||||
)
|
||||
|
||||
// rolBatchInputs mirrors verifyGroth16Batch's public-input vector:
|
||||
// [prevRoot, newRoot, txCount, l1BatchNum].
|
||||
func rolBatchInputs(prev, next [32]byte, txs, l1 uint64) []*big.Int {
|
||||
return []*big.Int{
|
||||
new(big.Int).SetBytes(prev[:]),
|
||||
new(big.Int).SetBytes(next[:]),
|
||||
new(big.Int).SetInt64(int64(txs)),
|
||||
new(big.Int).SetInt64(int64(l1)),
|
||||
}
|
||||
}
|
||||
|
||||
// rolSetup registers a rollup whose verifying key admits a genuine proof
|
||||
// for the batch described, and returns the rollup id and that batch.
|
||||
func rolSetup(t *testing.T, prev, next [32]byte, txs, l1 uint64) (*ZKVerifier, [32]byte, *RollupBatch) {
|
||||
t.Helper()
|
||||
zv := NewZKVerifier()
|
||||
|
||||
in := grBuild(t, rolBatchInputs(prev, next, txs, l1))
|
||||
keyID := grRegister(t, zv, in)
|
||||
|
||||
rollupID, err := zv.RegisterRollup(rolOwner, keyID, ProofSystemGroth16, 1000, 60, rolSequencer)
|
||||
require.NoError(t, err)
|
||||
|
||||
return zv, rollupID, &RollupBatch{
|
||||
BatchID: [32]byte{0xB1},
|
||||
RollupID: rollupID,
|
||||
PrevStateRoot: prev,
|
||||
NewStateRoot: next,
|
||||
Transactions: txs,
|
||||
L1BatchNum: l1,
|
||||
Proposer: rolSequencer,
|
||||
Proof: &Proof{
|
||||
ProofSystem: ProofSystemGroth16,
|
||||
A: in.a,
|
||||
B: in.b,
|
||||
C: in.c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRol_AcceptsAValidBatchAndAdvancesState is the control: a batch with
|
||||
// a genuine proof must be accepted, and must move the recorded state on.
|
||||
// Without it every refusal below would pass against a rollup that never
|
||||
// accepts anything.
|
||||
func TestRol_AcceptsAValidBatchAndAdvancesState(t *testing.T) {
|
||||
next := [32]byte{0xAB, 0xCD}
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 42, 7)
|
||||
|
||||
require.NoError(t, zv.VerifyRollupBatch(id, batch),
|
||||
"a batch with a satisfying proof must be accepted")
|
||||
|
||||
state, err := zv.GetRollupState(id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, next, state.LastStateRoot, "the state root must advance")
|
||||
require.Equal(t, batch.BatchID, state.LastBatchID)
|
||||
require.Equal(t, uint64(7), state.LastL1Block)
|
||||
require.Equal(t, uint64(1), state.TotalBatches)
|
||||
require.Equal(t, uint64(42), state.TotalTxs)
|
||||
|
||||
// Replaying the same batch must now fail: its PrevStateRoot no longer
|
||||
// matches the chain. This is what stops a batch being applied twice.
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrInvalidStateRoot,
|
||||
"a batch was replayable after it had been applied")
|
||||
}
|
||||
|
||||
// TestRol_RefusesOnEveryGuard walks each refusal in turn, from a batch
|
||||
// that is otherwise valid, so each assertion isolates one guard.
|
||||
func TestRol_RefusesOnEveryGuard(t *testing.T) {
|
||||
next := [32]byte{0x11, 0x22}
|
||||
|
||||
t.Run("unknown rollup", func(t *testing.T) {
|
||||
zv, _, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch([32]byte{0xFF}, batch), ErrRollupNotFound)
|
||||
})
|
||||
|
||||
t.Run("disabled rollup", func(t *testing.T) {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
zv.Rollups[id].Enabled = false
|
||||
require.Error(t, zv.VerifyRollupBatch(id, batch))
|
||||
})
|
||||
|
||||
t.Run("unauthorized proposer", func(t *testing.T) {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
batch.Proposer = rolStranger
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrUnauthorizedProposer)
|
||||
})
|
||||
|
||||
t.Run("owner may also propose", func(t *testing.T) {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
batch.Proposer = rolOwner
|
||||
require.NoError(t, zv.VerifyRollupBatch(id, batch))
|
||||
})
|
||||
|
||||
t.Run("batch too large", func(t *testing.T) {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
zv.Rollups[id].MaxTxPerBatch = 9
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrBatchTooLarge)
|
||||
|
||||
// Exactly at the limit is allowed — the bound is inclusive.
|
||||
zv.Rollups[id].MaxTxPerBatch = 10
|
||||
require.NoError(t, zv.VerifyRollupBatch(id, batch))
|
||||
})
|
||||
|
||||
t.Run("wrong previous state root", func(t *testing.T) {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
batch.PrevStateRoot = [32]byte{0x99}
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrInvalidStateRoot,
|
||||
"a batch extending a state the chain is not in was accepted")
|
||||
})
|
||||
|
||||
t.Run("missing proof", func(t *testing.T) {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
batch.Proof = nil
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrInvalidProof)
|
||||
})
|
||||
|
||||
t.Run("tampered proof", func(t *testing.T) {
|
||||
for _, mut := range []func(*Proof){
|
||||
func(p *Proof) { p.A[0] ^= 0x01 },
|
||||
func(p *Proof) { p.B[0] ^= 0x01 },
|
||||
func(p *Proof) { p.C[0] ^= 0x01 },
|
||||
} {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
mut(batch.Proof)
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrInvalidProof)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proof bound to the batch contents", func(t *testing.T) {
|
||||
// The proof commits to [prevRoot, newRoot, txCount, l1BatchNum].
|
||||
// Changing any of them while keeping the proof must fail, or an
|
||||
// operator could substitute a different state transition.
|
||||
for name, mut := range map[string]func(*RollupBatch){
|
||||
"new state root": func(b *RollupBatch) { b.NewStateRoot = [32]byte{0xEE} },
|
||||
"tx count": func(b *RollupBatch) { b.Transactions = 11 },
|
||||
"l1 batch num": func(b *RollupBatch) { b.L1BatchNum = 2 },
|
||||
} {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
mut(batch)
|
||||
require.ErrorIsf(t, zv.VerifyRollupBatch(id, batch), ErrInvalidProof,
|
||||
"the proof did not bind %s", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported proof system", func(t *testing.T) {
|
||||
zv, id, batch := rolSetup(t, [32]byte{}, next, 10, 1)
|
||||
zv.Rollups[id].ProofSystem = ProofSystemStark
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrProofSystemMismatch)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRol_PlonkBatchPath drives the PLONK branch of the dispatch.
|
||||
func TestRol_PlonkBatchPath(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
|
||||
ic := make([][]byte, 9)
|
||||
for i := range 8 {
|
||||
ic[i] = grG1(big.NewInt(int64(i + 61)))
|
||||
}
|
||||
ic[8] = grG2(big.NewInt(71))
|
||||
|
||||
keyID, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemPlonk, CircuitRollupBatch,
|
||||
grG1(big.NewInt(73)), grG2(big.NewInt(79)),
|
||||
grG2(big.NewInt(83)), grG2(big.NewInt(89)), ic)
|
||||
require.NoError(t, err)
|
||||
|
||||
id, err := zv.RegisterRollup(rolOwner, keyID, ProofSystemPlonk, 100, 60, rolSequencer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verifyPlonkBatch concatenates A‖B‖C, so together they must form the
|
||||
// 768-byte PLONK layout: nine G1 points then six scalars.
|
||||
proof := make([]byte, 0, 768)
|
||||
for i := range 9 {
|
||||
proof = append(proof, grG1(big.NewInt(int64(200+i)))...)
|
||||
}
|
||||
for i := range 6 {
|
||||
proof = append(proof, padScalar32(big.NewInt(int64(400+i)))...)
|
||||
}
|
||||
require.Len(t, proof, 768)
|
||||
|
||||
batch := &RollupBatch{
|
||||
RollupID: id,
|
||||
NewStateRoot: [32]byte{0x77},
|
||||
Transactions: 5,
|
||||
Proposer: rolSequencer,
|
||||
Proof: &Proof{
|
||||
ProofSystem: ProofSystemPlonk,
|
||||
A: proof[0:256:256],
|
||||
B: proof[256:512:512],
|
||||
C: proof[512:768:768],
|
||||
},
|
||||
}
|
||||
|
||||
// Arbitrary curve points are not a PLONK proof: refuse.
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrInvalidProof)
|
||||
|
||||
// A missing proof is refused before any arithmetic.
|
||||
batch.Proof = nil
|
||||
require.ErrorIs(t, zv.VerifyRollupBatch(id, batch), ErrInvalidProof)
|
||||
}
|
||||
|
||||
// TestRol_RegisterGuards covers registration itself.
|
||||
func TestRol_RegisterGuards(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
|
||||
_, err := zv.RegisterRollup(rolOwner, [32]byte{0xDE}, ProofSystemGroth16, 10, 60, rolSequencer)
|
||||
require.ErrorIs(t, err, ErrInvalidVerifyingKey,
|
||||
"a rollup must not register against a key that does not exist")
|
||||
|
||||
in := grBuild(t, []*big.Int{big.NewInt(1)})
|
||||
keyID := grRegister(t, zv, in)
|
||||
|
||||
id, err := zv.RegisterRollup(rolOwner, keyID, ProofSystemGroth16, 10, 60, rolSequencer)
|
||||
require.NoError(t, err)
|
||||
|
||||
state, err := zv.GetRollupState(id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, [32]byte{}, state.LastStateRoot, "a new rollup starts at genesis")
|
||||
require.Zero(t, state.TotalBatches)
|
||||
|
||||
_, err = zv.GetRollupState([32]byte{0xAA})
|
||||
require.ErrorIs(t, err, ErrRollupNotFound)
|
||||
|
||||
// The declared proof system is NOT checked against the key's own. A
|
||||
// Groth16 key can be registered as PLONK, and the batch dispatch will
|
||||
// then hand it to the PLONK verifier. It refuses — but on the proof,
|
||||
// not on the mismatch, so the confusion is silent.
|
||||
mixed, err := zv.RegisterRollup(rolOwner, keyID, ProofSystemPlonk, 10, 60, rolSequencer)
|
||||
require.NoError(t, err,
|
||||
"registration does not cross-check the declared system against the key")
|
||||
require.Equal(t, ProofSystemGroth16, zv.Rollups[mixed].VerifyingKey.ProofSystem)
|
||||
require.Equal(t, ProofSystemPlonk, zv.Rollups[mixed].ProofSystem)
|
||||
}
|
||||
|
||||
// TestRol_PoolLifecycle covers confidential-pool creation and commitment
|
||||
// insertion, including the refusals.
|
||||
func TestRol_PoolLifecycle(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
token := common.HexToAddress("0x4444444444444444444444444444444444444444")
|
||||
|
||||
poolID, err := zv.CreateConfidentialPool(rolOwner, token, 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := &Commitment{
|
||||
CommitType: CommitPedersen,
|
||||
Value: []byte("a-commitment"),
|
||||
Amount: big.NewInt(100),
|
||||
}
|
||||
cid, err := zv.AddCommitment(poolID, c)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, [32]byte{}, cid)
|
||||
require.Equal(t, big.NewInt(100), zv.Pools[poolID].TotalDeposits)
|
||||
|
||||
// A second deposit accumulates.
|
||||
_, err = zv.AddCommitment(poolID, &Commitment{
|
||||
CommitType: CommitPedersen,
|
||||
Value: []byte("another"),
|
||||
Amount: big.NewInt(50),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, big.NewInt(150), zv.Pools[poolID].TotalDeposits)
|
||||
|
||||
// Unknown and disabled pools are refused.
|
||||
_, err = zv.AddCommitment([32]byte{0xFF}, c)
|
||||
require.ErrorIs(t, err, ErrPoolNotFound)
|
||||
|
||||
zv.Pools[poolID].Enabled = false
|
||||
_, err = zv.AddCommitment(poolID, c)
|
||||
require.ErrorIs(t, err, ErrPoolDisabled)
|
||||
|
||||
// Inclusion against an unknown pool is an error, not a false verdict.
|
||||
_, err = zv.VerifyCommitmentInclusion([32]byte{0xFF}, cid, nil, 0)
|
||||
require.ErrorIs(t, err, ErrPoolNotFound)
|
||||
|
||||
// An empty proof only verifies if the leaf hash IS the root; with the
|
||||
// genesis root it must not.
|
||||
ok, err := zv.VerifyCommitmentInclusion(poolID, cid, nil, 0)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok, "a commitment must not be provable against an empty tree")
|
||||
}
|
||||
|
||||
// TestRol_StatsTracking pins the counters, which are the only signal an
|
||||
// operator has that verification is happening at all.
|
||||
func TestRol_StatsTracking(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
in := grBuild(t, []*big.Int{big.NewInt(3)})
|
||||
id := grRegister(t, zv, in)
|
||||
|
||||
require.Zero(t, zv.TotalVerifications)
|
||||
|
||||
res, err := zv.VerifyGroth16(id, in.a, in.b, in.c, in.public)
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Valid)
|
||||
require.Equal(t, uint64(1), zv.TotalVerifications)
|
||||
require.Equal(t, uint64(1), zv.TotalProofsValid)
|
||||
require.Zero(t, zv.TotalProofsFailed)
|
||||
|
||||
_, err = zv.VerifyGroth16(id, in.a, in.b, in.c, []*big.Int{big.NewInt(4)})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(2), zv.TotalVerifications)
|
||||
require.Equal(t, uint64(1), zv.TotalProofsValid)
|
||||
require.Equal(t, uint64(1), zv.TotalProofsFailed,
|
||||
"a rejected proof must be counted as failed, not valid")
|
||||
|
||||
// A refusal before verification must not be counted at all.
|
||||
_, err = zv.VerifyGroth16([32]byte{0xFF}, in.a, in.b, in.c, in.public)
|
||||
require.ErrorIs(t, err, ErrInvalidVerifyingKey)
|
||||
require.Equal(t, uint64(2), zv.TotalVerifications)
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/kzg4844"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Soundness is the only property of a verifier that matters: it must not
|
||||
// accept a proof of a false statement. A round-trip test ("a proof I built
|
||||
// verifies") passes just as happily against a verifier that returns true
|
||||
// unconditionally — which is exactly what two of the functions here used
|
||||
// to do. Every test below is therefore a REFUSAL test.
|
||||
|
||||
// sndZeroG1 / sndZeroG2 are bn256's uncompressed affine encodings of the
|
||||
// point at infinity — the identity of each group.
|
||||
func sndZeroG1() []byte { return make([]byte, 64) }
|
||||
func sndZeroG2() []byte { return make([]byte, 128) }
|
||||
|
||||
// sndStatements are unrelated public-input vectors. A verifier that
|
||||
// accepts a single proof under all of them is not verifying anything.
|
||||
var sndStatements = [][]*big.Int{
|
||||
nil,
|
||||
{big.NewInt(0)},
|
||||
{big.NewInt(1)},
|
||||
{big.NewInt(7777777)},
|
||||
{big.NewInt(0xdead), big.NewInt(0xbeef)},
|
||||
}
|
||||
|
||||
// TestSound_DegenerateKeyRefused pins the fix for a live universal forge.
|
||||
//
|
||||
// A verifying key whose group elements are the identity satisfies the
|
||||
// pairing equation unconditionally: e(O,Q) = 1, so every factor of the
|
||||
// product collapses and the check succeeds for ANY proof and ANY
|
||||
// statement. RegisterVerifyingKey accepts arbitrary bytes, so anyone who
|
||||
// could register a key could forge every proof checked against it. Both
|
||||
// Groth16 and PLONK were affected.
|
||||
func TestSound_DegenerateKeyRefused(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
g1, g2 := sndZeroG1(), sndZeroG2()
|
||||
|
||||
t.Run("groth16", func(t *testing.T) {
|
||||
id, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemGroth16, CircuitTransfer, g1, g2, g2, g2, [][]byte{g1, g1})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, pub := range sndStatements {
|
||||
if len(pub) != 1 {
|
||||
continue // VerifyGroth16 requires len(pub) == len(IC)-1
|
||||
}
|
||||
res, err := zv.VerifyGroth16(id, g1, g2, g1, pub)
|
||||
require.NoError(t, err)
|
||||
require.Falsef(t, res.Valid,
|
||||
"identity verifying key accepted statement %v", pub)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plonk", func(t *testing.T) {
|
||||
ic := [][]byte{g1, g1, g1, g1, g1, g1, g1, g1, g2}
|
||||
id, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemPlonk, CircuitTransfer, g1, g2, g2, g2, ic)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, pub := range sndStatements {
|
||||
res, err := zv.VerifyPlonk(id, make([]byte, 768), pub)
|
||||
require.NoError(t, err)
|
||||
require.Falsef(t, res.Valid,
|
||||
"identity verifying key accepted statement %v", pub)
|
||||
}
|
||||
})
|
||||
|
||||
// The predicate itself: absent and identity elements are degenerate,
|
||||
// anything with a set bit is not.
|
||||
require.True(t, degenerate(nil))
|
||||
require.True(t, degenerate([]byte{}))
|
||||
require.True(t, degenerate(sndZeroG1()))
|
||||
require.True(t, degenerate([]byte{1}, sndZeroG1()), "one bad element taints the set")
|
||||
require.False(t, degenerate([]byte{0, 0, 1}))
|
||||
}
|
||||
|
||||
// TestSound_Halo2KeyedPathRefuses pins the fix for the most direct forge
|
||||
// in the package: verifyHalo2IPA folded the IPA rounds, discarded the
|
||||
// result, and returned true. Op 0x04 therefore reported VALID for every
|
||||
// proof and every statement as soon as any Halo2 key was registered — no
|
||||
// degenerate key required.
|
||||
func TestSound_Halo2KeyedPathRefuses(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
id, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemHalo2, CircuitTransfer,
|
||||
sndZeroG1(), sndZeroG2(), sndZeroG2(), sndZeroG2(),
|
||||
[][]byte{sndZeroG1(), sndZeroG1(), sndZeroG1()})
|
||||
require.NoError(t, err)
|
||||
|
||||
p := &zkVerifyPrecompile{verifier: zv}
|
||||
|
||||
for _, shape := range [][4]int{{1, 2, 1, 8}, {2, 3, 2, 8}, {1, 1, 1, 4}} {
|
||||
data := buildTestHalo2Proof(shape[0], shape[1], shape[2], uint32(shape[3]))
|
||||
copy(data[0:32], id[:])
|
||||
|
||||
ok, err := p.verifyHalo2(data)
|
||||
require.Falsef(t, ok, "halo2 accepted an unchecked proof (shape %v)", shape)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Same through the precompile entry: never a VALID word.
|
||||
data := buildTestHalo2Proof(1, 2, 1, 8)
|
||||
copy(data[0:32], id[:])
|
||||
ret, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress,
|
||||
append([]byte{OpVerifyHalo2}, data...), 10_000_000, true)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, ret)
|
||||
}
|
||||
|
||||
// TestSound_NoKeyPathNeverAccepts covers the nil-verifying-key class
|
||||
// across every system. With no verifier attached, none may return
|
||||
// (true, nil) for any input — that shape is precisely what got fflonk
|
||||
// disabled.
|
||||
func TestSound_NoKeyPathNeverAccepts(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{} // no verifier
|
||||
|
||||
sizes := []int{0, 1, 35, 36, 37, 100, 292, 804, 1024}
|
||||
fills := []byte{0x00, 0x01, 0xff, 0xa5}
|
||||
|
||||
check := func(name string, fn func([]byte) (bool, error)) {
|
||||
for _, n := range sizes {
|
||||
for _, fill := range fills {
|
||||
in := bytes.Repeat([]byte{fill}, n)
|
||||
ok, err := fn(in)
|
||||
if ok {
|
||||
require.Failf(t, "no-key path ACCEPTED",
|
||||
"%s returned true with no verifying key (len=%d fill=0x%02x err=%v)",
|
||||
name, n, fill, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
check("groth16", p.verifyGroth16)
|
||||
check("plonk", p.verifyPLONK)
|
||||
check("halo2", p.verifyHalo2)
|
||||
check("ipa", p.verifyIPA)
|
||||
check("rangeproof", p.verifyRangeProof)
|
||||
check("nullifier", p.verifyNullifier)
|
||||
}
|
||||
|
||||
// TestSound_FflonkRefusedAtEntry pins defect (b): op 0x03 must refuse,
|
||||
// whatever it is handed. The verifier behind it is unsound; the refusal is
|
||||
// the security control, so it is pinned here and must not be relaxed
|
||||
// without replacing the verifier.
|
||||
func TestSound_FflonkRefusedAtEntry(t *testing.T) {
|
||||
inputs := [][]byte{
|
||||
{OpVerifyFflonk},
|
||||
{OpVerifyFflonk, 0, 0, 0, 0},
|
||||
append([]byte{OpVerifyFflonk}, bytes.Repeat([]byte{0xff}, 1024)...),
|
||||
}
|
||||
// Also a fully well-formed payload, so the refusal is not an accident
|
||||
// of malformed input.
|
||||
valid := buildValidFflonkProof(t, fflonkTestVK(), []*big.Int{big.NewInt(7)}, 8)
|
||||
wellFormed := make([]byte, inputsOff)
|
||||
wellFormed[0] = OpVerifyFflonk
|
||||
inputs = append(inputs, append(wellFormed, valid...))
|
||||
|
||||
for i, in := range inputs {
|
||||
ret, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress,
|
||||
in, 100_000_000, true)
|
||||
require.ErrorIsf(t, err, ErrFflonkDisabled, "input %d must be refused", i)
|
||||
require.Nilf(t, ret, "input %d must return no result word", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSound_FflonkForgeIsReal demonstrates WHY the refusal above must
|
||||
// stay, so that a future reader cannot mistake it for excess caution.
|
||||
//
|
||||
// A commitment to a CONSTANT polynomial opens to the same value at every
|
||||
// point, and its opening proof is the identity. The standalone fflonk
|
||||
// decision function derives its evaluation point by Fiat-Shamir from the
|
||||
// statement — but a constant polynomial does not care where it is opened,
|
||||
// so one fixed triple satisfies the check under every statement. That is a
|
||||
// universal forge: it proves nothing and verifies always.
|
||||
func TestSound_FflonkForgeIsReal(t *testing.T) {
|
||||
blob := new(kzg4844.Blob)
|
||||
for i := 0; i < len(blob)/32; i++ {
|
||||
blob[i*32+31] = 7 // every evaluation 7 => the polynomial IS 7
|
||||
}
|
||||
c1, err := kzg4844.BlobToCommitment(blob)
|
||||
require.NoError(t, err)
|
||||
var anywhere kzg4844.Point
|
||||
anywhere[31] = 3
|
||||
w1, claim, err := kzg4844.ComputeProof(blob, anywhere)
|
||||
require.NoError(t, err)
|
||||
|
||||
fp := &FflonkProof{C1: c1[:], C2: c1[:], W1: w1[:], W2: w1[:]}
|
||||
fp.Evaluations = make([]*big.Int, 8)
|
||||
for i := range fp.Evaluations {
|
||||
fp.Evaluations[i] = big.NewInt(0)
|
||||
}
|
||||
fp.Evaluations[0] = new(big.Int).SetBytes(claim[:])
|
||||
|
||||
accepted := 0
|
||||
for _, pub := range sndStatements {
|
||||
xi := computeFflonkChallenge([]byte("transcript"), pub, []byte("xi"))
|
||||
v := computeFflonkChallenge([]byte("transcript"), pub, []byte("v"))
|
||||
ok, err := verifyFflonkKZGOpening(fp, xi, v, pub)
|
||||
require.NoError(t, err)
|
||||
if ok {
|
||||
accepted++
|
||||
}
|
||||
}
|
||||
require.Equal(t, len(sndStatements), accepted,
|
||||
"the forge is expected to satisfy the unsound decision function for "+
|
||||
"every statement; if this now fails the verifier changed and this "+
|
||||
"test should be re-derived, not deleted")
|
||||
|
||||
// Today one thing stops that triple at the precompile boundary besides
|
||||
// the disable: the point validator rejects the CANONICAL BLS12-381
|
||||
// infinity encoding (0xc0 || zeros) while accepting the non-canonical
|
||||
// all-zero encoding. That asymmetry is accidental, not a control —
|
||||
// pinned here so that "correcting" it is a conscious act.
|
||||
canonicalInfinity := make([]byte, 48)
|
||||
canonicalInfinity[0] = 0xc0
|
||||
require.Error(t, validateBLS12381G1Point(canonicalInfinity),
|
||||
"canonical infinity is currently rejected")
|
||||
require.NoError(t, validateBLS12381G1Point(make([]byte, 48)),
|
||||
"non-canonical all-zero infinity is currently accepted")
|
||||
}
|
||||
|
||||
// TestSound_FflonkUnreachable proves the unsound body cannot be reached
|
||||
// through any dispatch: not the op, and not as a batch member.
|
||||
func TestSound_FflonkUnreachable(t *testing.T) {
|
||||
// Direct op: refused (covered above, asserted again as the batch
|
||||
// control).
|
||||
_, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress,
|
||||
[]byte{OpVerifyFflonk, 0, 0, 0, 0}, 10_000_000, true)
|
||||
require.ErrorIs(t, err, ErrFflonkDisabled)
|
||||
|
||||
// As a batch entry, 0x03 is not in verifyBatch's dispatch and must be
|
||||
// rejected as an unknown system rather than silently verified.
|
||||
batch := []byte{OpVerifyBatch, 0, 0, 0, 1, OpVerifyFflonk, 0, 0, 0, 0}
|
||||
_, _, err = ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress,
|
||||
batch, 10_000_000, true)
|
||||
require.ErrorIs(t, err, ErrUnknownProofSystem)
|
||||
}
|
||||
|
||||
// TestSound_StatementBinding is the core soundness property on the one
|
||||
// path that performs real pairing checks: a proof valid for statement A
|
||||
// must NOT verify against statement B.
|
||||
func TestSound_StatementBinding(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
vk := fflonkTestVK()
|
||||
stmtA := []*big.Int{big.NewInt(7), big.NewInt(42)}
|
||||
proof := buildValidFflonkProof(t, vk, stmtA, 8)
|
||||
|
||||
require.True(t, zv.fflonkVerify(vk, proof, stmtA),
|
||||
"control: the proof must verify for the statement it was made for")
|
||||
|
||||
for _, stmtB := range [][]*big.Int{
|
||||
nil,
|
||||
{big.NewInt(7)},
|
||||
{big.NewInt(8), big.NewInt(42)},
|
||||
{big.NewInt(7), big.NewInt(43)},
|
||||
{big.NewInt(42), big.NewInt(7)}, // same values, reordered
|
||||
{big.NewInt(7), big.NewInt(42), big.NewInt(0)},
|
||||
} {
|
||||
require.Falsef(t, zv.fflonkVerify(vk, proof, stmtB),
|
||||
"proof for %v verified against %v", stmtA, stmtB)
|
||||
}
|
||||
|
||||
// Binding to the KEY as well: the same proof under a different key.
|
||||
other := &VerifyingKey{ProofSystem: ProofSystemFflonk, Hash: [32]byte{0xAA}}
|
||||
require.False(t, zv.fflonkVerify(other, proof, stmtA),
|
||||
"proof verified under a different verifying key")
|
||||
}
|
||||
|
||||
// TestSound_ProofElementTampering flips a bit in every element of a valid
|
||||
// proof in turn. Each must be rejected; an element that can be corrupted
|
||||
// without detection is not bound by the verification equation.
|
||||
func TestSound_ProofElementTampering(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
vk := fflonkTestVK()
|
||||
pub := []*big.Int{big.NewInt(7)}
|
||||
valid := buildValidFflonkProof(t, vk, pub, 8)
|
||||
require.True(t, zv.fflonkVerify(vk, valid, pub), "control")
|
||||
|
||||
// 4 compressed G1 points (48 bytes each) then 8 scalars (32 each).
|
||||
type elem struct {
|
||||
name string
|
||||
start int
|
||||
size int
|
||||
}
|
||||
elems := []elem{
|
||||
{"C1", 0, 48}, {"C2", 48, 48}, {"W1", 96, 48}, {"W2", 144, 48},
|
||||
}
|
||||
for i := range 8 {
|
||||
elems = append(elems, elem{"eval" + string(rune('0'+i)), 192 + i*32, 32})
|
||||
}
|
||||
|
||||
for _, e := range elems {
|
||||
for _, off := range []int{0, e.size / 2, e.size - 1} {
|
||||
tampered := bytes.Clone(valid)
|
||||
tampered[e.start+off] ^= 0x01
|
||||
require.Falsef(t, zv.fflonkVerify(vk, tampered, pub),
|
||||
"a bit flip in %s at byte %d was not detected", e.name, off)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSound_RangeProofRefuses pins defect (c). No bulletproof verifier is
|
||||
// wired, so op 0x23 must refuse with a named sentinel rather than report a
|
||||
// verdict it cannot justify.
|
||||
func TestSound_RangeProofRefuses(t *testing.T) {
|
||||
p := &zkVerifyPrecompile{}
|
||||
data := make([]byte, 200)
|
||||
data[35] = 64 // bitLength = 64, a well-formed request
|
||||
|
||||
ok, err := p.verifyRangeProof(data)
|
||||
require.False(t, ok)
|
||||
require.ErrorIs(t, err, ErrRangeProofUnavailable)
|
||||
|
||||
// Through the precompile, and still charged for.
|
||||
in := append([]byte{OpVerifyRangeProof}, data...)
|
||||
need := ZKVerifyPrecompile.RequiredGas(in)
|
||||
require.GreaterOrEqual(t, need, MinCallGas)
|
||||
ret, _, err := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, in, need, true)
|
||||
require.ErrorIs(t, err, ErrRangeProofUnavailable)
|
||||
require.Nil(t, ret)
|
||||
|
||||
// The structural guards still refuse first, so the sentinel means
|
||||
// exactly "no verifier", not "any bad input".
|
||||
short := make([]byte, 99)
|
||||
_, err = p.verifyRangeProof(short)
|
||||
require.ErrorIs(t, err, ErrInvalidInput)
|
||||
|
||||
zeroBits := make([]byte, 200) // bitLength == 0
|
||||
_, err = p.verifyRangeProof(zeroBits)
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrRangeProofUnavailable)
|
||||
}
|
||||
|
||||
// TestSound_RangeProofRefusesWithVerifierAttached pins the fix for a live
|
||||
// forge on the same op. Attaching a ZKVerifier used to route 0x23 to a
|
||||
// helper that answered `len(commitment)>0 && len(proof)>0 && bits>0` — a
|
||||
// length check dressed as a verdict, so every byte string was a valid
|
||||
// range proof. Range proofs are what stop a confidential transfer minting
|
||||
// value from nothing, so accepting all of them defeats the scheme.
|
||||
func TestSound_RangeProofRefusesWithVerifierAttached(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
p := &zkVerifyPrecompile{verifier: zv}
|
||||
|
||||
for _, n := range []int{100, 200, 1024} {
|
||||
for _, fill := range []byte{0x00, 0x01, 0xff} {
|
||||
data := bytes.Repeat([]byte{fill}, n)
|
||||
data[32], data[33], data[34], data[35] = 0, 0, 0, 64 // bitLength = 64
|
||||
ok, err := p.verifyRangeProof(data)
|
||||
require.Falsef(t, ok, "accepted a range proof of %d bytes of 0x%02x", n, fill)
|
||||
require.ErrorIs(t, err, ErrRangeProofUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
// Directly on the verifier, which is also a public entry point.
|
||||
ok, err := zv.VerifyRangeProof(make([]byte, 32), make([]byte, 128), 64)
|
||||
require.False(t, ok)
|
||||
require.ErrorIs(t, err, ErrRangeProofUnavailable)
|
||||
|
||||
// And as a batch member: verifyBatch dispatches 0x23, so the refusal
|
||||
// must propagate rather than being swallowed into a passing batch.
|
||||
body := make([]byte, 200)
|
||||
body[35] = 64
|
||||
batch := []byte{OpVerifyBatch, 0, 0, 0, 1, OpVerifyRangeProof}
|
||||
batch = binary.BigEndian.AppendUint32(batch, uint32(len(body)))
|
||||
batch = append(batch, body...)
|
||||
|
||||
_, _, err = ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress,
|
||||
batch, 10_000_000, true)
|
||||
require.ErrorIs(t, err, ErrRangeProofUnavailable,
|
||||
"a batch containing an uncheckable range proof must not report success")
|
||||
}
|
||||
|
||||
// TestSound_VerifyingKeyIDIsInjective pins the fix for a key-substitution
|
||||
// defect: the ID hashed only alpha..delta by bare concatenation, so a key
|
||||
// could be replaced under an ID other code already referenced.
|
||||
func TestSound_VerifyingKeyIDIsInjective(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
a, b, c, d := []byte("alpha"), []byte("beta"), []byte("gamma"), []byte("delta")
|
||||
|
||||
reg := func(alpha, beta []byte, ic [][]byte) [32]byte {
|
||||
id, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemGroth16, CircuitTransfer, alpha, beta, c, d, ic)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
// Differing only in IC — the public-input constraints — must not
|
||||
// collide. Previously it did, and the second registration silently
|
||||
// replaced the first.
|
||||
one := reg(a, b, [][]byte{[]byte("ic-one")})
|
||||
two := reg(a, b, [][]byte{[]byte("ic-TWO-different")})
|
||||
require.NotEqual(t, one, two, "keys differing in IC shared an ID")
|
||||
require.Equal(t, []byte("ic-one"), zv.VerifyingKeys[one].IC[0],
|
||||
"the first key was overwritten by the second")
|
||||
|
||||
// Differing only in the number of IC points must not collide either.
|
||||
require.NotEqual(t,
|
||||
reg(a, b, [][]byte{[]byte("x")}),
|
||||
reg(a, b, [][]byte{[]byte("x"), []byte("y")}))
|
||||
|
||||
// Bare concatenation was ambiguous across the field boundary.
|
||||
require.NotEqual(t,
|
||||
reg([]byte("ab"), []byte("c"), [][]byte{a}),
|
||||
reg([]byte("a"), []byte("bc"), [][]byte{a}),
|
||||
"the field boundary is not committed to")
|
||||
|
||||
// The proof system is part of a key's identity.
|
||||
sameArgs := func(ps ProofSystem) [32]byte {
|
||||
id, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ps, CircuitTransfer, a, b, c, d, [][]byte{a})
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
require.NotEqual(t, sameArgs(ProofSystemGroth16), sameArgs(ProofSystemPlonk))
|
||||
|
||||
// And it is a pure function of its inputs.
|
||||
require.Equal(t,
|
||||
verifyingKeyID(ProofSystemGroth16, CircuitTransfer, a, b, c, d, [][]byte{a}),
|
||||
verifyingKeyID(ProofSystemGroth16, CircuitTransfer, a, b, c, d, [][]byte{a}))
|
||||
}
|
||||
|
||||
// TestSound_RegisterDoesNotAliasCallerBuffers pins that registration
|
||||
// copies nothing into a caller's spare capacity. The ID derivation used
|
||||
// append(alpha, beta...), which writes through alpha's backing array
|
||||
// whenever it has room.
|
||||
func TestSound_RegisterDoesNotAliasCallerBuffers(t *testing.T) {
|
||||
zv := NewZKVerifier()
|
||||
buf := make([]byte, 5, 128)
|
||||
copy(buf, "AAAAA")
|
||||
snapshot := bytes.Clone(buf[:cap(buf)])
|
||||
|
||||
_, err := zv.RegisterVerifyingKey(common.Address{},
|
||||
ProofSystemGroth16, CircuitTransfer,
|
||||
buf, []byte("BBBBB"), []byte("C"), []byte("D"), [][]byte{[]byte("E")})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, snapshot, buf[:cap(buf)],
|
||||
"registration wrote into the caller's backing array")
|
||||
}
|
||||
|
||||
// TestSound_Deterministic pins that verification is a pure function of its
|
||||
// input. A verifier that consults the clock, a random source, or map
|
||||
// iteration order would split consensus between nodes.
|
||||
func TestSound_Deterministic(t *testing.T) {
|
||||
inputs := [][]byte{
|
||||
{OpVerifyGroth16, 0, 0, 0, 0},
|
||||
{OpVerifyPLONK, 0, 0, 0, 0},
|
||||
{OpVerifyFflonk, 0, 0, 0, 0},
|
||||
{OpVerifyHalo2, 0, 0, 0, 0},
|
||||
{OpVerifyKZG},
|
||||
{OpVerifyBatch, 0, 0, 0, 2, OpVerifyGroth16, 0, 0, 0, 0},
|
||||
bytes.Repeat([]byte{0xa5}, 600),
|
||||
}
|
||||
for i := range inputs {
|
||||
if len(inputs[i]) > 0 && i == len(inputs)-1 {
|
||||
inputs[i][0] = OpVerifyHalo2
|
||||
}
|
||||
}
|
||||
|
||||
for _, in := range inputs {
|
||||
wantGas := ZKVerifyPrecompile.RequiredGas(in)
|
||||
wantRet, wantRem, wantErr := ZKVerifyPrecompile.Run(
|
||||
nil, addr0, ZKVerifyContractAddress, in, 50_000_000, true)
|
||||
|
||||
for range 25 {
|
||||
require.Equal(t, wantGas, ZKVerifyPrecompile.RequiredGas(in),
|
||||
"gas is not a pure function of the input")
|
||||
|
||||
ret, rem, err := ZKVerifyPrecompile.Run(
|
||||
nil, addr0, ZKVerifyContractAddress, in, 50_000_000, true)
|
||||
require.Equal(t, wantRet, ret)
|
||||
require.Equal(t, wantRem, rem)
|
||||
if wantErr == nil {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, wantErr.Error(), err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSound_ReadOnlyIsIrrelevant pins that verification does not depend on
|
||||
// the EVM's static-call flag: a proof either verifies or it does not, and
|
||||
// a verifier that behaved differently under staticcall would let callers
|
||||
// choose their verdict.
|
||||
func TestSound_ReadOnlyIsIrrelevant(t *testing.T) {
|
||||
for op := range 0x40 {
|
||||
in := make([]byte, 300)
|
||||
in[0] = byte(op)
|
||||
gas := ZKVerifyPrecompile.RequiredGas(in) + 1_000_000
|
||||
|
||||
roRet, roRem, roErr := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, in, gas, true)
|
||||
rwRet, rwRem, rwErr := ZKVerifyPrecompile.Run(nil, addr0, ZKVerifyContractAddress, in, gas, false)
|
||||
|
||||
require.Equalf(t, roRet, rwRet, "op 0x%02x returned differently under staticcall", op)
|
||||
require.Equal(t, roRem, rwRem)
|
||||
require.Equalf(t, errors.Is(roErr, rwErr) || (roErr == nil) == (rwErr == nil), true,
|
||||
"op 0x%02x errored differently under staticcall: %v vs %v", op, roErr, rwErr)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+91
-24
@@ -5,6 +5,7 @@ package zk
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sync"
|
||||
@@ -69,6 +70,51 @@ func NewZKVerifier() *ZKVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
// verifyingKeyID derives the identifier a key is stored and referenced
|
||||
// under. It must commit to EVERY field that defines the key, with each
|
||||
// field length-prefixed.
|
||||
//
|
||||
// The previous derivation, sha256(alpha‖beta‖gamma‖delta), failed both
|
||||
// requirements. It omitted IC — the public-input constraints — so two keys
|
||||
// differing only in IC shared an ID and registering the second silently
|
||||
// replaced the first under an ID other code already referenced. And the
|
||||
// bare concatenation was ambiguous: (alpha="ab", beta="c") hashed
|
||||
// identically to (alpha="a", beta="bc").
|
||||
//
|
||||
// Length prefixes make the encoding injective, so distinct keys have
|
||||
// distinct IDs.
|
||||
func verifyingKeyID(
|
||||
proofSystem ProofSystem,
|
||||
circuitType CircuitType,
|
||||
alpha, beta, gamma, delta []byte,
|
||||
ic [][]byte,
|
||||
) [32]byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("lux/zk/verifying-key/v1"))
|
||||
h.Write([]byte{byte(proofSystem), byte(circuitType)})
|
||||
|
||||
var n [4]byte
|
||||
field := func(b []byte) {
|
||||
binary.BigEndian.PutUint32(n[:], uint32(len(b)))
|
||||
h.Write(n[:])
|
||||
h.Write(b)
|
||||
}
|
||||
field(alpha)
|
||||
field(beta)
|
||||
field(gamma)
|
||||
field(delta)
|
||||
|
||||
binary.BigEndian.PutUint32(n[:], uint32(len(ic)))
|
||||
h.Write(n[:])
|
||||
for _, e := range ic {
|
||||
field(e)
|
||||
}
|
||||
|
||||
var id [32]byte
|
||||
copy(id[:], h.Sum(nil))
|
||||
return id
|
||||
}
|
||||
|
||||
// RegisterVerifyingKey registers a new verification key
|
||||
func (zv *ZKVerifier) RegisterVerifyingKey(
|
||||
owner common.Address,
|
||||
@@ -80,11 +126,7 @@ func (zv *ZKVerifier) RegisterVerifyingKey(
|
||||
zv.mu.Lock()
|
||||
defer zv.mu.Unlock()
|
||||
|
||||
// Generate key ID
|
||||
keyData := append(alpha, beta...)
|
||||
keyData = append(keyData, gamma...)
|
||||
keyData = append(keyData, delta...)
|
||||
keyID := sha256.Sum256(keyData)
|
||||
keyID := verifyingKeyID(proofSystem, circuitType, alpha, beta, gamma, delta, ic)
|
||||
|
||||
vk := &VerifyingKey{
|
||||
KeyID: keyID,
|
||||
@@ -95,7 +137,7 @@ func (zv *ZKVerifier) RegisterVerifyingKey(
|
||||
Gamma: gamma,
|
||||
Delta: delta,
|
||||
IC: ic,
|
||||
Hash: sha256.Sum256(keyData),
|
||||
Hash: keyID,
|
||||
Owner: owner,
|
||||
CreatedAt: uint64(time.Now().Unix()),
|
||||
}
|
||||
@@ -313,6 +355,22 @@ func (zv *ZKVerifier) fflonkVerify(
|
||||
return true
|
||||
}
|
||||
|
||||
// degenerate reports whether any given curve element is absent or is the
|
||||
// point at infinity. bn256 marshals affine (x,y) uncompressed and encodes
|
||||
// the identity as all zeros, so an all-zero element IS the identity.
|
||||
//
|
||||
// The identity is the one curve point that makes a pairing factor
|
||||
// unconditionally 1, which is why a verifying key containing one accepts
|
||||
// every proof. Rejecting it is a soundness requirement, not hygiene.
|
||||
func degenerate(elems ...[]byte) bool {
|
||||
for _, e := range elems {
|
||||
if len(e) == 0 || isZeroBytes(e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// blsScalarField returns the BLS12-381 scalar field order r.
|
||||
func blsScalarField() *big.Int {
|
||||
r := new(big.Int)
|
||||
@@ -416,13 +474,16 @@ func (zv *ZKVerifier) VerifyRangeProof(
|
||||
rangeProof []byte,
|
||||
bitLength uint32,
|
||||
) (bool, error) {
|
||||
zv.mu.RLock()
|
||||
defer zv.mu.RUnlock()
|
||||
|
||||
// Bulletproofs-style range proof verification
|
||||
valid := zv.bulletproofRangeVerify(commitment, rangeProof, bitLength)
|
||||
|
||||
return valid, nil
|
||||
// No bulletproof verifier is wired. Refuse.
|
||||
//
|
||||
// This used to answer `len(commitment) > 0 && len(rangeProof) > 0 &&
|
||||
// bitLength > 0` — a length check reported as a verification verdict,
|
||||
// so every well-formed byte string was a valid range proof. A range
|
||||
// proof is what stops a confidential transfer minting value out of
|
||||
// nothing, so accepting all of them defeats the whole scheme. The
|
||||
// helper is gone rather than fixed: a function that cannot verify must
|
||||
// not be shaped like a verifier.
|
||||
return false, ErrRangeProofUnavailable
|
||||
}
|
||||
|
||||
// CheckNullifier checks if a nullifier has been spent
|
||||
@@ -671,6 +732,15 @@ func (zv *ZKVerifier) groth16PairingCheck(
|
||||
proofA, proofB, proofC []byte,
|
||||
publicInputs []*big.Int,
|
||||
) bool {
|
||||
// A key whose elements are the identity verifies EVERYTHING: e(O,Q)=1,
|
||||
// so each factor of the product collapses and the check succeeds for
|
||||
// every proof and every statement. No honest setup emits one — alpha,
|
||||
// beta, gamma, delta are g^x for random nonzero x. Refuse before doing
|
||||
// any arithmetic, so nobody who can register a key can forge with it.
|
||||
if degenerate(vk.Alpha, vk.Beta, vk.Gamma, vk.Delta) || degenerate(vk.IC...) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse proof elements
|
||||
var a bn256.G1
|
||||
if _, err := a.Unmarshal(proofA); err != nil {
|
||||
@@ -790,6 +860,14 @@ func (zv *ZKVerifier) plonkVerify(
|
||||
proof []byte,
|
||||
publicInputs []*big.Int,
|
||||
) bool {
|
||||
// Same degeneracy refusal as Groth16: an identity selector or an
|
||||
// identity X2 collapses the pairing and accepts every proof. vk.Beta is
|
||||
// read below as the G2 generator, and vk.IC as the eight selectors plus
|
||||
// X2, so all of them must be non-identity.
|
||||
if degenerate(vk.Beta) || degenerate(vk.IC...) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Minimum proof size: 9 G1 points (576 bytes) + 6 scalars (192 bytes) = 768 bytes
|
||||
const minProofSize = 768
|
||||
if len(proof) < minProofSize {
|
||||
@@ -1017,17 +1095,6 @@ func (zv *ZKVerifier) kzgPointEvaluation(
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (zv *ZKVerifier) bulletproofRangeVerify(
|
||||
commitment []byte,
|
||||
rangeProof []byte,
|
||||
bitLength uint32,
|
||||
) bool {
|
||||
// Bulletproofs range proof verification
|
||||
// Proves that committed value v satisfies 0 ≤ v < 2^n
|
||||
|
||||
return len(commitment) > 0 && len(rangeProof) > 0 && bitLength > 0
|
||||
}
|
||||
|
||||
func (zv *ZKVerifier) verifyMerkleProof(
|
||||
root [32]byte,
|
||||
leaf []byte,
|
||||
|
||||
+26
-16
@@ -5,6 +5,7 @@ package zk
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
@@ -653,12 +654,16 @@ func TestVerifyRangeProof(t *testing.T) {
|
||||
commitment := make([]byte, 32)
|
||||
rangeProof := make([]byte, 128)
|
||||
|
||||
// There is no bulletproof verifier, so this must refuse. It used to
|
||||
// answer "true" to any non-empty byte strings, which made every range
|
||||
// proof valid and every confidential amount unconstrained.
|
||||
valid, err := zv.VerifyRangeProof(commitment, rangeProof, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRangeProof failed: %v", err)
|
||||
if valid {
|
||||
t.Fatal("VerifyRangeProof accepted a proof it cannot check")
|
||||
}
|
||||
if !errors.Is(err, ErrRangeProofUnavailable) {
|
||||
t.Fatalf("expected ErrRangeProofUnavailable, got %v", err)
|
||||
}
|
||||
|
||||
_ = valid // Returns true for range proof structural validation
|
||||
}
|
||||
|
||||
// TestVerificationStatistics tests stats tracking
|
||||
@@ -1078,14 +1083,18 @@ func TestVerifyHalo2Full(t *testing.T) {
|
||||
vk.IC[i][0] = byte(i + 1) // Non-zero
|
||||
}
|
||||
|
||||
// Full verification should complete
|
||||
// The keyed path must REFUSE. It can fold the IPA rounds, but the
|
||||
// closing equation needs the commitment key's SRS generators, which a
|
||||
// Groth16-shaped VerifyingKey does not carry. Returning true here — as
|
||||
// this function once did — made op 0x04 accept every proof for every
|
||||
// statement as soon as any Halo2 key was registered.
|
||||
valid, err := verifyHalo2Full(proof, vk)
|
||||
if err != nil {
|
||||
t.Fatalf("verifyHalo2Full failed: %v", err)
|
||||
if valid {
|
||||
t.Fatal("verifyHalo2Full accepted a proof it cannot check")
|
||||
}
|
||||
if !errors.Is(err, ErrHalo2KeyIncomplete) {
|
||||
t.Fatalf("expected ErrHalo2KeyIncomplete, got %v", err)
|
||||
}
|
||||
// Structural validation passes; actual curve point verification
|
||||
// is performed by the on-chain BN254/Pallas precompile calls.
|
||||
_ = valid
|
||||
}
|
||||
|
||||
// TestComputeHalo2Challenges tests Fiat-Shamir challenge generation
|
||||
@@ -1358,16 +1367,17 @@ func buildTestHalo2Proof(numInputs, numAdvice, numInstance int, numRounds uint32
|
||||
offset += 32
|
||||
}
|
||||
|
||||
// a_scalar (32 bytes) - non-zero
|
||||
data[offset] = 0x50
|
||||
// a_scalar, evalPoint, claimedEval (32 bytes each): non-zero AND
|
||||
// canonical. The marker byte goes in the LOW-order position: a
|
||||
// leading 0x50 would make the value exceed the Pallas group order,
|
||||
// which isValidScalar now rejects (it used to check only the length).
|
||||
data[offset+31] = 0x50
|
||||
offset += 32
|
||||
|
||||
// evalPoint (32 bytes) - non-zero
|
||||
data[offset] = 0x60
|
||||
data[offset+31] = 0x60
|
||||
offset += 32
|
||||
|
||||
// claimedEval (32 bytes) - non-zero
|
||||
data[offset] = 0x70
|
||||
data[offset+31] = 0x70
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user