mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
The proof argument carries the p3q header, and this precompile never looked at it -- only the "P3Q1" tag and the length framing. Everything about how hard the verification would be, log_blowup and num_queries, arrived off calldata chosen by whoever submitted the proof, and at the weakest settings the wire admits that is about one bit. admit() is now the one question both entry points ask: the envelope tag, a frame long enough to state parameters at all, and log_blowup * num_queries >= MinSoundnessBits. The verifier enforces the same floor, but a consensus rule belongs where the chain can be read, not only inside a linked staticlib -- and refusing here means the node crosses no FFI boundary for a proof it was never going to accept. The gas charge is unchanged; it is a function of calldata length alone, so the saving is the node's work, not the caller's fee. Under the starkfri_p3q tag a test reads p3q_min_soundness_bits() out of the linked library, so the two copies cannot drift. The fixtures move with it. "P3Q1" followed by arbitrary bytes was standing in for a well-formed proof, but those bytes ARE the parameters, so whether such a fixture cleared the floor was an accident -- and under crypto/rand, not the same accident twice. sfProof builds a frame that states what it means to state.
484 lines
16 KiB
Go
484 lines
16 KiB
Go
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package starkfri
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestStarkFRI_E2E_VerifiesNRealishProofs registers a counting
|
|
// verifier that emulates the structure of the real backend
|
|
// (cross-validates the (version, proof, pub) triple), runs N round
|
|
// trips through the precompile, and asserts each call lands at the
|
|
// backend with the correct parsed triple.
|
|
//
|
|
// This is the e2e equivalent of "verify N real STARK-FRI proofs
|
|
// through the EVM precompile dispatch": the backend is a counting
|
|
// stand-in here because the real Rust verifier is brought in via
|
|
// cgo at startup outside the Go unit-test build, but the byte path
|
|
// from EVM calldata to (version, proof, pub) is exercised in full.
|
|
func TestStarkFRI_E2E_VerifiesNRealishProofs(t *testing.T) {
|
|
const N = 64
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
seenSet = make(map[string]struct{}, N)
|
|
called = 0
|
|
verifier = func(v byte, proof, pub []byte) (bool, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
called++
|
|
// Cross-validation: a real backend would check the version,
|
|
// proof magic, and dispatch to FIPS 204 / STARK FRI. The
|
|
// stand-in checks the structural invariants the precompile
|
|
// is documented to guarantee at the backend boundary.
|
|
if v != VersionV1 {
|
|
return false, errors.New("backend: bad version")
|
|
}
|
|
if len(proof) < len(MagicHeader) {
|
|
return false, errors.New("backend: proof too short")
|
|
}
|
|
if string(proof[:len(MagicHeader)]) != MagicHeader {
|
|
return false, errors.New("backend: bad magic at backend")
|
|
}
|
|
// Record a hash-like fingerprint of the triple so the test
|
|
// can assert uniqueness.
|
|
fp := make([]byte, 0, 1+len(proof)+len(pub))
|
|
fp = append(fp, v)
|
|
fp = append(fp, proof...)
|
|
fp = append(fp, pub...)
|
|
seenSet[string(fp)] = struct{}{}
|
|
return true, nil
|
|
}
|
|
)
|
|
RegisterVerifier(verifier)
|
|
defer RegisterVerifier(nil)
|
|
|
|
for i := 0; i < N; i++ {
|
|
// Generate a structurally well-formed input with varying
|
|
// proof / pub payload.
|
|
body := make([]byte, 256)
|
|
_, err := rand.Read(body)
|
|
require.NoError(t, err)
|
|
pub := make([]byte, 96)
|
|
_, err = rand.Read(pub)
|
|
require.NoError(t, err)
|
|
|
|
proof := sfProof(body)
|
|
input := buildInput(VersionV1, proof, pub)
|
|
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
|
_, gasLeft, err := StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
require.NoError(t, err, "round %d", i)
|
|
require.Less(t, gasLeft, gas, "round %d: gas must be debited", i)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
require.Equal(t, N, called, "every round must reach the backend")
|
|
require.Len(t, seenSet, N, "every round must deliver a unique triple")
|
|
}
|
|
|
|
// TestP3Q_E2E_RejectsBeforeBackendOnBadMagic confirms the structural
|
|
// pre-filter rejects bad magic BEFORE the backend is invoked, even
|
|
// when calldata is otherwise well-formed.
|
|
func TestStarkFRI_E2E_RejectsBeforeBackendOnBadMagic(t *testing.T) {
|
|
called := 0
|
|
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
|
called++
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
for _, prefix := range []string{"XXXX", "P3Q0", "p3q1", "P3Q\x00"} {
|
|
body := make([]byte, 128)
|
|
_, err := rand.Read(body)
|
|
require.NoError(t, err)
|
|
proof := append([]byte(prefix), body...)
|
|
pub := []byte{0xde, 0xad, 0xbe, 0xef}
|
|
input := buildInput(VersionV1, proof, pub)
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
|
_, _, err = StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
require.ErrorIs(t, err, ErrInvalidProof, "prefix=%q", prefix)
|
|
}
|
|
require.Zero(t, called, "backend must not be invoked when magic mismatches")
|
|
}
|
|
|
|
// TestP3Q_E2E_VerifierFailureSurfacesAsInvalidProof confirms that a
|
|
// backend error or rejected verification surfaces as ErrInvalidProof
|
|
// at the precompile boundary.
|
|
func TestStarkFRI_E2E_VerifierFailureSurfacesAsInvalidProof(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fn VerifierFn
|
|
want error
|
|
}{
|
|
{
|
|
name: "backend returns ok=false",
|
|
fn: func(byte, []byte, []byte) (bool, error) { return false, nil },
|
|
want: ErrInvalidProof,
|
|
},
|
|
{
|
|
name: "backend returns custom error",
|
|
fn: func(byte, []byte, []byte) (bool, error) {
|
|
return false, errors.New("backend boom")
|
|
},
|
|
want: nil, // caller sees the underlying error directly
|
|
},
|
|
}
|
|
|
|
proof := sfProof(make([]byte, 64))
|
|
pub := []byte{0x01, 0x02}
|
|
input := buildInput(VersionV1, proof, pub)
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
RegisterVerifier(tc.fn)
|
|
defer RegisterVerifier(nil)
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
|
_, _, err := StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
if tc.want != nil {
|
|
require.ErrorIs(t, err, tc.want)
|
|
} else {
|
|
require.Error(t, err)
|
|
require.NotErrorIs(t, err, ErrInvalidProof,
|
|
"custom backend error must be propagated as-is")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestP3Q_E2E_GasMonotonic confirms that required_gas is monotonic in
|
|
// input length — the EC lemma `P3Q_Gas_Model.ec::gas_monotonic` is
|
|
// stated formally; this is its empirical realisation.
|
|
func TestStarkFRI_E2E_GasMonotonic(t *testing.T) {
|
|
prev := uint64(0)
|
|
for _, n := range []int{0, 1, 13, 100, 1024, 10240, 102400} {
|
|
input := make([]byte, n)
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input)
|
|
require.GreaterOrEqual(t, gas, prev, "n=%d", n)
|
|
prev = gas
|
|
}
|
|
}
|
|
|
|
// TestP3Q_E2E_GasUniformAcrossErrors confirms that all non-OOG error
|
|
// paths return the same `remaining` value as the success path. The EC
|
|
// lemma `P3Q_Gas_Model.ec::uniform_billing` is its formal counterpart.
|
|
func TestStarkFRI_E2E_GasUniformAcrossErrors(t *testing.T) {
|
|
// Build five inputs each producing one of the five error modes,
|
|
// AND a success input. All MUST land on the same `remaining`
|
|
// when given identical-length calldata at identical supplied_gas.
|
|
//
|
|
// Since errors are returned at different stages, achieving exact
|
|
// length equality across all error modes is non-trivial; we instead
|
|
// verify the closely related invariant: gas charged is identical
|
|
// for inputs of identical length, regardless of content.
|
|
const inputLen = 256
|
|
probe := make([]byte, inputLen)
|
|
wantGas := StarkFRIVerifyPrecompile.RequiredGas(probe)
|
|
for i := 0; i < 16; i++ {
|
|
_, err := rand.Read(probe)
|
|
require.NoError(t, err)
|
|
gotGas := StarkFRIVerifyPrecompile.RequiredGas(probe)
|
|
require.Equal(t, wantGas, gotGas, "iteration %d", i)
|
|
}
|
|
}
|
|
|
|
// TestP3Q_E2E_OOGShortCircuit confirms that supplied_gas <
|
|
// required_gas yields ErrOutOfGas and never reaches the backend.
|
|
func TestStarkFRI_E2E_OOGShortCircuit(t *testing.T) {
|
|
called := false
|
|
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
|
called = true
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
proof := sfProof(make([]byte, 64))
|
|
pub := []byte{0x01}
|
|
input := buildInput(VersionV1, proof, pub)
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) - 1 // one wei short
|
|
|
|
_, _, err := StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
require.Error(t, err)
|
|
require.False(t, called, "backend must not be invoked under OOG")
|
|
}
|
|
|
|
// TestP3Q_E2E_RoundTripParser is the round-trip e2e: serialise
|
|
// (version, proof, pub) via the same wire format that buildInput uses,
|
|
// dispatch through Run(), and assert that the backend observes exactly
|
|
// the same (version, proof, pub) triple.
|
|
func TestStarkFRI_E2E_RoundTripParser(t *testing.T) {
|
|
wantVersion := VersionV1
|
|
wantProof := sfProof([]byte("P3Q-STARK-payload-bytes-N-rounds"))
|
|
wantPub := []byte{0xab, 0xcd, 0xef, 0x12, 0x34}
|
|
|
|
var saw struct {
|
|
version byte
|
|
proof []byte
|
|
pub []byte
|
|
}
|
|
RegisterVerifier(func(v byte, p, pi []byte) (bool, error) {
|
|
saw.version = v
|
|
saw.proof = bytes.Clone(p)
|
|
saw.pub = bytes.Clone(pi)
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
input := buildInput(wantVersion, wantProof, wantPub)
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
|
|
|
_, _, err := StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, wantVersion, saw.version)
|
|
require.Equal(t, wantProof, saw.proof)
|
|
require.Equal(t, wantPub, saw.pub)
|
|
}
|
|
|
|
// helperLengthOffset locates the proof_len field offset in the wire
|
|
// format. Used by fuzz tests below.
|
|
func helperLengthOffset() int { return 1 /* version */ }
|
|
|
|
// helperBuildBadLenInput crafts an input whose declared proof_len
|
|
// over-runs the actual buffer.
|
|
func helperBuildBadLenInput(declaredProofLen uint32) []byte {
|
|
const actualBody = 16
|
|
out := make([]byte, 0, 1+4+actualBody+4+0)
|
|
out = append(out, VersionV1)
|
|
var b4 [4]byte
|
|
binary.BigEndian.PutUint32(b4[:], declaredProofLen)
|
|
out = append(out, b4[:]...)
|
|
out = append(out, []byte(MagicHeader)...)
|
|
out = append(out, make([]byte, actualBody-len(MagicHeader))...)
|
|
binary.BigEndian.PutUint32(b4[:], 0)
|
|
out = append(out, b4[:]...)
|
|
return out
|
|
}
|
|
|
|
// TestP3Q_Fuzz_TruncatedInput exercises the structural pre-filter
|
|
// against truncated calldata. Every truncation MUST fail with
|
|
// ErrInvalidInputLength; backend MUST NOT be invoked.
|
|
func TestStarkFRI_Fuzz_TruncatedInput(t *testing.T) {
|
|
called := false
|
|
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
|
called = true
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
full := buildInput(VersionV1, sfProof(make([]byte, 64)), []byte{0xaa, 0xbb})
|
|
for k := 0; k < len(full); k++ {
|
|
input := full[:k]
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
|
_, _, err := StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
// Anything shorter than the full input must error. The two
|
|
// permitted errors are ErrInvalidInputLength (the structural
|
|
// rejection) and ErrInvalidProof (when the magic prefix is
|
|
// truncated such that only a partial header survives).
|
|
require.Error(t, err, "k=%d", k)
|
|
}
|
|
require.False(t, called, "backend must not be invoked on truncated calldata")
|
|
}
|
|
|
|
// TestP3Q_Fuzz_PaddedInput exercises padded calldata. Padding bytes
|
|
// AFTER the declared pub_len ARE accepted by contract.go (the
|
|
// precompile decodes exactly proof_len + pub_len + length-prefixes
|
|
// and ignores trailing bytes). This test pins that semantic.
|
|
func TestStarkFRI_Fuzz_PaddedInput(t *testing.T) {
|
|
called := false
|
|
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
|
called = true
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
canonical := buildInput(VersionV1, sfProof(make([]byte, 16)), []byte{0xaa})
|
|
padded := append(bytes.Clone(canonical), make([]byte, 64)...)
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(padded) * 2
|
|
_, _, err := StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
padded, gas, true,
|
|
)
|
|
// Padding is accepted; the precompile decodes exactly the declared
|
|
// proof_len and pub_len, the trailing bytes are unused.
|
|
require.NoError(t, err)
|
|
require.True(t, called, "backend must be invoked on well-formed padded input")
|
|
}
|
|
|
|
// TestP3Q_Fuzz_BitFlipped runs N bit-flip fuzz iterations against a
|
|
// canonical input. The structural pre-filter must catch any flip that
|
|
// touches the version byte, length fields, or magic header; flips in
|
|
// the proof body or pub body propagate to the backend.
|
|
func TestStarkFRI_Fuzz_BitFlipped(t *testing.T) {
|
|
const iterations = 256
|
|
|
|
backendInvoked := 0
|
|
structuralRejected := 0
|
|
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
|
backendInvoked++
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
canonical := buildInput(VersionV1,
|
|
sfProof(make([]byte, 128)),
|
|
make([]byte, 32))
|
|
|
|
for i := 0; i < iterations; i++ {
|
|
var idx [8]byte
|
|
_, err := rand.Read(idx[:])
|
|
require.NoError(t, err)
|
|
off := binary.BigEndian.Uint64(idx[:]) % uint64(len(canonical))
|
|
mutant := bytes.Clone(canonical)
|
|
mutant[off] ^= 0x55
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(mutant) * 2
|
|
_, _, err = StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
mutant, gas, true,
|
|
)
|
|
// A flip at offset 0 hits the version byte → InvalidVersion.
|
|
// A flip at offsets 1..4 hits proof_len → likely InvalidInputLength.
|
|
// A flip at offset 5..8 hits the magic header → InvalidProof.
|
|
// A flip in the body propagates to the backend (which accepts here,
|
|
// since the stand-in verifier returns true unconditionally).
|
|
switch off {
|
|
case 0:
|
|
require.ErrorIs(t, err, ErrInvalidVersion, "off=%d", off)
|
|
case 1, 2, 3, 4:
|
|
// proof_len mutation can flip into either InvalidInputLength
|
|
// (length overshoots buffer) or pass through to backend (length
|
|
// happens to remain within bounds). Both are acceptable; the
|
|
// structural pre-filter does its job either way.
|
|
if err != nil {
|
|
require.True(t,
|
|
errors.Is(err, ErrInvalidInputLength) || err == nil,
|
|
"off=%d err=%v", off, err)
|
|
structuralRejected++
|
|
}
|
|
case 5, 6, 7, 8:
|
|
require.ErrorIs(t, err, ErrInvalidProof, "off=%d", off)
|
|
structuralRejected++
|
|
}
|
|
}
|
|
t.Logf("backendInvoked=%d structuralRejected=%d", backendInvoked, structuralRejected)
|
|
}
|
|
|
|
// TestP3Q_Fuzz_BadLengthField crafts inputs whose declared proof_len
|
|
// is wildly inconsistent with the buffer length. All MUST fail with
|
|
// ErrInvalidInputLength; backend MUST NOT be invoked.
|
|
func TestStarkFRI_Fuzz_BadLengthField(t *testing.T) {
|
|
called := false
|
|
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
|
called = true
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
for _, declared := range []uint32{0xFFFFFFFF, 0x80000000, 0x40000000, 0x10000000} {
|
|
input := helperBuildBadLenInput(declared)
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
|
_, _, err := StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
require.ErrorIs(t, err, ErrInvalidInputLength,
|
|
"declared=0x%x", declared)
|
|
}
|
|
require.False(t, called, "backend must not be invoked on bad-length inputs")
|
|
}
|
|
|
|
// TestP3Q_Fuzz_WrongVersion exercises versions other than 0x01.
|
|
// All MUST be rejected with ErrInvalidVersion BEFORE backend dispatch.
|
|
func TestStarkFRI_Fuzz_WrongVersion(t *testing.T) {
|
|
called := false
|
|
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
|
called = true
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
for v := 0; v < 256; v++ {
|
|
if byte(v) == VersionV1 {
|
|
continue
|
|
}
|
|
proof := sfProof(make([]byte, 64))
|
|
pub := []byte{0xaa}
|
|
input := buildInput(byte(v), proof, pub)
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
|
_, _, err := StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
require.ErrorIs(t, err, ErrInvalidVersion, "v=0x%x", v)
|
|
}
|
|
require.False(t, called, "backend must not be invoked on wrong-version inputs")
|
|
}
|
|
|
|
// FuzzP3Q_Dispatch is a Go fuzz target. The structural gate must
|
|
// never panic on arbitrary calldata; the backend (registered to
|
|
// always accept) must never see a triple that fails its own
|
|
// preconditions.
|
|
func FuzzStarkFRI_Dispatch(f *testing.F) {
|
|
// Seed corpus: canonical, truncated, padded.
|
|
f.Add([]byte{0x01, 0x00, 0x00, 0x00, 0x04, 0x50, 0x33, 0x51, 0x31, 0x00, 0x00, 0x00, 0x00})
|
|
f.Add([]byte("short"))
|
|
f.Add([]byte{})
|
|
|
|
RegisterVerifier(func(v byte, proof, pub []byte) (bool, error) {
|
|
// Backend's own structural invariants. If any of these fail,
|
|
// the precompile's structural gate let bad calldata through.
|
|
if v != VersionV1 {
|
|
return false, errors.New("backend: bad version reached backend")
|
|
}
|
|
if len(proof) < len(MagicHeader) {
|
|
return false, errors.New("backend: short proof reached backend")
|
|
}
|
|
if string(proof[:len(MagicHeader)]) != MagicHeader {
|
|
return false, errors.New("backend: bad magic reached backend")
|
|
}
|
|
return true, nil
|
|
})
|
|
defer RegisterVerifier(nil)
|
|
|
|
f.Fuzz(func(t *testing.T, input []byte) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("precompile panicked on input: %v", r)
|
|
}
|
|
}()
|
|
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
|
_, _, _ = StarkFRIVerifyPrecompile.Run(
|
|
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
|
input, gas, true,
|
|
)
|
|
})
|
|
}
|