Files
zeekay 73f3159a94 starkfri: refuse a proof that picked its own difficulty
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.
2026-08-28 14:04:37 -07:00

174 lines
6.1 KiB
Go

// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package starkfri
import (
"bytes"
"testing"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// sfProofAt returns a proof frame stating the given parameters:
// the "P3Q1" envelope tag, the P3Q-SHA3 profile byte, the 10-byte p3q
// header, then body.
func sfProofAt(logBlowup, numQueries byte, body []byte) []byte {
out := append([]byte(MagicHeader), 0x10) // ProofSystemId::Sha3
out = append(out,
0x01, // wire version
0x03, // log_degree_bound
logBlowup, //
0x01, // log_arity (binary folding)
numQueries, //
0x02, // log_final_poly
0x00, 0x00, // flags, reserved
0x00, 0x00, // public_input_len
)
return append(out, body...)
}
// sfProof returns the cheapest frame the floor admits, carrying body.
//
// Fixtures meaning "a proof that clears structural validation" must be
// built with this. A bare MagicHeader followed by arbitrary bytes
// states parameters nobody chose, so whether it clears the floor is an
// accident of those bytes — and under crypto/rand it is not even the
// same accident twice.
func sfProof(body []byte) []byte { return sfProofAt(2, 64, body) }
// TestSF_FloorBoundary walks the whole parameter grid the p3q wire
// admits and pins the verdict at every point: admitted exactly when
// log_blowup * num_queries reaches MinSoundnessBits. One query below
// the line refuses, one query on it passes.
//
// A per-field minimum cannot express this. (log_blowup=1,
// num_queries=64) and (log_blowup=5, num_queries=1) both sit inside
// every range the wire allows and neither is admissible; the boundary
// is a product, so no pair of independent bounds draws it.
func TestSF_FloorBoundary(t *testing.T) {
sfClear(t)
var reached int
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
reached++
return true, nil
})
for logBlowup := 1; logBlowup <= 5; logBlowup++ {
for numQueries := 1; numQueries <= 64; numQueries++ {
proof := sfProofAt(byte(logBlowup), byte(numQueries), nil)
want := logBlowup*numQueries >= MinSoundnessBits
before := reached
ok, err := Verify(proof, nil)
require.Equalf(t, want, ok,
"log_blowup=%d num_queries=%d (%d bits)",
logBlowup, numQueries, logBlowup*numQueries)
if want {
require.NoError(t, err)
require.Equal(t, before+1, reached, "an admitted proof must reach the verifier")
} else {
require.ErrorIs(t, err, ErrInsufficientSoundness)
require.Equal(t, before, reached,
"a proof below the floor must not reach the verifier")
}
}
}
// Rate 1/2 cannot reach the floor at any query count the wire
// allows, so the whole blowup is unreachable.
for numQueries := 1; numQueries <= 64; numQueries++ {
ok, _ := Verify(sfProofAt(1, byte(numQueries), nil), nil)
require.False(t, ok, "log_blowup=1 num_queries=%d", numQueries)
}
}
// TestSF_FloorHoldsAtBothEntryPoints: Run and Verify ask the same
// question, so a proof refused by one cannot be admitted by the other.
func TestSF_FloorHoldsAtBothEntryPoints(t *testing.T) {
sfClear(t)
called := false
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
called = true
return true, nil
})
weak := sfProofAt(2, 63, nil) // 126 bits — one query short
at := sfProofAt(2, 64, nil) // 128 bits — exactly the floor
_, err := Verify(weak, nil)
require.ErrorIs(t, err, ErrInsufficientSoundness)
require.ErrorIs(t, sfRun(sfWire(VersionV1, weak, nil)), ErrInsufficientSoundness)
require.False(t, called, "a sub-floor proof reached the verifier")
_, err = Verify(at, nil)
require.NoError(t, err)
require.NoError(t, sfRun(sfWire(VersionV1, at, nil)))
require.True(t, called)
}
// TestSF_FrameTooShortToStateParametersIsRefused: a proof carrying the
// envelope tag but not the header states no parameters at all, so
// there is nothing to hold to the floor. Refusing is the only
// fail-closed answer — reading past the end would decide the policy on
// whatever memory followed.
func TestSF_FrameTooShortToStateParametersIsRefused(t *testing.T) {
sfClear(t)
called := false
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
called = true
return true, nil
})
full := sfProof(nil)
require.Len(t, full, MinProofLength)
for n := range MinProofLength {
// Spare capacity behind the truncation holds the bytes that
// WOULD have cleared the floor, so a missing bound is visible.
short := full[:n:n]
short = append(bytes.Clone(short), full[n:]...)[:n]
ok, err := Verify(short, nil)
require.False(t, ok, "len=%d", n)
require.Error(t, err, "len=%d", n)
}
require.False(t, called, "a frame too short to state parameters reached the verifier")
require.NoError(t, sfRunErr(full))
}
// sfRunErr is Verify's error for a proof with no public inputs, so the
// control above reads at the same entry point as the sweep.
func sfRunErr(proof []byte) error {
_, err := Verify(proof, nil)
return err
}
// TestSF_FloorRefusalCostsNoVerification pins the ordering that makes
// the floor worth having on this side of the FFI: the refusal is
// decided on the proof's own bytes, before the verifier is looked up,
// so an unregistered node and a registered one answer identically.
func TestSF_FloorRefusalCostsNoVerification(t *testing.T) {
sfClear(t)
weak := sfProofAt(1, 1, nil)
in := sfWire(VersionV1, weak, []byte{0xaa})
require.ErrorIs(t, sfRun(in), ErrInsufficientSoundness,
"with no verifier registered, the floor still decides first")
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
t.Fatal("verifier consulted for a proof below the floor")
return true, nil
})
require.ErrorIs(t, sfRun(in), ErrInsufficientSoundness)
// Gas is a function of calldata length alone, so the refusal is
// not cheaper for the caller — the saving is the node's work.
require.Equal(t,
StarkFRIVerifyPrecompile.RequiredGas(in),
BaseVerifyGas+uint64(len(in))*PerByteGas)
_, rem, err := StarkFRIVerifyPrecompile.Run(nil, common.Address{},
ContractStarkFRIVerifyAddress, in,
StarkFRIVerifyPrecompile.RequiredGas(in)+42, true)
require.ErrorIs(t, err, ErrInsufficientSoundness)
require.Equal(t, uint64(42), rem, "the full gas charge is still taken")
}