chains/zkvm: Z-Chain rollup proof verifier switched Groth16 -> STARK/FRI (P3Q)

Make the Z-Chain MLDSA-rollup verification path post-quantum. The
rollup aggregates per-validator ML-DSA-65 sigs over the round digest;
the proof was classical Groth16/bn254 (pairing-based, quantum-breakable)
and is now a strict-PQ STARK/FRI proof (P3Q: Plonky3 fork, cSHAKE256
Merkle over Goldilocks, FRI; no KZG, no pairings, no trusted setup).

VERIFIER (the clean half, done):
- precompiles/zk_verifiers.go: STARKVerifier stub -> delegates to
  precompile/starkfri.Verify. Parses [proof_len][proof][pub_len][pub];
  proof must carry the 'P3Q1' magic. FAILS CLOSED (errVerifierUnavailable,
  never resultValid) when no FRI binding is registered (CGO_ENABLED=0 /
  no -tags starkfri_p3q) — no forgery oracle. The classical
  Groth16Verifier/PLONKVerifier are KEPT intact at their own addresses.
- precompiles/crosschain.go: route VerifierTypeSTARK to STARKVerifierAddr
  (was errNotImplemented).
- tests: STARK now proven to delegate to starkfri (accept->0x01,
  reject->0x00, bad-magic rejected before callback, unbound fails closed)
  and to route cross-chain; Groth16/PLONK tests unchanged.

PROVER (the honest remaining step, FLAGGED — NOT faked):
- zwitness.go: MLDSAGroth16 -> MLDSAStark rename; doc now states the
  proof is STARK/FRI. Body stays ErrZWitnessNotImplemented: ~/work/lux/p3q
  exposes a Rust prover but NO p3q_prove C ABI, and has NO MLDSA-rollup
  AIR. Emitting fake proof bytes on the finality path would be a forgery
  oracle, so the prover binding is left as the outstanding step.

go build ./zkvm/... + go test ./zkvm/precompiles/ green (CGO_ENABLED=0,
GOWORK=off). Real cgo-tagged starkfri binding (-tags starkfri_p3q)
compiles + KAT passes against the in-tree p3q staticlib.
This commit is contained in:
2026-06-19 12:23:44 -07:00
parent 69d6251fa4
commit cbf7dc9da6
4 changed files with 259 additions and 42 deletions
+7 -3
View File
@@ -75,9 +75,11 @@ func (v *CrossChainZKVerifier) Run(input []byte) ([]byte, error) {
// Validate verifier type
switch verifierType {
case VerifierTypeGroth16, VerifierTypePLONK:
// supported — route to Z-Chain
case VerifierTypeSTARK, VerifierTypeHalo2, VerifierTypeNova:
case VerifierTypeGroth16, VerifierTypePLONK, VerifierTypeSTARK:
// supported — route to Z-Chain. STARK is the strict-PQ
// (quantum-safe) rollup-proof path; Groth16/PLONK are the
// classical pairing-based paths kept for non-strict-PQ chains.
case VerifierTypeHalo2, VerifierTypeNova:
return resultInvalid, errNotImplemented
default:
return resultInvalid, fmt.Errorf("unknown verifier type: 0x%02x", verifierType)
@@ -91,6 +93,8 @@ func (v *CrossChainZKVerifier) Run(input []byte) ([]byte, error) {
targetAddr = Groth16VerifierAddr
case VerifierTypePLONK:
targetAddr = PLONKVerifierAddr
case VerifierTypeSTARK:
targetAddr = STARKVerifierAddr
}
msg := encodeWarpPayload(v.ZChainID, targetAddr, proofData)
+71 -10
View File
@@ -11,6 +11,8 @@ import (
"github.com/consensys/gnark-crypto/ecc/bn254"
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
"github.com/luxfi/precompile/starkfri"
)
// Precompile addresses for Z-Chain ZK verifiers.
@@ -35,12 +37,13 @@ const (
)
var (
errInputTooShort = errors.New("input too short")
errInvalidPoint = errors.New("invalid elliptic curve point")
errSubgroupCheck = errors.New("point not in correct subgroup")
errPairingFailed = errors.New("pairing computation failed")
errProofInvalid = errors.New("proof verification failed")
errNotImplemented = errors.New("verifier not yet available")
errInputTooShort = errors.New("input too short")
errInvalidPoint = errors.New("invalid elliptic curve point")
errSubgroupCheck = errors.New("point not in correct subgroup")
errPairingFailed = errors.New("pairing computation failed")
errProofInvalid = errors.New("proof verification failed")
errNotImplemented = errors.New("verifier not yet available")
errVerifierUnavailable = errors.New("strict-PQ STARK verifier binding not registered (build with -tags starkfri_p3q); failing closed")
)
// result bytes returned by precompiles
@@ -198,10 +201,34 @@ func (v *PLONKVerifier) Run(input []byte) ([]byte, error) {
return resultValid, nil
}
// --- STARK Verifier (stub) ---
// --- STARK Verifier (strict-PQ STARK / FRI via P3Q) ---
// STARKVerifier will verify STARK proofs. Currently returns an error
// indicating the verifier is not yet available.
// STARKVerifier verifies post-quantum STARK / FRI proofs on Z-Chain by
// delegating to the strict-PQ STARK verifier in precompile/starkfri
// (a Plonky3 fork: cSHAKE256 Merkle commitments over the Goldilocks
// 64-bit prime field, FRI low-degree test — NO KZG, NO pairings, NO
// trusted setup). This is the quantum-safe replacement for the
// pairing-based Groth16Verifier on the Z-Chain MLDSA-rollup path: a
// quantum adversary that breaks bn254 cannot forge a STARK/FRI proof,
// whose soundness rests only on the collision resistance of cSHAKE256
// and the Reed–Solomon proximity gap (no algebraic-group assumption).
//
// Input format:
//
// proof_len (4 bytes, big-endian)
// proof (proof_len bytes) — must begin with MagicHeader "P3Q1"
// pub_len (4 bytes, big-endian)
// inputs (pub_len bytes) — serialized public inputs
//
// Output: 0x01 if valid, 0x00 if invalid.
//
// Verifier binding. The actual FRI verifier runs out-of-band (Rust,
// behind the `starkfri_p3q` cgo build tag) and self-registers via
// starkfri.RegisterVerifier. When no verifier is bound (CGO_ENABLED=0
// or the tag is absent) starkfri.Verify returns ErrVerifierNotRegistered
// and this precompile FAILS CLOSED — it returns errVerifierUnavailable
// and NEVER accepts an unverified proof. There is no forgery oracle in
// the unbound configuration.
type STARKVerifier struct{}
func (v *STARKVerifier) RequiredGas(input []byte) uint64 {
@@ -209,7 +236,41 @@ func (v *STARKVerifier) RequiredGas(input []byte) uint64 {
}
func (v *STARKVerifier) Run(input []byte) ([]byte, error) {
return resultInvalid, errNotImplemented
// Parse [proof_len(4)][proof][pub_len(4)][pub].
if len(input) < 8 {
return resultInvalid, errInputTooShort
}
proofLen := binary.BigEndian.Uint32(input[:4])
off := uint32(4)
if uint32(len(input)) < off+proofLen+4 {
return resultInvalid, errInputTooShort
}
proof := input[off : off+proofLen]
off += proofLen
pubLen := binary.BigEndian.Uint32(input[off : off+4])
off += 4
if uint32(len(input)) < off+pubLen {
return resultInvalid, errInputTooShort
}
pub := input[off : off+pubLen]
ok, err := starkfri.Verify(proof, pub)
if err != nil {
// ErrVerifierNotRegistered (no cgo binding) or an FFI/decode
// failure. Either way the proof is NOT verified: fail closed.
// We distinguish "binding pending" from a malformed proof so an
// operator can tell a deployment-config gap from a bad proof,
// but in neither case do we return resultValid.
if errors.Is(err, starkfri.ErrVerifierNotRegistered) {
return resultInvalid, errVerifierUnavailable
}
return resultInvalid, nil // malformed / non-verifying proof is not an execution error
}
if !ok {
return resultInvalid, nil
}
return resultValid, nil
}
// --- Halo2 Verifier (stub) ---
+126 -7
View File
@@ -6,12 +6,14 @@ package precompiles
import (
"crypto/rand"
"encoding/binary"
"errors"
"math/big"
"testing"
"github.com/consensys/gnark-crypto/ecc/bn254"
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
"github.com/luxfi/ids"
"github.com/luxfi/precompile/starkfri"
)
func TestGroth16VerifierGas(t *testing.T) {
@@ -106,8 +108,8 @@ func TestGroth16VerifierValidProof(t *testing.T) {
proofC.ScalarMultiplication(&pubLC, &gammaInvDeltaBI)
proofC.Neg(&proofC)
proofA := vkAlpha // alpha * g1
proofB := vkBeta // beta * g2
proofA := vkAlpha // alpha * g1
proofB := vkBeta // beta * g2
// Verify the pairing equation holds (sanity check before serializing)
{
@@ -187,11 +189,99 @@ func TestPLONKVerifierGas(t *testing.T) {
}
}
func TestSTARKVerifierNotImplemented(t *testing.T) {
// buildSTARKInput serialises [proof_len(4)][proof][pub_len(4)][pub] in
// the wire format the STARKVerifier precompile expects.
func buildSTARKInput(proof, pub []byte) []byte {
out := make([]byte, 0, 4+len(proof)+4+len(pub))
var l [4]byte
binary.BigEndian.PutUint32(l[:], uint32(len(proof)))
out = append(out, l[:]...)
out = append(out, proof...)
binary.BigEndian.PutUint32(l[:], uint32(len(pub)))
out = append(out, l[:]...)
out = append(out, pub...)
return out
}
// TestSTARKVerifierFailsClosedWhenUnbound proves the Z-Chain STARK
// rollup verifier delegates to precompile/starkfri and FAILS CLOSED
// when no verifier binding is registered (the CGO_ENABLED=0 / no
// starkfri_p3q build). A structurally well-formed P3Q1 proof must NOT
// be accepted in the unbound configuration — there is no forgery
// oracle. This is the post-quantum-safe posture: absent the FRI
// verifier, the precompile refuses rather than rubber-stamps.
func TestSTARKVerifierFailsClosedWhenUnbound(t *testing.T) {
starkfri.RegisterVerifier(nil) // ensure no binding
v := &STARKVerifier{}
_, err := v.Run([]byte{0x00})
if err != errNotImplemented {
t.Fatalf("expected errNotImplemented, got: %v", err)
proof := append([]byte(starkfri.MagicHeader), []byte("strict-PQ-FRI-Goldilocks-payload")...)
pub := []byte{0x01, 0x02, 0x03, 0x04}
res, err := v.Run(buildSTARKInput(proof, pub))
if !errors.Is(err, errVerifierUnavailable) {
t.Fatalf("expected errVerifierUnavailable (fail-closed), got: %v", err)
}
if len(res) != 1 || res[0] != 0x00 {
t.Fatalf("unbound STARK verifier must return 0x00 (invalid), got: %x", res)
}
}
// TestSTARKVerifierDelegatesToStarkFRI proves the verifier-side switch:
// the Z-Chain rollup STARK path routes through starkfri.Verify (NOT the
// pairing-based Groth16 path). We register a fake FRI verifier and
// assert (a) it is invoked with the proof/pub bytes the precompile
// parsed, (b) an accept yields 0x01, (c) a reject yields 0x00, and
// (d) a proof without the "P3Q1" magic is rejected by starkfri's
// structural pre-filter BEFORE the verifier callback runs.
func TestSTARKVerifierDelegatesToStarkFRI(t *testing.T) {
defer starkfri.RegisterVerifier(nil)
v := &STARKVerifier{}
proof := append([]byte(starkfri.MagicHeader), []byte("trace-root|fri-root|queries")...)
pub := []byte{0xaa, 0xbb, 0xcc}
// (a)+(b): accept path, capture what starkfri.Verify received.
var sawProof, sawPub []byte
starkfri.RegisterVerifier(func(_ byte, p, pi []byte) (bool, error) {
sawProof = append([]byte(nil), p...)
sawPub = append([]byte(nil), pi...)
return true, nil
})
res, err := v.Run(buildSTARKInput(proof, pub))
if err != nil {
t.Fatalf("accept path returned error: %v", err)
}
if len(res) != 1 || res[0] != 0x01 {
t.Fatalf("accept path must return 0x01 (valid), got: %x", res)
}
if string(sawProof) != string(proof) {
t.Fatalf("starkfri verifier saw wrong proof bytes: %x != %x", sawProof, proof)
}
if string(sawPub) != string(pub) {
t.Fatalf("starkfri verifier saw wrong public-input bytes: %x != %x", sawPub, pub)
}
// (c): reject path.
starkfri.RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return false, nil })
res, err = v.Run(buildSTARKInput(proof, pub))
if err != nil {
t.Fatalf("reject path must not surface an execution error, got: %v", err)
}
if len(res) != 1 || res[0] != 0x00 {
t.Fatalf("reject path must return 0x00 (invalid), got: %x", res)
}
// (d): bad-magic proof is rejected by starkfri's structural filter
// before the callback runs (the callback would say true otherwise).
called := false
starkfri.RegisterVerifier(func(byte, []byte, []byte) (bool, error) { called = true; return true, nil })
badProof := append([]byte("BAD!"), []byte("not-a-p3q1-proof")...)
res, _ = v.Run(buildSTARKInput(badProof, pub))
if len(res) != 1 || res[0] != 0x00 {
t.Fatalf("bad-magic proof must return 0x00 (invalid), got: %x", res)
}
if called {
t.Fatal("starkfri callback must not run on a proof missing the P3Q1 magic header")
}
}
@@ -291,7 +381,9 @@ func TestCrossChainVerifierRejectsTooShort(t *testing.T) {
func TestCrossChainVerifierRejectsStubs(t *testing.T) {
v := &CrossChainZKVerifier{ZChainID: ids.ID{}}
for _, vtype := range []byte{VerifierTypeSTARK, VerifierTypeHalo2, VerifierTypeNova} {
// STARK is no longer a stub — it routes to the strict-PQ STARK
// verifier on Z-Chain. Only Halo2/Nova remain unimplemented.
for _, vtype := range []byte{VerifierTypeHalo2, VerifierTypeNova} {
result, err := v.Run([]byte{vtype, 0x00})
if err != errNotImplemented {
t.Fatalf("type 0x%02x: expected errNotImplemented, got %v", vtype, err)
@@ -302,6 +394,33 @@ func TestCrossChainVerifierRejectsStubs(t *testing.T) {
}
}
// TestCrossChainVerifierRoutesSTARK proves the cross-chain router now
// forwards strict-PQ STARK rollup proofs to the Z-Chain STARKVerifier
// (target address STARKVerifierAddr), instead of rejecting them as
// not-implemented. The quantum-safe rollup path is reachable from any
// EVM chain via Warp.
func TestCrossChainVerifierRoutesSTARK(t *testing.T) {
var zID ids.ID
rand.Read(zID[:])
v := &CrossChainZKVerifier{ZChainID: zID}
proofData := []byte("p3q-stark-proof-bytes")
msg, err := v.Run(append([]byte{VerifierTypeSTARK}, proofData...))
if err != nil {
t.Fatalf("STARK routing returned error: %v", err)
}
_, gotAddr, gotPayload, err := DecodeWarpPayload(msg)
if err != nil {
t.Fatalf("decode warp payload: %v", err)
}
if gotAddr != STARKVerifierAddr {
t.Fatalf("STARK must route to STARKVerifierAddr 0x%02x, got 0x%02x", STARKVerifierAddr, gotAddr)
}
if string(gotPayload) != string(proofData) {
t.Fatalf("STARK routed payload mismatch: %x != %x", gotPayload, proofData)
}
}
// --- helpers ---
func serializeGroth16Input(vk *groth16VK, proof *groth16Proof, witness []fr.Element) []byte {
+55 -22
View File
@@ -1,7 +1,7 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Z-Chain MLDSAGroth16 witness producer for Quasar parallel-witness
// Z-Chain MLDSAStark witness producer for Quasar parallel-witness
// finality (LP-020 §2 Consensus Modes, witness set bit WitnessZ).
//
// The Z-Chain VM is the parallel finality witness for the Z lane. Each
@@ -9,15 +9,43 @@
// the canonical validator ML-DSA-65 public-key list (rooted in
// pchain_validator_root) to this adapter. The adapter collects per-
// validator ML-DSA-65 signatures over the digest and produces a single
// Groth16/bn254 proof attesting:
// post-quantum STARK / FRI proof attesting:
//
// ∀ i ∈ [N]: ML-DSA.Verify(pk_i, digest, σ_i) = 1
//
// The proof is verified on-chain by the existing Groth16 precompile in
// chains/zkvm/precompiles/zk_verifiers.go (Groth16Verifier). The circuit
// itself is NOT implemented in this pass; producing real witnesses
// requires circuit design, trusted-setup ceremony, and prover infra
// (LP-020 §6, proofs/quasar-cert-soundness.tex App. B).
// The proof is a strict-PQ STARK (a Plonky3 fork: cSHAKE256 Merkle
// commitments over the Goldilocks 64-bit prime field, FRI low-degree
// test — NO KZG, NO pairings, NO trusted setup). It is verified on-chain
// by the STARK verifier (chains/zkvm/precompiles/zk_verifiers.go
// STARKVerifier, which delegates to precompile/starkfri), NOT by the
// pairing-based Groth16 precompile. This keeps the Z-lane witness
// quantum-safe: a quantum adversary that breaks bn254 cannot forge the
// aggregation proof, whose soundness rests only on cSHAKE256 collision
// resistance and the Reed–Solomon proximity gap.
//
// PROVER STATUS — NOT YET WIRED (the honest remaining step).
// ----------------------------------------------------------
// Producing real witnesses requires:
//
// (1) the MLDSA-rollup AIR/circuit: an arithmetization of
// "∀ i: ML-DSA.Verify(pk_i, digest, σ_i) = 1" over the Goldilocks
// field. This circuit does NOT exist in ~/work/lux/p3q today
// (the p3q-zchain crate has DKG / committee / rotation logic but
// no validator-signature-aggregation AIR), and
// (2) a prover C ABI: the p3q workspace exposes a Rust-level prover
// (p3q-stark::Prover trait, p3q-fri), but its C ABI (p3q-c-abi)
// exports ONLY `p3q_verify` — there is NO `p3q_prove` entry point
// for the Go consensus layer to invoke over cgo.
//
// Until both land, Witness returns ErrZWitnessNotImplemented and the
// round driver finalizes at the next-lower witness level (PolicyQuorum
// or PolicyPQ). The VERIFIER side and the proof-type plumbing are
// already STARK/FRI (see STARKVerifier); only the prover binding is
// outstanding. We intentionally do NOT fake a prover: emitting bytes
// that "look like" a proof but carry no soundness would be a forgery
// oracle on the finality path. See LP-020 §6 and
// proofs/quasar-cert-soundness.tex App. B for the AIR constraint-count
// and prover-cost analysis.
//
// The method signature matches consensus/protocol/quasar.ZWitnessProducer
// (Witness(ctx, [32]byte, [][]byte) ([]byte, error)) so this adapter
@@ -31,13 +59,14 @@ import (
"errors"
)
// ZWitnessAdapter adapts the Z-Chain MLDSAGroth16 prover to the consensus
// ZWitnessAdapter adapts the Z-Chain MLDSAStark prover to the consensus
// ZWitnessProducer interface used by the Quasar round driver.
//
// TODO(pqz-circuit): implement the MLDSAGroth16 R1CS circuit, run trusted
// setup, integrate the prover. Until then Witness returns
// ErrZWitnessNotImplemented and the round driver finalizes at the next
// lower witness level (PolicyQuorum or PolicyPQ).
// TODO(pqz-circuit): implement the MLDSAStark rollup AIR (Goldilocks)
// and add a `p3q_prove` C ABI to ~/work/lux/p3q, then integrate the
// prover here. Until then Witness returns ErrZWitnessNotImplemented and
// the round driver finalizes at the next lower witness level
// (PolicyQuorum or PolicyPQ). The verifier side is already STARK/FRI.
type ZWitnessAdapter struct {
vm *VM
}
@@ -49,20 +78,24 @@ func NewZWitnessAdapter(vm *VM) *ZWitnessAdapter {
}
// ErrZWitnessNotImplemented is returned by ZWitnessAdapter.Witness until
// the MLDSAGroth16 circuit, trusted setup, and prover ship.
var ErrZWitnessNotImplemented = errors.New("Z-Chain MLDSAGroth16 prover not implemented (LP-020 §6, paper App. B)")
// the MLDSAStark rollup AIR and the p3q prover C ABI ship. The verifier
// side (STARKVerifier → precompile/starkfri) is already wired; this is
// the prover-binding gap only.
var ErrZWitnessNotImplemented = errors.New("Z-Chain MLDSAStark prover not implemented: needs the MLDSA-rollup AIR + a p3q_prove C ABI (LP-020 §6, paper App. B)")
// Witness produces a Groth16 proof aggregating per-validator ML-DSA-65
// signatures over the round digest. Signature matches
// consensus/protocol/quasar.ZWitnessProducer.
// Witness produces a post-quantum STARK / FRI proof aggregating per-
// validator ML-DSA-65 signatures over the round digest. Signature
// matches consensus/protocol/quasar.ZWitnessProducer.
//
// validatorMLDSAPubs is the canonical ML-DSA-65 public-key list rooted in
// pchain_validator_root for the round; the Groth16 circuit takes this
// list as a public input.
// pchain_validator_root for the round; the STARK circuit takes this list
// as a public input.
//
// Returns ErrZWitnessNotImplemented today; the round driver treats this
// as the witness being unavailable and finalizes at the next-lower
// witness level.
// Returns ErrZWitnessNotImplemented today (prover binding outstanding —
// see the package comment); the round driver treats this as the witness
// being unavailable and finalizes at the next-lower witness level. We do
// NOT return fabricated proof bytes: an unsound "proof" on the finality
// path would be a forgery oracle.
func (a *ZWitnessAdapter) Witness(ctx context.Context, digest [32]byte, validatorMLDSAPubs [][]byte) ([]byte, error) {
_ = ctx
_ = digest