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.
214 lines
8.9 KiB
Go
214 lines
8.9 KiB
Go
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
//go:build cgo && starkfri_p3q
|
|
|
|
// verify_cgo.go — the REAL strict-PQ STARK/FRI verifier binding.
|
|
//
|
|
// This file wires the precompile's `RegisterVerifier` seam to the
|
|
// audited Rust verifier crate `p3q-verifier` (a post-quantum-only fork
|
|
// of Plonky3: FRI low-degree test over the Goldilocks 64-bit prime
|
|
// field, cSHAKE256 / KMAC256 / TupleHash256 Merkle commitments, no KZG,
|
|
// no pairings) through its C ABI shim `p3q-c-abi` (single entry point
|
|
// `p3q_verify`). Source: ~/work/lux/p3q.
|
|
//
|
|
// Build
|
|
// -----
|
|
//
|
|
// # 1. build the staticlib (from the p3q workspace root):
|
|
// make -C ../../p3q staticlib # -> target/release/libp3q_c_abi.a
|
|
// # 2. build any consumer of this package with the tag:
|
|
// go build -tags starkfri_p3q ./...
|
|
// go test -tags starkfri_p3q ./...
|
|
//
|
|
// Without the `starkfri_p3q` build tag this file is excluded and the
|
|
// node falls back to the safe-refuse registration (verify_nocgo.go in
|
|
// this package, and the geth-side init in core/vm/lux_precompiles.go).
|
|
// Safe-refuse means: no verifier registered ⇒ the precompile returns
|
|
// ErrVerifierNotRegistered ⇒ no forgery oracle. The verifier is opt-in
|
|
// at build time because it pulls a native Rust staticlib into the link.
|
|
//
|
|
// Wire-format bridge (the load-bearing decision)
|
|
// ----------------------------------------------
|
|
//
|
|
// Two distinct envelopes meet here:
|
|
//
|
|
// - EVM/precompile wire (contract.go): the `proof` argument handed to
|
|
// a VerifierFn begins with the 4-byte MagicHeader "P3Q1"
|
|
// (= 0x50 0x33 0x51 0x31). This tag is preserved verbatim so the
|
|
// EasyCrypt / Jasmin / Lean proof artifacts in proofs/ and jasmin/
|
|
// reference byte-identical on-chain calldata.
|
|
//
|
|
// - p3q-native wire (p3q_verify): the proof begins with a
|
|
// ProofSystemId byte (0x10 = P3Q-SHA3 strict-PQ, 0x11 = P3Q-Keccak),
|
|
// followed by the 10-byte header (see p3q-verifier/src/wire.rs).
|
|
//
|
|
// The bridge: "P3Q1" is the on-chain ENVELOPE tag; the bytes AFTER it
|
|
// are the p3q-native proof. So this adapter cuts the magic off the front
|
|
// and forwards the remainder to p3q_verify. This is exactly the
|
|
// `backend_verifier_axiom` hand-off point in
|
|
// proofs/easycrypt/P3Q_Verifier.ec (the precompile checks the magic and
|
|
// structural envelope; the backend does the cryptographic work). This
|
|
// binding is the concrete discharge of that axiom.
|
|
//
|
|
// Constant-time / soundness
|
|
// -------------------------
|
|
//
|
|
// The proof and public inputs are PUBLIC by EVM-precompile ABI — there
|
|
// is no confidentiality obligation. The verifier's secret-independent
|
|
// arithmetic (Goldilocks field ops, cSHAKE256 sponge) is constant-time
|
|
// by construction in the Rust crate; the CT property we care about is
|
|
// soundness (no timing oracle over the structural pre-filter), which is
|
|
// discharged by the Jasmin gate in jasmin/verify.jazz and measured by
|
|
// the dudect harness in ~/work/lux/crypto/p3q/ct/dudect.
|
|
package starkfri
|
|
|
|
/*
|
|
#cgo CFLAGS: -I${SRCDIR}/../../p3q/crates/p3q-c-abi/include
|
|
#cgo LDFLAGS: ${SRCDIR}/../../p3q/target/release/libp3q_c_abi.a -lm
|
|
#include <stddef.h>
|
|
#include <stdint.h>
|
|
#include "lux/p3q.h"
|
|
*/
|
|
import "C"
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"unsafe"
|
|
)
|
|
|
|
// p3qStatus mirrors the P3Q_* status codes exported by p3q-c-abi's
|
|
// generated header. Kept as typed constants so the switch in the
|
|
// adapter is exhaustive and self-documenting.
|
|
const (
|
|
p3qOK = C.P3Q_OK // 1 — accepted
|
|
p3qRejected = C.P3Q_REJECTED // 0 — ran, rejected
|
|
p3qErrNullPtr = C.P3Q_ERR_NULL_PTR // -1
|
|
p3qErrParse = C.P3Q_ERR_PARSE // -2
|
|
p3qErrUnsupportedProf = C.P3Q_ERR_UNSUPPORTED_PROFILE // -3
|
|
p3qErrMerkle = C.P3Q_ERR_MERKLE // -4
|
|
p3qErrFRI = C.P3Q_ERR_FRI // -5
|
|
p3qErrStark = C.P3Q_ERR_STARK // -6
|
|
p3qErrTranscript = C.P3Q_ERR_TRANSCRIPT // -7
|
|
p3qErrField = C.P3Q_ERR_FIELD // -8
|
|
p3qErrCircuit = C.P3Q_ERR_CIRCUIT // -9
|
|
p3qErrPanic = C.P3Q_ERR_PANIC // -99
|
|
)
|
|
|
|
// Backend error sentinels. These are INTERNAL failures (decode, FFI,
|
|
// panic) surfaced as non-nil err — distinct from a well-formed proof
|
|
// that simply did not verify, which is (false, nil). Run() in
|
|
// contract.go propagates a non-nil err verbatim and maps (false, nil)
|
|
// to ErrInvalidProof, matching the EasyCrypt model
|
|
// (P3Q_Verifier.ec::reject_iff_backend_reject vs the VFailed path).
|
|
var (
|
|
errBackendNullPtr = errors.New("starkfri/p3q: backend received null pointer")
|
|
errBackendParse = errors.New("starkfri/p3q: proof failed to decode")
|
|
errBackendUnsupported = errors.New("starkfri/p3q: unsupported proof-system profile byte")
|
|
errBackendPanic = errors.New("starkfri/p3q: backend panicked (caught at FFI boundary)")
|
|
)
|
|
|
|
// backendSoundnessFloor reports the query-phase soundness, in bits,
|
|
// that the LINKED verifier requires before it will do any work.
|
|
//
|
|
// MinSoundnessBits is the same number stated on this side of the FFI,
|
|
// so the chain's policy is readable in the chain's own code. Two
|
|
// copies of a consensus parameter are only safe while they agree, and
|
|
// this is what lets a test ask. cgo cannot be called from a _test.go
|
|
// file, so the accessor lives here.
|
|
func backendSoundnessFloor() uint32 {
|
|
return uint32(C.p3q_min_soundness_bits())
|
|
}
|
|
|
|
// p3qVerify is the cgo bridge to `p3q_verify`. It performs the genuine
|
|
// FRI low-degree test (Merkle-cap authentication + per-query
|
|
// colinearity folds + final-polynomial check) under a Fiat-Shamir
|
|
// transcript bound to `publicInput`.
|
|
//
|
|
// Returns:
|
|
// - (true, nil) on a verified proof (P3Q_OK)
|
|
// - (false, nil) on a well-formed but invalid proof
|
|
// (P3Q_REJECTED, and every cryptographic-rejection code:
|
|
// Merkle / FRI / STARK / transcript / field / circuit — these mean
|
|
// "this proof does not verify", which the precompile must treat as
|
|
// ErrInvalidProof, NOT an internal error)
|
|
// - (false, err) on a STRUCTURAL/internal failure (null ptr, parse,
|
|
// unsupported profile, panic) — surfaced verbatim by the precompile
|
|
func p3qVerify(p3qProof, publicInput []byte) (bool, error) {
|
|
// p3q_verify accepts a null pointer iff len == 0; mirror that so an
|
|
// empty public input (legal) doesn't trip the null-ptr guard.
|
|
var piPtr *C.uint8_t
|
|
if len(publicInput) > 0 {
|
|
piPtr = (*C.uint8_t)(unsafe.Pointer(&publicInput[0]))
|
|
}
|
|
var prPtr *C.uint8_t
|
|
if len(p3qProof) > 0 {
|
|
prPtr = (*C.uint8_t)(unsafe.Pointer(&p3qProof[0]))
|
|
}
|
|
|
|
rc := C.p3q_verify(
|
|
piPtr, C.uintptr_t(len(publicInput)),
|
|
prPtr, C.uintptr_t(len(p3qProof)),
|
|
)
|
|
|
|
switch rc {
|
|
case p3qOK:
|
|
return true, nil
|
|
case p3qRejected,
|
|
p3qErrMerkle, p3qErrFRI, p3qErrStark,
|
|
p3qErrTranscript, p3qErrField, p3qErrCircuit:
|
|
// All cryptographic rejection paths: the proof is well-formed
|
|
// but does not verify. The precompile maps (false, nil) to
|
|
// ErrInvalidProof.
|
|
return false, nil
|
|
case p3qErrNullPtr:
|
|
return false, errBackendNullPtr
|
|
case p3qErrParse:
|
|
return false, errBackendParse
|
|
case p3qErrUnsupportedProf:
|
|
return false, errBackendUnsupported
|
|
case p3qErrPanic:
|
|
return false, errBackendPanic
|
|
default:
|
|
// Unknown code — fail closed as an internal error. Never accept
|
|
// on an unrecognised status.
|
|
return false, errors.New("starkfri/p3q: unknown backend status code")
|
|
}
|
|
}
|
|
|
|
// p3qVerifierFn is the VerifierFn adapter for the real strict-PQ
|
|
// STARK/FRI backend. It strips the 4-byte "P3Q1" envelope tag (already
|
|
// validated by the precompile's structural gate before this callback
|
|
// runs) and hands the p3q-native proof body to p3qVerify.
|
|
//
|
|
// Exposed as a named func (not an inline closure in init) so tests can
|
|
// re-register it after a RegisterVerifier(nil) — the registration is
|
|
// process-global mutable state, and other tests in the same binary may
|
|
// clear it.
|
|
//
|
|
// version is fixed to VersionV1 by the precompile; it is not part of the
|
|
// p3q-native proof (the p3q wire carries its own version inside the
|
|
// 10-byte header), so it is intentionally unused beyond the
|
|
// envelope-level check the precompile already performed.
|
|
func p3qVerifierFn(_ byte, proof, pubInputs []byte) (bool, error) {
|
|
// The precompile has already checked the envelope tag, but this is also
|
|
// reachable from the in-process Verify() path, so strip the tag rather
|
|
// than assume it: CutPrefix tests and removes in one step, and cannot
|
|
// index past the end whatever it is handed.
|
|
body, ok := bytes.CutPrefix(proof, magic)
|
|
if !ok {
|
|
return false, ErrInvalidProof
|
|
}
|
|
return p3qVerify(body, pubInputs)
|
|
}
|
|
|
|
// init wires the real verifier as the authoritative RegisterVerifier
|
|
// seam. Because Go runs this imported package's init() before any
|
|
// importer's init(), this beats the geth-side RegisterDefaultVerifier
|
|
// (which compare-and-swaps against nil and therefore no-ops once this
|
|
// has registered).
|
|
func init() {
|
|
RegisterVerifier(p3qVerifierFn)
|
|
}
|