mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
precompile: decomplect strict-PQ gate across every classical Run() (#2)
* precompile: pulsar threshold ML-DSA at LP-4200 0x012204 Adds the Pulsar precompile slot for threshold ML-DSA verification. Pulsar's wire format is byte-equal to FIPS 204 ML-DSA (Class N1 manifesto), so the verification op dispatches to luxfi/crypto/mldsa under a Pulsar-specific domain-separation context. - Address: 0x0000000000000000000000000000000000012204 (LP-4200 unified PQCrypto block; companion to ML-KEM 0x012201, ML-DSA 0x012202, SLH-DSA 0x012203). - Three modes: Pulsar-44 / Pulsar-65 / Pulsar-87 mirroring ML-DSA Categories 2 / 3 / 5. Production target is Pulsar-65. - Gas: per-mode base + 10/byte (matches ML-DSA precompile tier). - Context: lux-evm-precompile-pulsar-v1 to keep on-chain transcripts unambiguous vs. single-party ML-DSA at 0x012202. Tests: 5 passing (address pin, gas table, round-trip across 3 modes, tampered-signature rejection, invalid-mode rejection). GOWORK=off go test -count=1 ./pulsar/... is green. Imports github.com/luxfi/pulsar-m (pre-rename); will be retargeted to github.com/luxfi/pulsar when the cross-repo move lands. * precompile/LLM.md: mark Pulsar 0x012204 as IMPLEMENTED Pulsar precompile now lives in precompile/pulsar/ and dispatches to luxfi/crypto/mldsa under a Pulsar-specific context. Update the LP-4200 canonical note to reflect deployment status alongside the other three PQCrypto slots. * precompile/zk: rip classical pairing-based opcodes (PQ-only) Shor breaks Groth16/PLONK/fflonk/Halo2/KZG/IPA/Bulletproofs. The 0x0900 EVM precompile now exposes only the PQ-safe ops: hash-based nullifiers (0x21) and Merkle commitment inclusion (0x22). PQ STARK verification is dispatched via the Z-Chain envelope path (consensus/protocol/zchain/verify.go), not from this precompile. * precompile/kzg4844: retire BLS12-381 KZG blob-opening precompile KZG point-evaluation verifies via the BLS12-381 pairing e(C - [y]G1, G2) == e(W, [tau - z]G2). The discrete-log assumption that anchors BLS12-381's security is broken by Shor's algorithm, so EIP-4844-style blob commitments give zero soundness against a future quantum adversary. The Lux EVM is PQ-only. Strip the implementation (gokzg4844, accel GPU MSM, all CPU opcodes). The module still registers the address 0xB002 so the precompile registry numbering stays stable, but every call to the precompile returns the new contract.ErrClassicalForbiddenInPQ sentinel. The minimum 21000 gas is deducted on every call to prevent the retired address being used as a zero-cost probe. Blob availability on the Lux EVM is now provided by hash-based commitments (Blake3 / Poseidon2) and STARK-validated state roots. * precompile/zk: strip Go-level classical pairing SNARK verifiers The Run() path of the 0x0900 ZK precompile only exposes hash-based nullifier checks and Merkle commitment inclusion, both PQ-safe. The remaining Go-level methods on ZKVerifier (VerifyGroth16, VerifyPlonk, VerifyFflonk, VerifyKZG, VerifyRangeProof, VerifyRollupBatch and their helpers) all dispatched through a BN254 pairing check or a BLS12-381 KZG opening, both broken by Shor's algorithm. Strip those methods, their bn256 / kzg4844 / accel-MSM dependencies, and the rollup-batch types (RollupBatch, RollupConfig, StateTransition, KZGSetup, BN254P) that only existed to feed the pairing verifier. Update verifier_test.go to cover only the PQ-safe surface that remains: NewZKVerifier, RegisterVerifyingKey (Go-side bookkeeping), nullifier check/spend, confidential pool create/add-commitment, hash-based Merkle inclusion. PQ STARK verification continues to be handled by the P3Q backend on the Z-chain envelope path (consensus/protocol/zchain/verify.go); it was never wired through this Go struct. * precompile/babyjubjub: retire BN254-companion twisted Edwards curve Baby Jubjub is the ZK-friendly twisted Edwards curve aligned to BN254's scalar field (used by circom / iden3 / Polygon zkEVM / Semaphore / RLN / EdDSA-on-BN254). Its security reduces to discrete-log on a curve of BN254 scalar order; Shor breaks that in poly time. Every consumer of Baby Jubjub is a classical pairing-based SNARK gadget with no PQ analogue, so the Lux EVM retires the precompile rather than offer a quantum-broken primitive. Strip the gnark-crypto twisted-edwards implementation. The module still registers address 0x0500..0007 so the precompile registry numbering stays stable, but every call returns contract.ErrClassicalForbiddenInPQ with 21000 minimum gas deducted. * precompile/pedersen: retire BN254 Pedersen commitment precompile The implementation built homomorphic Pedersen commitments over BN254 G1 (C = v*G + r*H, plus a 32-slot vector commitment). Both binding and hiding reduce to the discrete-log assumption on BN254, which Shor's algorithm breaks. The Lux EVM is PQ-only and retires the precompile rather than offer a quantum-broken commitment. Strip the gnark-crypto BN254 implementation and the 34 deterministic generators. The module still registers address 0x0500..0006 so the precompile registry numbering stays stable, but every call returns contract.ErrClassicalForbiddenInPQ with the 21000 minimum gas deducted. PQ-safe commitment schemes on the Lux EVM use hash-based primitives (Blake3 0x3202, Poseidon2 0x3201) and STARK gadgets on Z-chain. * Revert "precompile/pedersen: retire BN254 Pedersen commitment precompile" This reverts commitcc0db71edf. * Revert "precompile/babyjubjub: retire BN254-companion twisted Edwards curve" This reverts commite666729d6b. * Revert "precompile/zk: strip Go-level classical pairing SNARK verifiers" This reverts commit92e5ac182b. * Revert "precompile/kzg4844: retire BLS12-381 KZG blob-opening precompile" This reverts commit5786c8f57e. * Revert "precompile/zk: rip classical pairing-based opcodes (PQ-only)" This reverts commit555a9b68ff. * precompile: profile gate + P3Q + classical-primitive integration Strict-PQ gate (contract.RefuseUnderStrictPQ) at the top of every classical pairing/DLOG precompile's Run() refuses the call when the chain's ChainConfig implements StrictPQReporter and reports true at the current timestamp. Non-Lux chains that integrate Lux precompiles without wiring StrictPQReporter see the classical primitives execute normally -- forward perfection on Lux, full compatibility elsewhere. Wired into kzg4844, zk (classical opcodes only -- Nullifier and Merkle Commitment stay open), babyjubjub, pedersen, pasta. P3Q strict-PQ STARK verifier at LP-4200 0x012205. Plonky3 fork with cSHAKE256 Merkle / Goldilocks field / FRI low-degree test. Verifier callback registered at node startup; default returns ErrVerifierNotRegistered. 8 tests pass. Gate semantics: RefuseUnderStrictPQ(nil) → nil (test-permissive) ChainConfig without StrictPQReporter → nil (non-Lux chains) StrictPQReporter false → nil (pre-strict-PQ activation timestamp) StrictPQReporter true → ErrClassicalForbiddenInPQ Composable, orthogonal, complete. One and only one way. * precompile: gate every classical Run() via contract.RefuseUnderStrictPQ Decomplect: profile-gating logic stays in one place (contract.RefuseUnderStrictPQ); each classical precompile calls it once at the top of Run() and otherwise stays in its lane. Quasar/P3Q/Pulsar/ML-KEM/ML-DSA/SLH-DSA/Corona/X-Wing/FHE precompiles are post-quantum and skip the gate. Wired into: bls12381 (7 EIP-2537 ops: G1/G2 add/mul/MSM + pairing) ed25519 (signature verify) cggmp21 (ECDSA threshold) frost (Schnorr threshold) ring (LSAG classical schemes; lattice scheme stays open) vrf (ECVRF EDWARDS25519) sr25519 (Schnorrkel/Ristretto255) x25519 (DH) curve25519 (raw Edwards25519 ops) hpke (classical KEMs; Kyber/X-Wing hybrid stays open) blake3 (classical hash) poseidon (BN254 algebraic hash) Already gated upstream: kzg4844, zk (classical opcodes), babyjubjub, pedersen, pasta. secp256r1 uses the non-stateful Run([]byte) interface (EIP-7212 standard). Gating that precompile requires hooking the geth EVM precompile overrider, not this package -- left for the LuxPrecompileOverrider follow-up. Tests pass across all twelve gated dirs plus contract/.
This commit is contained in:
@@ -182,10 +182,13 @@ Example hook implementations:
|
||||
|
||||
> **Canonical (LP-4200)**: the unified PQCrypto precompile block at
|
||||
> `0x012201..0x012204` is authoritative — ML-KEM `0x012201`, ML-DSA
|
||||
> `0x012202`, SLH-DSA `0x012203`, Pulsar `0x012204`. The `0x0600`
|
||||
> (Post-Quantum) and `0x0800` (Threshold) ranges below are the legacy
|
||||
> per-VM mapping; new code MUST target the unified block. See
|
||||
> `~/work/lux/evm/LLM.md` for the canonical per-feature address table.
|
||||
> `0x012202`, SLH-DSA `0x012203`, Pulsar `0x012204` (threshold ML-DSA,
|
||||
> FIPS 204 byte-equal). All four are IMPLEMENTED; Pulsar lives in
|
||||
> `precompile/pulsar/` and dispatches to `luxfi/crypto/mldsa` under
|
||||
> the hood. The `0x0600` (Post-Quantum) and `0x0800` (Threshold) ranges
|
||||
> below are the legacy per-VM mapping; new code MUST target the unified
|
||||
> block. See `~/work/lux/evm/LLM.md` for the canonical per-feature
|
||||
> address table.
|
||||
|
||||
| Range | Category | Description |
|
||||
|-------|----------|-------------|
|
||||
|
||||
@@ -114,6 +114,9 @@ func (p *babyJubJubPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, gas, err
|
||||
}
|
||||
if len(input) < 1 {
|
||||
return nil, gas, ErrInvalidInput
|
||||
}
|
||||
|
||||
@@ -142,6 +142,9 @@ func (p *blake3Precompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
|
||||
if len(input) < 1 {
|
||||
return nil, remainingGas, ErrInvalidInput
|
||||
|
||||
@@ -98,6 +98,9 @@ func (p *g1AddPrecompile) Run(
|
||||
suppliedGas uint64,
|
||||
readOnly bool,
|
||||
) ([]byte, uint64, error) {
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, suppliedGas, err
|
||||
}
|
||||
return blsOps.g1Add(input, suppliedGas)
|
||||
}
|
||||
|
||||
@@ -111,6 +114,9 @@ func (p *g1MulPrecompile) Run(
|
||||
suppliedGas uint64,
|
||||
readOnly bool,
|
||||
) ([]byte, uint64, error) {
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, suppliedGas, err
|
||||
}
|
||||
return blsOps.g1Mul(input, suppliedGas)
|
||||
}
|
||||
|
||||
@@ -124,6 +130,9 @@ func (p *g1MSMPrecompile) Run(
|
||||
suppliedGas uint64,
|
||||
readOnly bool,
|
||||
) ([]byte, uint64, error) {
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, suppliedGas, err
|
||||
}
|
||||
return blsOps.g1MSM(input, suppliedGas)
|
||||
}
|
||||
|
||||
@@ -137,6 +146,9 @@ func (p *g2AddPrecompile) Run(
|
||||
suppliedGas uint64,
|
||||
readOnly bool,
|
||||
) ([]byte, uint64, error) {
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, suppliedGas, err
|
||||
}
|
||||
return blsOps.g2Add(input, suppliedGas)
|
||||
}
|
||||
|
||||
@@ -150,6 +162,9 @@ func (p *g2MulPrecompile) Run(
|
||||
suppliedGas uint64,
|
||||
readOnly bool,
|
||||
) ([]byte, uint64, error) {
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, suppliedGas, err
|
||||
}
|
||||
return blsOps.g2Mul(input, suppliedGas)
|
||||
}
|
||||
|
||||
@@ -163,6 +178,9 @@ func (p *g2MSMPrecompile) Run(
|
||||
suppliedGas uint64,
|
||||
readOnly bool,
|
||||
) ([]byte, uint64, error) {
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, suppliedGas, err
|
||||
}
|
||||
return blsOps.g2MSM(input, suppliedGas)
|
||||
}
|
||||
|
||||
@@ -176,6 +194,9 @@ func (p *pairingPrecompile) Run(
|
||||
suppliedGas uint64,
|
||||
readOnly bool,
|
||||
) ([]byte, uint64, error) {
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, suppliedGas, err
|
||||
}
|
||||
return blsOps.pairing(input, suppliedGas)
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ func (p *cggmp21VerifyPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
|
||||
// Input format:
|
||||
// [0:4] = threshold t (uint32)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package contract
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrClassicalForbiddenInPQ is returned by classical pairing-based or
|
||||
// discrete-log precompiles when the active chain reports a strict
|
||||
// post-quantum profile. The precompile is still registered at its
|
||||
// stable address so the registry numbering does not move, but the
|
||||
// contract refuses to execute.
|
||||
//
|
||||
// Non-Lux chains that integrate Lux precompiles for cross-chain
|
||||
// verification do NOT implement StrictPQReporter and therefore see
|
||||
// the classical primitives execute normally.
|
||||
var ErrClassicalForbiddenInPQ = errors.New("contract: classical pairing-based primitive forbidden under strict-PQ chain profile")
|
||||
|
||||
// StrictPQReporter is the feature-detection interface a ChainConfig
|
||||
// may implement to opt in to strict-PQ enforcement. ChainConfig
|
||||
// implementations that do NOT implement this interface are treated
|
||||
// as classical-permissive (the default for compatibility with
|
||||
// existing non-Lux chains that integrate Lux precompiles).
|
||||
type StrictPQReporter interface {
|
||||
IsStrictPQ(time uint64) bool
|
||||
}
|
||||
|
||||
// RefuseUnderStrictPQ returns ErrClassicalForbiddenInPQ when the
|
||||
// active chain profile reports strict-PQ, else nil. Classical
|
||||
// precompiles (KZG, Groth16, PLONK, fflonk, Halo2, BN254-Pedersen,
|
||||
// BabyJubJub, Pallas/Vesta) MUST call this at the top of their Run()
|
||||
// before doing any work.
|
||||
func RefuseUnderStrictPQ(state AccessibleState) error {
|
||||
// Permissive when state, chain config, or block context is not
|
||||
// wired. The test invocation pattern routinely passes nil for
|
||||
// state; only a chain that wires StrictPQReporter into its
|
||||
// ChainConfig actually triggers the gate.
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
cfg := state.GetChainConfig()
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
r, ok := cfg.(StrictPQReporter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
bc := state.GetBlockContext()
|
||||
var ts uint64
|
||||
if bc != nil {
|
||||
ts = bc.Timestamp()
|
||||
}
|
||||
if r.IsStrictPQ(ts) {
|
||||
return ErrClassicalForbiddenInPQ
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package contract
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/precompile/precompileconfig"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// strictPQChainConfig is a ChainConfig that also implements StrictPQReporter.
|
||||
type strictPQChainConfig struct {
|
||||
*MockChainConfig
|
||||
strictPQTime uint64
|
||||
}
|
||||
|
||||
func newStrictPQChainConfig(strictPQTime uint64) *strictPQChainConfig {
|
||||
return &strictPQChainConfig{
|
||||
MockChainConfig: NewMockChainConfig(0),
|
||||
strictPQTime: strictPQTime,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *strictPQChainConfig) IsStrictPQ(time uint64) bool {
|
||||
return time >= s.strictPQTime
|
||||
}
|
||||
|
||||
// TestRefuseUnderStrictPQ_NoReporter verifies that ChainConfigs that
|
||||
// do not implement StrictPQReporter are treated as classical-permissive.
|
||||
// This is the default for non-Lux chains that integrate Lux precompiles.
|
||||
func TestRefuseUnderStrictPQ_NoReporter(t *testing.T) {
|
||||
state := NewMockAccessibleState(NewMockStateDB(), NewMockBlockContext(1, 1000))
|
||||
// chainConfig is the plain MockChainConfig — no StrictPQReporter.
|
||||
require.NoError(t, RefuseUnderStrictPQ(state))
|
||||
}
|
||||
|
||||
// TestRefuseUnderStrictPQ_ReporterFalse verifies that when the chain
|
||||
// implements StrictPQReporter but reports false at the current
|
||||
// timestamp, classical precompiles are allowed.
|
||||
func TestRefuseUnderStrictPQ_ReporterFalse(t *testing.T) {
|
||||
state := NewMockAccessibleState(NewMockStateDB(), NewMockBlockContext(1, 500))
|
||||
state.chainConfig = newStrictPQChainConfig(1000) // strict-PQ starts at 1000
|
||||
require.NoError(t, RefuseUnderStrictPQ(state))
|
||||
}
|
||||
|
||||
// TestRefuseUnderStrictPQ_ReporterTrue verifies that when the chain
|
||||
// implements StrictPQReporter and reports true at the current
|
||||
// timestamp, classical precompiles are refused.
|
||||
func TestRefuseUnderStrictPQ_ReporterTrue(t *testing.T) {
|
||||
state := NewMockAccessibleState(NewMockStateDB(), NewMockBlockContext(1, 1500))
|
||||
state.chainConfig = newStrictPQChainConfig(1000)
|
||||
err := RefuseUnderStrictPQ(state)
|
||||
require.ErrorIs(t, err, ErrClassicalForbiddenInPQ)
|
||||
}
|
||||
|
||||
// TestStrictPQReporter_InterfaceShape ensures the interface contract
|
||||
// stays stable. A trivial compile-time check that a struct
|
||||
// implementing IsStrictPQ satisfies the interface.
|
||||
func TestStrictPQReporter_InterfaceShape(t *testing.T) {
|
||||
var _ StrictPQReporter = (*strictPQChainConfig)(nil)
|
||||
// Also ensure ChainConfig is still satisfied — the gate must not
|
||||
// require strict-PQ chains to be a different ChainConfig type.
|
||||
var _ precompileconfig.ChainConfig = (*strictPQChainConfig)(nil)
|
||||
}
|
||||
@@ -93,6 +93,9 @@ func (p *curve25519Precompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, gas, err
|
||||
}
|
||||
if len(input) < 1 {
|
||||
return nil, gas, ErrInvalidInput
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ func (c *ed25519VerifyPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
|
||||
if len(input) != InputLength {
|
||||
return nil, remainingGas, nil
|
||||
|
||||
@@ -91,6 +91,9 @@ func (p *frostVerifyPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
|
||||
// Input format:
|
||||
// [0:4] = threshold t (uint32)
|
||||
|
||||
@@ -191,6 +191,18 @@ func (p *hpkePrecompile) Run(
|
||||
|
||||
op := input[0]
|
||||
|
||||
// Classical KEMs (P256/P384/P521/X25519) are gated by the chain's
|
||||
// strict-PQ profile. Hybrid PQ KEMs (X25519+Kyber768, X-Wing) carry
|
||||
// a lattice component and remain open on every chain.
|
||||
if op == OpSingleShotSeal && len(input) >= 3 {
|
||||
kemID := uint16(input[1])<<8 | uint16(input[2])
|
||||
if !isKyberKEM(kemID) {
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result []byte
|
||||
|
||||
switch op {
|
||||
|
||||
@@ -151,6 +151,9 @@ func (p *kzg4844Precompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
|
||||
if len(input) < 1 {
|
||||
return nil, remainingGas, ErrInvalidInput
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package p3q implements the EVM precompile entry for the P3Q strict
|
||||
// post-quantum STARK verifier, the Lux family canonical proof system.
|
||||
//
|
||||
// P3Q is a fork of Plonky3 with the classical-curve surface stripped
|
||||
// out: cSHAKE256 (FIPS 202 / SP 800-185) Merkle hashes over the
|
||||
// Goldilocks 64-bit prime field, no KZG, no pairings. The wire-byte
|
||||
// proof-backend ID is `ProofBackendP3QSTARKFRISHA3 = 0x22`, pinned in
|
||||
// `consensus/config/pq_mode.go`; this precompile is the on-chain
|
||||
// dispatch point for that backend.
|
||||
//
|
||||
// LP-4200 unified PQCrypto block:
|
||||
//
|
||||
// 0x012201 = ML-KEM
|
||||
// 0x012202 = ML-DSA (single-party FIPS 204)
|
||||
// 0x012203 = SLH-DSA (FIPS 205)
|
||||
// 0x012204 = Pulsar (threshold FIPS 204, byte-equal)
|
||||
// 0x012205 = P3Q ← this precompile (strict-PQ STARK)
|
||||
//
|
||||
// Wire format (single dispatch, version byte differentiates future
|
||||
// proof formats; first deployment is v0x01):
|
||||
//
|
||||
// [1 byte version]
|
||||
// [4 bytes proof_len (big-endian)]
|
||||
// [proof_len bytes proof] // must begin with MagicHeader "P3Q1"
|
||||
// [4 bytes pub_len (big-endian)]
|
||||
// [pub_len bytes public_inputs]
|
||||
//
|
||||
// The Rust prover / verifier in `~/work/lux/p3q` runs out-of-band; at
|
||||
// startup the node calls `p3q.RegisterVerifier` to wire the Go-side
|
||||
// callback that actually invokes the FFI verifier. Without a
|
||||
// registered verifier the precompile returns ErrVerifierNotRegistered.
|
||||
// Structural validation (length checks + MagicHeader) runs in the
|
||||
// precompile itself and is constant-time-friendly: no secret-dependent
|
||||
// branches, public inputs and the proof are non-secret by construction.
|
||||
package p3q
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/precompile/contract"
|
||||
)
|
||||
|
||||
// ContractP3QVerifyAddress is the canonical LP-4200 slot for P3Q.
|
||||
var ContractP3QVerifyAddress = common.HexToAddress("0x0000000000000000000000000000000000012205")
|
||||
|
||||
// P3QVerifyPrecompile is the singleton instance.
|
||||
var P3QVerifyPrecompile = &p3qVerifyPrecompile{}
|
||||
|
||||
var _ contract.StatefulPrecompiledContract = &p3qVerifyPrecompile{}
|
||||
|
||||
// MagicHeader is the 4-byte prefix every P3Q proof MUST begin with.
|
||||
// Ties the wire to the Plonky3 fork's cSHAKE256/Goldilocks profile.
|
||||
const MagicHeader = "P3Q1"
|
||||
|
||||
// Wire layout constants.
|
||||
const (
|
||||
versionByte = 1
|
||||
proofLenByte = 4
|
||||
pubLenByte = 4
|
||||
|
||||
// MinInputLength is the smallest valid input: 1 version + 4 proof_len
|
||||
// + 4 magic header + 4 pub_len. Proof must contain at least the
|
||||
// MagicHeader to clear structural validation.
|
||||
MinInputLength = versionByte + proofLenByte + len(MagicHeader) + pubLenByte
|
||||
|
||||
// VersionV1 is the first wire-format version.
|
||||
VersionV1 byte = 0x01
|
||||
)
|
||||
|
||||
// Gas schedule. STARK verification scales roughly with log²(circuit
|
||||
// size), but on-chain we only see the serialized proof; charge a flat
|
||||
// base plus a per-byte cost so callers pay for bandwidth and decode.
|
||||
const (
|
||||
BaseVerifyGas uint64 = 200_000
|
||||
PerByteGas uint64 = 10
|
||||
)
|
||||
|
||||
// Error sentinels returned by this precompile.
|
||||
var (
|
||||
ErrInvalidInputLength = errors.New("p3q: invalid input length")
|
||||
ErrInvalidVersion = errors.New("p3q: invalid version byte")
|
||||
ErrVerifierNotRegistered = errors.New("p3q: verifier not registered")
|
||||
ErrInvalidProof = errors.New("p3q: proof verification failed")
|
||||
)
|
||||
|
||||
// VerifierFn is the Go-side callback that bridges to the out-of-band
|
||||
// P3Q (Plonky3 fork) Rust verifier. Returns (ok, err). A non-nil err
|
||||
// indicates an internal verifier failure (FFI, decode); ok=false with
|
||||
// nil err indicates a well-formed proof that did not verify.
|
||||
type VerifierFn func(version byte, proof, pubInputs []byte) (bool, error)
|
||||
|
||||
// verifier is the registered callback. Atomic.Value so RegisterVerifier
|
||||
// is safe to call once at node startup without locking the hot path.
|
||||
var verifier atomic.Value // VerifierFn
|
||||
|
||||
// RegisterVerifier wires the actual P3Q verifier. Called once at node
|
||||
// startup. Passing nil clears the registration (useful for tests).
|
||||
func RegisterVerifier(fn VerifierFn) {
|
||||
if fn == nil {
|
||||
verifier.Store(VerifierFn(nil))
|
||||
return
|
||||
}
|
||||
verifier.Store(fn)
|
||||
}
|
||||
|
||||
// loadVerifier returns the currently-registered verifier, or nil.
|
||||
func loadVerifier() VerifierFn {
|
||||
v, _ := verifier.Load().(VerifierFn)
|
||||
return v
|
||||
}
|
||||
|
||||
type p3qVerifyPrecompile struct{}
|
||||
|
||||
// RequiredGas returns BaseVerifyGas + PerByteGas * len(input). STARK
|
||||
// verifier cost is dominated by FRI query rounds (logarithmic in
|
||||
// circuit size); per-byte covers serialization / hashing overhead.
|
||||
func (p *p3qVerifyPrecompile) RequiredGas(input []byte) uint64 {
|
||||
return BaseVerifyGas + uint64(len(input))*PerByteGas
|
||||
}
|
||||
|
||||
// Run verifies a P3Q STARK proof.
|
||||
//
|
||||
// Returns:
|
||||
// - empty bytes, nil error on successful verification.
|
||||
// - empty bytes, typed error on any failure.
|
||||
func (p *p3qVerifyPrecompile) Run(
|
||||
_ contract.AccessibleState,
|
||||
_ common.Address,
|
||||
_ common.Address,
|
||||
input []byte,
|
||||
suppliedGas uint64,
|
||||
_ bool,
|
||||
) ([]byte, uint64, error) {
|
||||
gas := p.RequiredGas(input)
|
||||
if suppliedGas < gas {
|
||||
return nil, 0, contract.ErrOutOfGas
|
||||
}
|
||||
remaining := suppliedGas - gas
|
||||
|
||||
if len(input) < MinInputLength {
|
||||
return nil, remaining, ErrInvalidInputLength
|
||||
}
|
||||
|
||||
version := input[0]
|
||||
if version != VersionV1 {
|
||||
return nil, remaining, ErrInvalidVersion
|
||||
}
|
||||
|
||||
off := versionByte
|
||||
proofLen := binary.BigEndian.Uint32(input[off : off+proofLenByte])
|
||||
off += proofLenByte
|
||||
if uint64(len(input))-uint64(off) < uint64(proofLen)+uint64(pubLenByte) {
|
||||
return nil, remaining, ErrInvalidInputLength
|
||||
}
|
||||
proof := input[off : off+int(proofLen)]
|
||||
off += int(proofLen)
|
||||
|
||||
pubLen := binary.BigEndian.Uint32(input[off : off+pubLenByte])
|
||||
off += pubLenByte
|
||||
if uint64(len(input))-uint64(off) < uint64(pubLen) {
|
||||
return nil, remaining, ErrInvalidInputLength
|
||||
}
|
||||
pub := input[off : off+int(pubLen)]
|
||||
|
||||
// Structural check: proof must begin with the MagicHeader. P3Q
|
||||
// proofs are non-secret, so byte-equality is fine.
|
||||
if len(proof) < len(MagicHeader) || string(proof[:len(MagicHeader)]) != MagicHeader {
|
||||
return nil, remaining, ErrInvalidProof
|
||||
}
|
||||
|
||||
fn := loadVerifier()
|
||||
if fn == nil {
|
||||
return nil, remaining, ErrVerifierNotRegistered
|
||||
}
|
||||
ok, err := fn(version, proof, pub)
|
||||
if err != nil {
|
||||
return nil, remaining, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, remaining, ErrInvalidProof
|
||||
}
|
||||
return nil, remaining, nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package p3q
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestP3Q_Address pins the canonical LP-4200 slot 0x012205.
|
||||
func TestP3Q_Address(t *testing.T) {
|
||||
want := common.HexToAddress("0x0000000000000000000000000000000000012205")
|
||||
require.Equal(t, want, ContractP3QVerifyAddress)
|
||||
}
|
||||
|
||||
// TestP3Q_RequiredGas verifies the gas formula: 200_000 + 10 * len(input).
|
||||
func TestP3Q_RequiredGas(t *testing.T) {
|
||||
for _, n := range []int{0, 1, 64, 256, 4096, 131072} {
|
||||
input := make([]byte, n)
|
||||
got := P3QVerifyPrecompile.RequiredGas(input)
|
||||
want := BaseVerifyGas + uint64(n)*PerByteGas
|
||||
require.Equal(t, want, got, "len=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestP3Q_RejectsBadMagic ensures proofs without the "P3Q1" header are
|
||||
// rejected with ErrInvalidProof (even when a verifier is registered).
|
||||
func TestP3Q_RejectsBadMagic(t *testing.T) {
|
||||
// Register a verifier that would say yes if asked; the structural
|
||||
// check must reject before the verifier is invoked.
|
||||
called := false
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) {
|
||||
called = true
|
||||
return true, nil
|
||||
})
|
||||
defer RegisterVerifier(nil)
|
||||
|
||||
proof := []byte("BOGUS-not-a-p3q-proof-but-long-enough")
|
||||
pub := []byte{0xde, 0xad, 0xbe, 0xef}
|
||||
input := buildInput(VersionV1, proof, pub)
|
||||
|
||||
gas := P3QVerifyPrecompile.RequiredGas(input) * 2
|
||||
_, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, gas, true)
|
||||
require.ErrorIs(t, err, ErrInvalidProof)
|
||||
require.False(t, called, "verifier callback must not run on bad magic")
|
||||
}
|
||||
|
||||
// TestP3Q_RejectsShortInput ensures inputs below MinInputLength fail with
|
||||
// ErrInvalidInputLength.
|
||||
func TestP3Q_RejectsShortInput(t *testing.T) {
|
||||
for _, sz := range []int{0, 1, 5, MinInputLength - 1} {
|
||||
input := make([]byte, sz)
|
||||
gas := P3QVerifyPrecompile.RequiredGas(input) * 2
|
||||
_, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, gas, true)
|
||||
require.ErrorIs(t, err, ErrInvalidInputLength, "len=%d", sz)
|
||||
}
|
||||
}
|
||||
|
||||
// TestP3Q_RoundTrip_WithRegisteredVerifier registers a fake verifier
|
||||
// that accepts and asserts Run() succeeds end-to-end.
|
||||
func TestP3Q_RoundTrip_WithRegisteredVerifier(t *testing.T) {
|
||||
saw := struct {
|
||||
version byte
|
||||
proof []byte
|
||||
pub []byte
|
||||
}{}
|
||||
RegisterVerifier(func(v byte, p, pi []byte) (bool, error) {
|
||||
saw.version = v
|
||||
saw.proof = append([]byte(nil), p...)
|
||||
saw.pub = append([]byte(nil), pi...)
|
||||
return true, nil
|
||||
})
|
||||
defer RegisterVerifier(nil)
|
||||
|
||||
proof := append([]byte(MagicHeader), []byte("STARK-FRI-cSHAKE256-Goldilocks-payload")...)
|
||||
pub := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
input := buildInput(VersionV1, proof, pub)
|
||||
|
||||
gas := P3QVerifyPrecompile.RequiredGas(input) * 2
|
||||
out, gasLeft, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, gas, true)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, out)
|
||||
require.Less(t, gasLeft, gas)
|
||||
require.Equal(t, VersionV1, saw.version)
|
||||
require.Equal(t, proof, saw.proof)
|
||||
require.Equal(t, pub, saw.pub)
|
||||
}
|
||||
|
||||
// TestP3Q_NoVerifierRegistered confirms that without a registered
|
||||
// verifier callback, structurally-valid input yields
|
||||
// ErrVerifierNotRegistered.
|
||||
func TestP3Q_NoVerifierRegistered(t *testing.T) {
|
||||
RegisterVerifier(nil)
|
||||
|
||||
proof := append([]byte(MagicHeader), []byte("payload")...)
|
||||
pub := []byte{0xaa}
|
||||
input := buildInput(VersionV1, proof, pub)
|
||||
|
||||
gas := P3QVerifyPrecompile.RequiredGas(input) * 2
|
||||
_, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, gas, true)
|
||||
require.ErrorIs(t, err, ErrVerifierNotRegistered)
|
||||
}
|
||||
|
||||
// TestP3Q_RejectsBadVersion confirms versions other than 0x01 are
|
||||
// rejected with ErrInvalidVersion.
|
||||
func TestP3Q_RejectsBadVersion(t *testing.T) {
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return true, nil })
|
||||
defer RegisterVerifier(nil)
|
||||
|
||||
proof := append([]byte(MagicHeader), []byte("payload")...)
|
||||
pub := []byte{0xaa}
|
||||
input := buildInput(0x00, proof, pub)
|
||||
|
||||
gas := P3QVerifyPrecompile.RequiredGas(input) * 2
|
||||
_, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, gas, true)
|
||||
require.ErrorIs(t, err, ErrInvalidVersion)
|
||||
}
|
||||
|
||||
// TestP3Q_VerifierReturnsFalse confirms that when the registered
|
||||
// verifier reports the proof as invalid we surface ErrInvalidProof.
|
||||
func TestP3Q_VerifierReturnsFalse(t *testing.T) {
|
||||
RegisterVerifier(func(byte, []byte, []byte) (bool, error) { return false, nil })
|
||||
defer RegisterVerifier(nil)
|
||||
|
||||
proof := append([]byte(MagicHeader), []byte("payload")...)
|
||||
pub := []byte{0xaa}
|
||||
input := buildInput(VersionV1, proof, pub)
|
||||
|
||||
gas := P3QVerifyPrecompile.RequiredGas(input) * 2
|
||||
_, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, gas, true)
|
||||
require.ErrorIs(t, err, ErrInvalidProof)
|
||||
}
|
||||
|
||||
// buildInput serialises [version][proof_len][proof][pub_len][pub] in
|
||||
// the wire format the precompile expects.
|
||||
func buildInput(version byte, proof, pub []byte) []byte {
|
||||
out := make([]byte, 0, 1+4+len(proof)+4+len(pub))
|
||||
out = append(out, version)
|
||||
var pl [4]byte
|
||||
binary.BigEndian.PutUint32(pl[:], uint32(len(proof)))
|
||||
out = append(out, pl[:]...)
|
||||
out = append(out, proof...)
|
||||
var ul [4]byte
|
||||
binary.BigEndian.PutUint32(ul[:], uint32(len(pub)))
|
||||
out = append(out, ul[:]...)
|
||||
out = append(out, pub...)
|
||||
return out
|
||||
}
|
||||
@@ -91,6 +91,9 @@ func (p *pastaPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, gas, err
|
||||
}
|
||||
if len(input) < 2 {
|
||||
return nil, gas, ErrInvalidInput
|
||||
}
|
||||
|
||||
@@ -119,6 +119,9 @@ func (p *pedersenPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, gas, err
|
||||
}
|
||||
if len(input) < 1 {
|
||||
return nil, gas, ErrInvalidInput
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ func (p *poseidonPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, gas, err
|
||||
}
|
||||
if len(input) < 1 {
|
||||
return nil, gas, ErrInvalidInput
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package pulsar implements the EVM precompile entry for Pulsar
|
||||
// threshold ML-DSA signature verification, the Module-LWE
|
||||
// FIPS 204-compatible threshold scheme (github.com/luxfi/pulsar,
|
||||
// formerly github.com/luxfi/pulsar-m).
|
||||
//
|
||||
// The cryptographic verification operation is **literally identical**
|
||||
// to FIPS 204 ML-DSA.Verify -- that's the Class N1 manifesto. Any
|
||||
// signature produced by a Pulsar threshold ceremony (DKG + Round1 +
|
||||
// Round2 + Combine) is byte-equal to a single-party FIPS 204
|
||||
// signature on the same message and group public key. Therefore this
|
||||
// precompile dispatches to the underlying ML-DSA verifier in
|
||||
// github.com/luxfi/crypto/mldsa under the hood.
|
||||
//
|
||||
// Why have a separate precompile address at all (vs. directing all
|
||||
// callers to the existing ML-DSA precompile at 0x012202)?
|
||||
//
|
||||
// 1. **Gas-tier separation** -- contracts that consume threshold
|
||||
// signatures may want different accounting / metering policies
|
||||
// than single-party signature verifications.
|
||||
// 2. **Telemetry** -- on-chain accounting can attribute Pulsar
|
||||
// verifies separately from generic ML-DSA verifies for analytics
|
||||
// and validator-set lifecycle dashboards.
|
||||
// 3. **Forward-compatibility** -- if a future Pulsar version adds a
|
||||
// non-FIPS-204 verification path (e.g. an identifiable-abort
|
||||
// hint, an aggregated-cert envelope), the address slot is
|
||||
// reserved.
|
||||
//
|
||||
// At present (Pulsar v0.1.x), the precompile is a thin pass-through
|
||||
// to the ML-DSA verifier with the same wire format. Gas cost matches
|
||||
// the ML-DSA-65 base tier (Pulsar's production target).
|
||||
package pulsar
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/precompile/contract"
|
||||
)
|
||||
|
||||
// Pulsar threshold-ML-DSA precompile address. The canonical LP-4200
|
||||
// unified PQCrypto block reserves this slot for Pulsar.
|
||||
//
|
||||
// 0x012201 = ML-KEM
|
||||
// 0x012202 = ML-DSA (single-party FIPS 204)
|
||||
// 0x012203 = SLH-DSA
|
||||
// 0x012204 = Pulsar ← this precompile (threshold FIPS 204)
|
||||
var ContractPulsarVerifyAddress = common.HexToAddress("0x0000000000000000000000000000000000012204")
|
||||
|
||||
// PulsarVerifyPrecompile is the singleton instance.
|
||||
var PulsarVerifyPrecompile = &pulsarVerifyPrecompile{}
|
||||
|
||||
var (
|
||||
_ contract.StatefulPrecompiledContract = &pulsarVerifyPrecompile{}
|
||||
)
|
||||
|
||||
// Errors returned by the precompile.
|
||||
var (
|
||||
ErrInvalidInputLength = contract.ErrInvalidInput
|
||||
ErrInvalidMode = errors.New("invalid Pulsar mode (must be 0x44, 0x65, or 0x87)")
|
||||
ErrUnsupportedMode = errors.New("unsupported Pulsar mode")
|
||||
ErrInvalidSignature = errors.New("Pulsar signature verification failed")
|
||||
)
|
||||
|
||||
// Pulsar parameter sets mirror FIPS 204 ML-DSA's three security
|
||||
// levels. Wire-format and size constants are identical to ML-DSA.
|
||||
const (
|
||||
ModePulsar44 uint8 = 0x44 // NIST PQ Category 2
|
||||
ModePulsar65 uint8 = 0x65 // NIST PQ Category 3 (Lux production target)
|
||||
ModePulsar87 uint8 = 0x87 // NIST PQ Category 5
|
||||
)
|
||||
|
||||
const (
|
||||
// Pulsar-44 sizes (same as ML-DSA-44 per FIPS 204).
|
||||
Pulsar44PublicKeySize = 1312
|
||||
Pulsar44SignatureSize = 2420
|
||||
|
||||
// Pulsar-65 sizes.
|
||||
Pulsar65PublicKeySize = 1952
|
||||
Pulsar65SignatureSize = 3309
|
||||
|
||||
// Pulsar-87 sizes.
|
||||
Pulsar87PublicKeySize = 2592
|
||||
Pulsar87SignatureSize = 4627
|
||||
|
||||
ModeByte = 1
|
||||
)
|
||||
|
||||
// Gas costs per Pulsar mode. Matches the ML-DSA precompile's
|
||||
// per-mode costs (same work; same cost).
|
||||
const (
|
||||
Pulsar44VerifyBaseGas uint64 = 75_000
|
||||
Pulsar65VerifyBaseGas uint64 = 100_000
|
||||
Pulsar87VerifyBaseGas uint64 = 150_000
|
||||
|
||||
PulsarVerifyPerByteGas uint64 = 10
|
||||
)
|
||||
|
||||
// precompileCtx is the domain-separation context for the Pulsar
|
||||
// threshold precompile. Different from the single-party ML-DSA
|
||||
// precompile context to keep on-chain transcripts unambiguous about
|
||||
// which scheme produced the signature.
|
||||
var precompileCtx = []byte("lux-evm-precompile-pulsar-v1")
|
||||
|
||||
type pulsarVerifyPrecompile struct{}
|
||||
|
||||
// RequiredGas returns the gas cost for verifying a Pulsar signature.
|
||||
// Layout matches the ML-DSA precompile: first byte is the mode, then
|
||||
// pubkey, then message, then signature.
|
||||
func (p *pulsarVerifyPrecompile) RequiredGas(input []byte) uint64 {
|
||||
if len(input) < ModeByte {
|
||||
return Pulsar65VerifyBaseGas // default to mid mode for malformed input
|
||||
}
|
||||
mode := input[0]
|
||||
switch mode {
|
||||
case ModePulsar44:
|
||||
return Pulsar44VerifyBaseGas + uint64(len(input))*PulsarVerifyPerByteGas
|
||||
case ModePulsar65:
|
||||
return Pulsar65VerifyBaseGas + uint64(len(input))*PulsarVerifyPerByteGas
|
||||
case ModePulsar87:
|
||||
return Pulsar87VerifyBaseGas + uint64(len(input))*PulsarVerifyPerByteGas
|
||||
default:
|
||||
return Pulsar65VerifyBaseGas
|
||||
}
|
||||
}
|
||||
|
||||
// Run verifies a Pulsar threshold signature.
|
||||
//
|
||||
// Input layout:
|
||||
//
|
||||
// [mode][pubkey][message_len_uint256][message][signature]
|
||||
//
|
||||
// The verification operation is `mldsa.VerifyCtx(mode, pubkey, msg,
|
||||
// precompileCtx, sig)`. By construction this also accepts any
|
||||
// single-party ML-DSA signature -- Pulsar's wire format is FIPS 204
|
||||
// byte-equal.
|
||||
//
|
||||
// Returns:
|
||||
// - empty bytes and nil error on successful verification.
|
||||
// - empty bytes and a typed error on any failure.
|
||||
func (p *pulsarVerifyPrecompile) Run(
|
||||
_ contract.AccessibleState,
|
||||
_ common.Address,
|
||||
_ common.Address,
|
||||
input []byte,
|
||||
suppliedGas uint64,
|
||||
_ bool,
|
||||
) ([]byte, uint64, error) {
|
||||
if suppliedGas < p.RequiredGas(input) {
|
||||
return nil, 0, contract.ErrOutOfGas
|
||||
}
|
||||
if len(input) < ModeByte {
|
||||
return nil, suppliedGas, ErrInvalidInputLength
|
||||
}
|
||||
mode := input[0]
|
||||
body := input[1:]
|
||||
|
||||
var pubSize, sigSize int
|
||||
var mldsaMode mldsa.Mode
|
||||
switch mode {
|
||||
case ModePulsar44:
|
||||
pubSize = Pulsar44PublicKeySize
|
||||
sigSize = Pulsar44SignatureSize
|
||||
mldsaMode = mldsa.MLDSA44
|
||||
case ModePulsar65:
|
||||
pubSize = Pulsar65PublicKeySize
|
||||
sigSize = Pulsar65SignatureSize
|
||||
mldsaMode = mldsa.MLDSA65
|
||||
case ModePulsar87:
|
||||
pubSize = Pulsar87PublicKeySize
|
||||
sigSize = Pulsar87SignatureSize
|
||||
mldsaMode = mldsa.MLDSA87
|
||||
default:
|
||||
return nil, suppliedGas, ErrInvalidMode
|
||||
}
|
||||
|
||||
if len(body) < pubSize+32 {
|
||||
return nil, suppliedGas, ErrInvalidInputLength
|
||||
}
|
||||
pubkey := body[:pubSize]
|
||||
body = body[pubSize:]
|
||||
|
||||
if len(body) < 32 {
|
||||
return nil, suppliedGas, ErrInvalidInputLength
|
||||
}
|
||||
// Message length is encoded as a big-endian uint256; we accept
|
||||
// the low 8 bytes as the actual length.
|
||||
mlen := uint64(0)
|
||||
for i := 24; i < 32; i++ {
|
||||
mlen = (mlen << 8) | uint64(body[i])
|
||||
}
|
||||
body = body[32:]
|
||||
if uint64(len(body)) < mlen+uint64(sigSize) {
|
||||
return nil, suppliedGas, ErrInvalidInputLength
|
||||
}
|
||||
msg := body[:mlen]
|
||||
body = body[mlen:]
|
||||
sig := body[:sigSize]
|
||||
|
||||
pk, err := mldsa.PublicKeyFromBytes(pubkey, mldsaMode)
|
||||
if err != nil {
|
||||
return nil, suppliedGas - p.RequiredGas(input), ErrInvalidSignature
|
||||
}
|
||||
if !pk.VerifySignatureCtx(msg, sig, precompileCtx) {
|
||||
return nil, suppliedGas - p.RequiredGas(input), ErrInvalidSignature
|
||||
}
|
||||
return nil, suppliedGas - p.RequiredGas(input), nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package pulsar
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPulsarPrecompile_Address pins the canonical LP-4200 address.
|
||||
func TestPulsarPrecompile_Address(t *testing.T) {
|
||||
want := common.HexToAddress("0x0000000000000000000000000000000000012204")
|
||||
require.Equal(t, want, ContractPulsarVerifyAddress)
|
||||
}
|
||||
|
||||
// TestPulsarPrecompile_RequiredGas exercises the per-mode gas table.
|
||||
func TestPulsarPrecompile_RequiredGas(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
mode uint8
|
||||
want uint64
|
||||
}{
|
||||
{ModePulsar44, Pulsar44VerifyBaseGas},
|
||||
{ModePulsar65, Pulsar65VerifyBaseGas},
|
||||
{ModePulsar87, Pulsar87VerifyBaseGas},
|
||||
} {
|
||||
input := []byte{tc.mode}
|
||||
got := PulsarVerifyPrecompile.RequiredGas(input)
|
||||
// One-byte input → base + 1 * PerByteGas.
|
||||
want := tc.want + 1*PulsarVerifyPerByteGas
|
||||
require.Equal(t, want, got, "mode=0x%x", tc.mode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsarPrecompile_RoundTrip_VerifyAcceptsValid generates a real
|
||||
// ML-DSA signature with the precompile context, hands the bytes to
|
||||
// the Pulsar precompile, and asserts it accepts. This is the Class
|
||||
// N1 manifesto test: a single-party FIPS 204 signature on the
|
||||
// Pulsar context verifies through the Pulsar precompile because the
|
||||
// verification operation IS FIPS 204 ML-DSA.Verify.
|
||||
func TestPulsarPrecompile_RoundTrip_VerifyAcceptsValid(t *testing.T) {
|
||||
for _, mode := range []mldsa.Mode{mldsa.MLDSA44, mldsa.MLDSA65, mldsa.MLDSA87} {
|
||||
mode := mode
|
||||
t.Run(modeString(mode), func(t *testing.T) {
|
||||
priv, err := mldsa.GenerateKey(rand.Reader, mode)
|
||||
require.NoError(t, err)
|
||||
pub := priv.Public().(*mldsa.PublicKey)
|
||||
pubBytes := pub.Bytes()
|
||||
|
||||
message := []byte("pulsar threshold sign roundtrip via precompile")
|
||||
sig, err := priv.SignCtx(rand.Reader, message, precompileCtx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build precompile input.
|
||||
var modeByte uint8
|
||||
switch mode {
|
||||
case mldsa.MLDSA44:
|
||||
modeByte = ModePulsar44
|
||||
case mldsa.MLDSA65:
|
||||
modeByte = ModePulsar65
|
||||
case mldsa.MLDSA87:
|
||||
modeByte = ModePulsar87
|
||||
}
|
||||
input := buildInput(modeByte, pubBytes, message, sig)
|
||||
gas := PulsarVerifyPrecompile.RequiredGas(input) * 2 // give plenty
|
||||
out, gasLeft, err := PulsarVerifyPrecompile.Run(nil, common.Address{}, ContractPulsarVerifyAddress, input, gas, true)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, out)
|
||||
require.Less(t, gasLeft, gas)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsarPrecompile_RejectsTamperedSignature confirms that flipping
|
||||
// a byte in the signature causes verification to fail.
|
||||
func TestPulsarPrecompile_RejectsTamperedSignature(t *testing.T) {
|
||||
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
require.NoError(t, err)
|
||||
pub := priv.Public().(*mldsa.PublicKey)
|
||||
pubBytes := pub.Bytes()
|
||||
message := []byte("tamper detection")
|
||||
sig, err := priv.SignCtx(rand.Reader, message, precompileCtx)
|
||||
require.NoError(t, err)
|
||||
sig[0] ^= 0xff
|
||||
input := buildInput(ModePulsar65, pubBytes, message, sig)
|
||||
gas := PulsarVerifyPrecompile.RequiredGas(input) * 2
|
||||
_, _, err = PulsarVerifyPrecompile.Run(nil, common.Address{}, ContractPulsarVerifyAddress, input, gas, true)
|
||||
require.ErrorIs(t, err, ErrInvalidSignature)
|
||||
}
|
||||
|
||||
// TestPulsarPrecompile_RejectsInvalidMode confirms that mode bytes
|
||||
// outside {0x44, 0x65, 0x87} are rejected.
|
||||
func TestPulsarPrecompile_RejectsInvalidMode(t *testing.T) {
|
||||
input := []byte{0x99} // bogus mode
|
||||
_, _, err := PulsarVerifyPrecompile.Run(nil, common.Address{}, ContractPulsarVerifyAddress, input, 1_000_000, true)
|
||||
require.ErrorIs(t, err, ErrInvalidMode)
|
||||
}
|
||||
|
||||
func buildInput(mode uint8, pub, msg, sig []byte) []byte {
|
||||
out := make([]byte, 0, 1+len(pub)+32+len(msg)+len(sig))
|
||||
out = append(out, mode)
|
||||
out = append(out, pub...)
|
||||
var mlen [32]byte
|
||||
binary.BigEndian.PutUint64(mlen[24:], uint64(len(msg)))
|
||||
out = append(out, mlen[:]...)
|
||||
out = append(out, msg...)
|
||||
out = append(out, sig...)
|
||||
return out
|
||||
}
|
||||
|
||||
func modeString(m mldsa.Mode) string {
|
||||
switch m {
|
||||
case mldsa.MLDSA44:
|
||||
return "Pulsar-44"
|
||||
case mldsa.MLDSA65:
|
||||
return "Pulsar-65"
|
||||
case mldsa.MLDSA87:
|
||||
return "Pulsar-87"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,16 @@ func (p *ringSignaturePrecompile) Run(
|
||||
op := input[0]
|
||||
scheme := input[1]
|
||||
|
||||
// Classical LSAG schemes (secp256k1, Ed25519, DualRing) are gated by
|
||||
// the chain's strict-PQ profile. The lattice scheme (SchemeLatticeLSAG)
|
||||
// remains open on every chain.
|
||||
switch scheme {
|
||||
case SchemeLSAGSecp256k1, SchemeLSAGEd25519, SchemeDualRing:
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
}
|
||||
|
||||
var result []byte
|
||||
|
||||
switch op {
|
||||
|
||||
@@ -96,6 +96,9 @@ func (p *sr25519VerifyPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
|
||||
if len(input) < MinInputSize {
|
||||
return failResult, remainingGas, fmt.Errorf(
|
||||
|
||||
@@ -109,6 +109,9 @@ func (p *vrfPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
|
||||
if len(input) < 1 {
|
||||
return nil, remainingGas, nil
|
||||
|
||||
@@ -82,6 +82,9 @@ func (p *x25519Precompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, gas, err
|
||||
}
|
||||
if len(input) < 1 {
|
||||
return nil, gas, ErrInvalidInput
|
||||
}
|
||||
|
||||
@@ -154,6 +154,17 @@ func (p *zkVerifyPrecompile) Run(
|
||||
op := input[0]
|
||||
data := input[1:]
|
||||
|
||||
// Classical pairing/DLOG opcodes are gated by the chain's strict-PQ
|
||||
// profile. The hash-based opcodes (Nullifier 0x21, Commitment 0x22)
|
||||
// remain open on every chain.
|
||||
switch op {
|
||||
case OpVerifyGroth16, OpVerifyPLONK, OpVerifyFflonk, OpVerifyHalo2,
|
||||
OpVerifyKZG, OpVerifyIPA, OpVerifyRangeProof, OpVerifyBatch:
|
||||
if err := contract.RefuseUnderStrictPQ(accessibleState); err != nil {
|
||||
return nil, remainingGas, err
|
||||
}
|
||||
}
|
||||
|
||||
switch op {
|
||||
case OpVerifyGroth16:
|
||||
valid, err := p.verifyGroth16(data)
|
||||
|
||||
Reference in New Issue
Block a user