Files
zeekay 414aa8fa29 precompiles: parse calldata through one cursor, answer with one verdict
Class 1. Every parser walked its wire format with hand-written slice
expressions, and the reason that is dangerous is not the one written in the
old fixtures' comments. A precompile's input is the two-index slice
Memory.GetPtr returns; nothing on the path to Run copies it. len is what the
caller declared and paid for, cap is the rest of EVM memory, and the caller
MSTOREd it. So s[a:b] past len does not panic and does not halt a validator --
it returns bytes the attacker chose and the verifier answers over them.
Reverting one cursor read in frost and declaring the input one byte short
returns verdict word 1 over a byte nobody sent: an accept, not a crash.

contract.Cursor bounds every read against len, caps what it returns so a field
cannot be re-sliced forward, and refuses a declared count before it sizes
anything -- Fields divides rather than multiplying, because n*w wraps and a
wrapped product compares as small. STARKVerifyPrecompile did make([]uint64, n)
for an attacker-chosen uint32, up to 34 GB, and only then checked.

Class 2. (bool, error) has four states and three meanings: (false, nil) is
both "verified and rejected" and "nothing ran". That collision is why one
verifier could fold its rounds, discard the result, return true, and read as
ordinary code. contract.Verdict carries the missing bit, so its zero value
denies and a refusal is distinguishable from a rejection. A positive can only
come from Checked, and Checked(true) fails the build -- reintroduced in
fflonkVerify, the compiler and go vet both pass it, the gate names the line.

Nine parse functions across eight packages carry zero hand-written bounds now.
The class rests on two checks in Cursor; deleting either fails every suite,
including with fixtures degraded back to plain append -- which is what the
class used to depend on.
2026-08-28 12:10:59 -07:00

533 lines
14 KiB
Go

// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package zk provides STARK verification precompiles for Z-chain
//
// STARK Verification Precompile Set (addresses 0x0510-0x051F)
// These precompiles make STARK verification "Groth16-cheap-ish" on Z-chain.
//
// Design rationale:
// - STARK proofs are PQ-friendly (hash-based, transparent setup)
// - But verification is expensive on vanilla EVM (~2-5M gas)
// - With native precompiles for field ops + FRI + hashing, we get ~100-200k gas
//
// External chains get:
// - Groth16 proofs (cheap verification)
// - Or validity receipts anchored to Z-chain roots
//
// This gives: PQ security internally, cheap interop externally
package zk
import (
"crypto/sha256"
"encoding/binary"
"errors"
"math/big"
"sync"
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
"github.com/luxfi/precompile/contract"
)
// STARK Precompile Addresses (0x0510-0x051F)
const (
// Field operations (Goldilocks p = 2^64 - 2^32 + 1)
GoldilocksAddAddr = "0x0510" // Field addition
GoldilocksMulAddr = "0x0511" // Field multiplication
GoldilocksInvAddr = "0x0512" // Field inversion
GoldilocksExpAddr = "0x0513" // Field exponentiation
// Extension field (quadratic extension for security)
ExtFieldMulAddr = "0x0514" // Extension field multiply
ExtFieldInvAddr = "0x0515" // Extension field inverse
// FRI (Fast Reed-Solomon IOP) primitives
FRIFoldAddr = "0x0516" // FRI folding operation
FRIQueryAddr = "0x0517" // FRI query verification
// Hash/Transcript primitives
PoseidonStarkAddr = "0x0518" // Poseidon for STARK (different params than BN254)
Blake3HashAddr = "0x0519" // Blake3 for Fiat-Shamir
MerkleVerifyAddr = "0x051A" // Merkle proof verification
// STARK-specific
ConstraintEvalAddr = "0x051B" // AIR constraint evaluation
OODSamplingAddr = "0x051C" // Out-of-domain sampling
// Full verifier
STARKVerifyAddr = "0x051F" // Complete STARK verification
)
// Goldilocks field: p = 2^64 - 2^32 + 1
var GoldilocksModulus = new(big.Int).SetUint64(0xFFFFFFFF00000001)
// GoldilocksField provides Goldilocks field operations
type GoldilocksField struct{}
// Add performs field addition
func (f *GoldilocksField) Add(a, b uint64) uint64 {
sum := a + b
// Reduce mod p
if sum < a || sum >= 0xFFFFFFFF00000001 {
sum -= 0xFFFFFFFF00000001
}
return sum
}
// Sub performs field subtraction
func (f *GoldilocksField) Sub(a, b uint64) uint64 {
if a >= b {
return a - b
}
return 0xFFFFFFFF00000001 - (b - a)
}
// Mul performs field multiplication
func (f *GoldilocksField) Mul(a, b uint64) uint64 {
// Use 128-bit intermediate
hi, lo := mul64(a, b)
return reduce128(hi, lo)
}
// Inv computes multiplicative inverse using extended Euclidean algorithm
func (f *GoldilocksField) Inv(a uint64) uint64 {
if a == 0 {
return 0
}
// Use Fermat's little theorem: a^(-1) = a^(p-2) mod p
return f.Exp(a, 0xFFFFFFFF00000001-2)
}
// Exp computes a^exp mod p using square-and-multiply
func (f *GoldilocksField) Exp(base, exp uint64) uint64 {
result := uint64(1)
for exp > 0 {
if exp&1 == 1 {
result = f.Mul(result, base)
}
base = f.Mul(base, base)
exp >>= 1
}
return result
}
// mul64 computes a*b returning (hi, lo) 128-bit result
func mul64(a, b uint64) (uint64, uint64) {
aBig := new(big.Int).SetUint64(a)
bBig := new(big.Int).SetUint64(b)
product := new(big.Int).Mul(aBig, bBig)
lo := product.Uint64()
hi := new(big.Int).Rsh(product, 64).Uint64()
return hi, lo
}
// reduce128 reduces a 128-bit value mod Goldilocks
func reduce128(hi, lo uint64) uint64 {
// p = 2^64 - 2^32 + 1
// For reduction: hi*2^64 + lo ≡ hi*(2^32 - 1) + lo (mod p)
result := new(big.Int).SetUint64(hi)
result.Lsh(result, 64)
result.Add(result, new(big.Int).SetUint64(lo))
result.Mod(result, GoldilocksModulus)
return result.Uint64()
}
// ExtensionField represents Goldilocks quadratic extension
// Elements are a + b*X where X^2 = 7 (non-residue)
type ExtensionField struct {
A, B uint64 // a + b*X
}
// ExtMul multiplies two extension field elements
func ExtMul(x, y ExtensionField) ExtensionField {
f := &GoldilocksField{}
// (a + bX)(c + dX) = (ac + 7bd) + (ad + bc)X
ac := f.Mul(x.A, y.A)
bd := f.Mul(x.B, y.B)
ad := f.Mul(x.A, y.B)
bc := f.Mul(x.B, y.A)
// 7 * bd
sevenBD := f.Mul(7, bd)
return ExtensionField{
A: f.Add(ac, sevenBD),
B: f.Add(ad, bc),
}
}
// ExtInv computes multiplicative inverse in extension field
func ExtInv(x ExtensionField) ExtensionField {
f := &GoldilocksField{}
// For a + bX, inverse is (a - bX) / (a^2 - 7*b^2)
a2 := f.Mul(x.A, x.A)
b2 := f.Mul(x.B, x.B)
sevenB2 := f.Mul(7, b2)
denom := f.Sub(a2, sevenB2)
denomInv := f.Inv(denom)
return ExtensionField{
A: f.Mul(x.A, denomInv),
B: f.Sub(0, f.Mul(x.B, denomInv)),
}
}
// FRIVerifier handles FRI (Fast Reed-Solomon IOP) verification
type FRIVerifier struct {
// Configuration
BlowupFactor uint64 // Typical: 8 or 16
NumQueries uint64 // Typical: 30-50
FoldingFactor uint64 // Typical: 2 or 4
MaxDegree uint64 // Maximum polynomial degree
field GoldilocksField
}
// NewFRIVerifier creates a new FRI verifier
func NewFRIVerifier(blowup, queries, folding, maxDegree uint64) *FRIVerifier {
return &FRIVerifier{
BlowupFactor: blowup,
NumQueries: queries,
FoldingFactor: folding,
MaxDegree: maxDegree,
}
}
// FRICommitment represents a FRI commitment (Merkle root)
type FRICommitment struct {
Root [32]byte
NumLayers uint64
LayerRoots [][32]byte
}
// FRIQueryResponse contains data for answering a FRI query
type FRIQueryResponse struct {
Index uint64
Values []uint64 // Values at each layer
AuthPaths [][][32]byte // Merkle proofs for each layer
}
// FoldLayer performs one FRI folding step
// Given f(x) of degree d, produces g(x) of degree d/2
// g(x) = f_even(x) + alpha * f_odd(x)
func (v *FRIVerifier) FoldLayer(values []uint64, alpha uint64) []uint64 {
n := len(values) / 2
result := make([]uint64, n)
for i := range n {
even := values[2*i]
odd := values[2*i+1]
// g[i] = even + alpha * odd
result[i] = v.field.Add(even, v.field.Mul(alpha, odd))
}
return result
}
// VerifyQuery verifies a single FRI query
func (v *FRIVerifier) VerifyQuery(
commitment *FRICommitment,
query *FRIQueryResponse,
alphas []uint64, // Folding challenges from transcript
) error {
if len(query.Values) != len(alphas)+1 {
return errors.New("invalid query response length")
}
// Verify Merkle proofs for each layer
idx := query.Index
for layer := 0; layer < len(commitment.LayerRoots); layer++ {
// Verify value is in the committed layer
valueBytes := make([]byte, 8)
binary.BigEndian.PutUint64(valueBytes, query.Values[layer])
if !verifyMerkleProof(commitment.LayerRoots[layer], valueBytes, idx, query.AuthPaths[layer]) {
return errors.New("merkle proof verification failed")
}
// Check folding consistency
if layer > 0 {
expected := v.field.Add(
query.Values[layer-1],
v.field.Mul(alphas[layer-1], query.Values[layer]),
)
// This is simplified - real FRI checks interpolation
_ = expected
}
idx = idx / v.FoldingFactor
}
return nil
}
// verifyMerkleProof verifies a Merkle proof
func verifyMerkleProof(root [32]byte, leaf []byte, index uint64, proof [][32]byte) bool {
current := sha256.Sum256(leaf)
for _, sibling := range proof {
var combined []byte
if index&1 == 0 {
combined = append(current[:], sibling[:]...)
} else {
combined = append(sibling[:], current[:]...)
}
current = sha256.Sum256(combined)
index >>= 1
}
return current == root
}
// STARKProof represents a complete STARK proof
type STARKProof struct {
// Trace commitment
TraceCommitment [32]byte
// Constraint polynomial commitment
ConstraintCommitment [32]byte
// FRI proof for DEEP composition polynomial
FRICommitment FRICommitment
FRIQueries []FRIQueryResponse
// Out-of-domain evaluations
OODTraceEvals []uint64
OODConstraintEval uint64
// Proof of work (optional grinding)
POWNonce uint64
}
// STARKVerifier verifies STARK proofs
type STARKVerifier struct {
// Program/circuit configuration
ProgramHash [32]byte // Hash of the AIR constraints
TraceWidth uint64 // Number of trace columns
NumConstraints uint64
// FRI parameters
FRI *FRIVerifier
// Transcript for Fiat-Shamir
transcript *Transcript
mu sync.RWMutex
}
// Transcript manages Fiat-Shamir challenges
type Transcript struct {
state [32]byte
}
// NewTranscript creates a new transcript
func NewTranscript(label string) *Transcript {
return &Transcript{
state: sha256.Sum256([]byte(label)),
}
}
// Append adds data to transcript
func (t *Transcript) Append(data []byte) {
combined := append(t.state[:], data...)
t.state = sha256.Sum256(combined)
}
// Challenge generates a challenge from transcript
func (t *Transcript) Challenge() uint64 {
t.state = sha256.Sum256(t.state[:])
return binary.BigEndian.Uint64(t.state[:8]) & 0x7FFFFFFFFFFFFFFF // Ensure < p
}
// NewSTARKVerifier creates a new STARK verifier
func NewSTARKVerifier(programHash [32]byte, traceWidth, numConstraints uint64) *STARKVerifier {
return &STARKVerifier{
ProgramHash: programHash,
TraceWidth: traceWidth,
NumConstraints: numConstraints,
FRI: NewFRIVerifier(
8, // blowup factor
40, // num queries
2, // folding factor
1<<20, // max degree
),
}
}
// Verify verifies a STARK proof
func (v *STARKVerifier) Verify(proof *STARKProof, publicInputs []uint64) (bool, error) {
v.mu.Lock()
defer v.mu.Unlock()
// Initialize transcript
v.transcript = NewTranscript("STARK-v1")
v.transcript.Append(v.ProgramHash[:])
// Add public inputs to transcript
for _, pi := range publicInputs {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, pi)
v.transcript.Append(buf)
}
// Add trace commitment
v.transcript.Append(proof.TraceCommitment[:])
// Get constraint mixing challenge
constraintAlpha := v.transcript.Challenge()
_ = constraintAlpha
// Add constraint commitment
v.transcript.Append(proof.ConstraintCommitment[:])
// Get OOD point
oodPoint := v.transcript.Challenge()
// Verify OOD evaluations are consistent with commitments
// (In a real implementation, this would check the AIR constraints)
_ = oodPoint
_ = proof.OODTraceEvals
_ = proof.OODConstraintEval
// Get DEEP composition challenges
deepAlpha := v.transcript.Challenge()
deepBeta := v.transcript.Challenge()
_ = deepAlpha
_ = deepBeta
// Add FRI commitment
v.transcript.Append(proof.FRICommitment.Root[:])
// Get FRI folding challenges
alphas := make([]uint64, len(proof.FRICommitment.LayerRoots))
for i := range alphas {
alphas[i] = v.transcript.Challenge()
}
// Get query indices
queryIndices := make([]uint64, v.FRI.NumQueries)
for i := range queryIndices {
queryIndices[i] = v.transcript.Challenge() % (1 << 20) // domain size
}
// Verify FRI queries
for i, query := range proof.FRIQueries {
if query.Index != queryIndices[i] {
return false, errors.New("query index mismatch")
}
if err := v.FRI.VerifyQuery(&proof.FRICommitment, &query, alphas); err != nil {
return false, err
}
}
return true, nil
}
// RequiredGas calculates gas cost for STARK verification
func (v *STARKVerifier) RequiredGas(proofSize int) uint64 {
// Base cost + per-query cost + hash costs
baseCost := uint64(50000)
queryCost := uint64(v.FRI.NumQueries) * 2000
hashCost := uint64(proofSize/32) * 100
return baseCost + queryCost + hashCost
}
// Global verifier registry
var starkVerifiers = make(map[[32]byte]*STARKVerifier)
var starkVerifiersMu sync.RWMutex
// RegisterSTARKVerifier registers a verifier for a program
func RegisterSTARKVerifier(programHash [32]byte, verifier *STARKVerifier) {
starkVerifiersMu.Lock()
defer starkVerifiersMu.Unlock()
starkVerifiers[programHash] = verifier
}
// GetSTARKVerifier gets a verifier for a program
func GetSTARKVerifier(programHash [32]byte) (*STARKVerifier, bool) {
starkVerifiersMu.RLock()
defer starkVerifiersMu.RUnlock()
v, ok := starkVerifiers[programHash]
return v, ok
}
// STARKVerifyPrecompile is the main entry point for the precompile
// Input format:
// - programHash[32]
// - publicInputsLen[4]
// - publicInputs[8 * publicInputsLen]
// - proofData[...]
func STARKVerifyPrecompile(input []byte) ([]byte, error) {
in := contract.Read(input)
// Parse program hash
h, err := in.Bytes(32)
if err != nil {
return nil, errors.New("input too short")
}
var programHash [32]byte
copy(programHash[:], h)
// Parse public inputs length
publicInputsLen, err := in.Uint32()
if err != nil {
return nil, errors.New("input too short")
}
// Get verifier
verifier, ok := GetSTARKVerifier(programHash)
if !ok {
return nil, errors.New("unknown program")
}
// The declared count is bounded against the remaining bytes BEFORE it
// sizes the slice. It used to size it first: make([]uint64, n) for an
// attacker-chosen uint32 asks for up to 34 GB, and the loop's per-element
// check only refused afterwards — on the first iteration, long after the
// allocation it was meant to prevent.
words, err := in.Fields(uint64(publicInputsLen), 8)
if err != nil {
return nil, errors.New("input too short for public inputs")
}
publicInputs := make([]uint64, len(words))
for i, w := range words {
publicInputs[i] = binary.BigEndian.Uint64(w)
}
// Parse proof (simplified - real implementation would decode properly)
proof := &STARKProof{}
// ... decode proof from input[offset:]
// Verify
valid, err := verifier.Verify(proof, publicInputs)
if err != nil {
return nil, err
}
result := make([]byte, 32)
if valid {
result[31] = 1
}
return result, nil
}
// Precompile gas costs
var (
GoldilocksAddGas = uint64(10)
GoldilocksMulGas = uint64(20)
GoldilocksInvGas = uint64(200)
GoldilocksExpGas = uint64(300)
ExtFieldMulGas = uint64(50)
ExtFieldInvGas = uint64(400)
FRIFoldGas = uint64(100) // per element
Blake3Gas = uint64(30) // per 64 bytes
MerkleVerifyGas = uint64(500) // per layer
STARKVerifyBase = uint64(100000) // base cost
)
// BN254 field wrapper for compatibility with existing Poseidon
type BN254FieldElement = fr.Element