Files
zeekay 9cc8cc7bb7 contract: one cursor holds every calldata bound, one verdict cannot be faked
A precompile's input is the two-index slice Memory.GetPtr returns, and
nothing on the path to Run copies it. len is what the caller declared and
paid for; cap is the rest of EVM memory, which that same caller filled with
MSTORE. So a parser reaching past its input does not panic and does not halt
a validator -- it reads bytes the attacker chose and answers over them.
Measured, not assumed: TestCalldataCarriesCallerChosenSpareCapacity drives
the VM and reads back len=8 cap=256 with 0xA5 past the end.

Cursor bounds every read against len, never against cap and never by letting
a slice expression refuse, and caps what it returns so a field cannot be
re-sliced forward. Verdict carries the bit (bool, error) was missing -- did a
verifier run -- so "nothing checked this" stops borrowing the spelling of
"this failed", and its zero value denies. Poisoned is the one definition of
the adversarial fixture: append-built input has spare capacity too, but
zeroed, so an over-read reads as a harmless run of zeros.

p3q parses through the cursor. Its six hand-written bounds become zero; the
class now rests on the single check in Cursor.Bytes, and deleting that check
fails both suites -- including with fixtures degraded back to plain append,
which is what the class used to depend on.
2026-08-28 11:47:04 -07:00

490 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package p3q implements the EVM precompile at slot 0x012205 for P3Q
// — "Post-Quantum Pulsar Proof" — the on-chain Pulsar / FIPS 204
// ML-DSA threshold-signature verifier.
//
// Canonical reference: HANZO-CRYPTO-SUITE §5.2 + LP-218 + ROADMAP
// §B.11. P3Q is the Solidity-callable verifier shim for the rollup-
// commit pattern of LP-218: a Tier-3 rollup sequencer Pulsar-threshold-
// signs the tuple (prev_root, new_root, batch_hash), packs the
// signature into a RollupBatchTx, and the parent L1 verifies the
// signature via this precompile inside normal block validation.
//
// Cryptographically, Pulsar's headline Class N1 claim is that a
// threshold-ceremony signature is byte-equal to a single-party
// FIPS 204 ML-DSA signature on the same message and group public key.
// Therefore this precompile's verifier core IS the FIPS 204 ML-DSA
// verifier — there is no Pulsar-specific verification logic. The
// dispatch goes through luxfi/crypto/mldsa.VerifySignatureCtx, which
// is a thin alias over cloudflare/circl mldsa{44,65,87} (the FIPS 204
// §6.3 verifier). One verifier, two import seams, byte-equal output.
//
// LP-4200 unified PQCrypto block:
//
// 0x012201 = ML-KEM (Module-LWE KEM, FIPS 203)
// 0x012202 = ML-DSA (Module-LWE single-party signature, FIPS 204)
// 0x012203 = SLH-DSA (hash-based signature, FIPS 205)
// 0x012204 = Pulsar (Module-LWE threshold, FIPS 204 byte-equal)
// 0x012205 = P3Q ← this precompile (LP-218 rollup-commit verifier)
// 0x012206 = Corona (Module-LWE threshold, Ringtail/Raccoon)
// 0x012207 = Magnetar (SLH-DSA threshold, FIPS 205 byte-equal)
// 0x012208 = HQC (code-based KEM, family-disjoint backup)
//
// Relationship to the Pulsar precompile at 0x012204:
//
// - 0x012204 (Pulsar) is the generic Pulsar / threshold-FIPS-204
// verifier. Wire: [mode][pubkey][msg_len][msg][sig]. Accepts
// arbitrary-length messages. Use for: any Pulsar-signed payload
// that needs Solidity-callable verification.
// - 0x012205 (P3Q) is the rollup-commit-specialised verifier of
// LP-218. Wire: [mode][pulsarSigLen][pulsarSig][groupPubKeyLen]
// [groupPubKey][messageHash:32]. The message is a fixed 32-byte
// hash. Use for: the LP-218 rollup pattern (rollup sequencer
// Pulsar-signs (prev_root || new_root || batch_hash)).
//
// The two precompiles share a common verifier core but serve distinct
// consumers — orthogonal slots, orthogonal wire formats, one and one
// way to do each (HANZO-CRYPTO-SUITE §5.2: "P3Q is a new primitive at
// its own slot, not bolted onto existing ZK or Pulsar").
//
// Wire format (single-call interface, no function selector):
//
// [ 1 byte ] kind (0x01 Pulsar / 0x02 Corona / 0x03 Magnetar)
// [ 1 byte ] mode (primitive-specific level — for Pulsar:
// 0x44 / 0x65 / 0x87 ML-DSA parameter set)
// [ 4 bytes ] pulsarSig length (big-endian uint32)
// [ N bytes ] pulsarSig (length = previous field)
// [ 4 bytes ] groupPubKey length (big-endian uint32)
// [ M bytes ] groupPubKey (length = previous field)
// [ 32 bytes ] messageHash (32-byte digest)
//
// Output: 32-byte EVM-ABI-encoded bool. Last byte is 0x01 on
// successful verification, 0x00 on any verification failure (including
// malformed input, mode mismatch, length mismatch, signature size
// mismatch, public-key size mismatch, FIPS 204 verifier reject).
//
// Returns `(out, gasLeft, nil)` on every well-gas-supplied call: the
// boolean result is conveyed via the output word, never via the Go
// error. This matches the LP-218 §"Failure modes" semantics — the
// precompile reports verification outcome without reverting, so the
// caller (Solidity contract) decides whether `false` is fatal.
//
// Gas cost: BaseVerifyGas + PerByteGas * len(input). The 50_000 base
// is the LP-218 calibration (10x BLS verify ≈ 5_000 gas; 17x ecrecover
// ≈ 3_000 gas) reflecting the inherent cost of ML-DSA lattice
// verification (NTT inverse + matrix-vector multiplication mod q).
// The per-byte adder covers serialization / hashing overhead. At a
// canonical ML-DSA-65 payload (1952-byte pk + 3309-byte sig + 32-byte
// hash + 10-byte framing [1 kind + 1 mode + 4 sigLen + 4 pkLen] = 5303
// bytes) the total gas is 50_000 + 5303 * 10 = 103_030 gas — well under
// the 12M C-Chain block gas limit (per contract.FeeConfigReporter audit
// at ~117 verifies per block).
//
// Domain separation: every P3Q verify binds the FIPS 204 context
// string `lux-evm-precompile-p3q-v1`. Mismatched context between
// signer and verifier causes the FIPS 204 verifier to reject — this
// prevents cross-precompile replay where a Pulsar signature minted
// for the generic 0x012204 path is re-submitted to the rollup-commit
// 0x012205 path. The signer SHOULD set ctx = "lux-evm-precompile-p3q-v1"
// when producing rollup-commit signatures destined for on-chain P3Q
// verification.
//
// Constant-time properties: P3Q operates on PUBLIC calldata — both
// the signature and the public key are observable on-chain. The CT
// concern is therefore not confidentiality but soundness: no timing
// channel may distinguish an accepting verify from a rejecting verify
// in a way that an adversary could turn into a consensus-relevant
// oracle. The FIPS 204 verifier is constant-time per FIPS 204 §6.3 and
// cloudflare/circl's documented CT contract; we forward that property
// without introducing data-dependent branches at the precompile layer.
// The only branches in Run are on PUBLIC mode/length fields, which is
// allowed under the CT contract.
package p3q
import (
"encoding/binary"
"errors"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
)
// ContractP3QVerifyAddress is the canonical LP-4200 slot for P3Q per
// HANZO-CRYPTO-SUITE §5.2 and LP-218.
var ContractP3QVerifyAddress = common.HexToAddress("0x0000000000000000000000000000000000012205")
// P3QVerifyPrecompile is the singleton instance bound to slot
// 0x012205 by package init() in module.go.
var P3QVerifyPrecompile = &p3qVerifyPrecompile{}
var _ contract.StatefulPrecompiledContract = &p3qVerifyPrecompile{}
// Wire format size constants.
const (
// KindByte is the single-byte P3Q family discriminator at offset 0
// of the calldata per LP-218 §"Precompile wire format" (decomplected
// 2026-06-03 — slot 0x012205 hosts the unified P3Q verifier family;
// dispatch on the kind byte selects which lattice/hash primitive
// performs the verify).
KindByte = 1
// ModeByte is the primitive-specific parameter-set identifier at
// offset 1 of the calldata (post-kind). For KindPulsar / KindCorona,
// this names the FIPS 204 ML-DSA parameter set (44/65/87). For
// KindMagnetar, this names the FIPS 205 SLH-DSA parameter set.
ModeByte = 1
// LengthFieldSize is the byte width of each big-endian uint32
// length field embedded in the wire format.
LengthFieldSize = 4
// MessageHashSize is the byte width of the fixed messageHash field
// per LP-218 (the precompile binds a 32-byte digest, not a
// variable-length message — that is the distinguishing feature of
// P3Q vs the generic Pulsar precompile at 0x012204).
MessageHashSize = 32
// MinInputLength is the smallest structurally valid input:
// 1 kind + 1 mode + 4 sig_len + 0 sig + 4 pk_len + 0 pk + 32 hash.
// A zero-length signature or zero-length public key cannot
// possibly verify (FIPS 204 requires both to be non-empty), but
// the structural check is constant-time over PUBLIC lengths and
// the FIPS 204 verifier surfaces the actual rejection.
MinInputLength = KindByte + ModeByte + LengthFieldSize + LengthFieldSize + MessageHashSize
)
// P3Q family kinds per LP-218 / LP-220. Slot 0x012205 hosts a unified
// verifier; the kind byte at offset 0 of the wire format selects
// which primitive performs the verify. Decomplection of 2026-06-03
// collapsed the prior sibling-slots model (LP-220 §"Architectural
// decision — single slot, kind-byte dispatch") into one slot.
//
// Today this precompile fully implements KindPulsar (FIPS 204 ML-DSA
// byte-equal output via Pulsar threshold ceremony). KindCorona and
// KindMagnetar hold their place in the dispatch table and answer
// false, so the surface is stable while their lattice/hash threshold
// verifiers land (see LP-220 §"Per-kind specs").
const (
KindPulsar uint8 = 0x01 // FIPS 204 ML-DSA (Module-LWE threshold, byte-equal)
KindCorona uint8 = 0x02 // Module-LWE threshold (Corona eprint 2024/1113)
KindMagnetar uint8 = 0x03 // FIPS 205 SLH-DSA (hash-based threshold)
)
// ML-DSA parameter-set identifiers. Wire bytes match the canonical
// FIPS 204 mode numbering used across the Lux precompile family
// (0x012202 ML-DSA, 0x012204 Pulsar). Aligning with sibling precompiles
// keeps client tooling uniform.
const (
ModeMLDSA44 uint8 = 0x44 // NIST PQ Category 2
ModeMLDSA65 uint8 = 0x65 // NIST PQ Category 3 (Lux production target)
ModeMLDSA87 uint8 = 0x87 // NIST PQ Category 5
)
// FIPS 204 byte-size constants per parameter set (FIPS 204 Table 2).
// Mirrored from the Pulsar precompile at 0x012204 — same lattice,
// same byte layout, same sizes.
const (
MLDSA44PublicKeySize = 1312
MLDSA44SignatureSize = 2420
MLDSA65PublicKeySize = 1952
MLDSA65SignatureSize = 3309
MLDSA87PublicKeySize = 2592
MLDSA87SignatureSize = 4627
)
// Gas schedule per LP-218 §"Honest cost comparison":
//
// P3Q (Pulsar / ML-DSA-65) | 50,000 gas | ~50 μs Blackwell | ~80 μs CPU
// BLS pairing verify | 5,000 gas | ~10 μs Blackwell | ~6 ms CPU
// ecrecover (secp256k1) | 3,000 gas | ~3 μs Blackwell | ~10 μs CPU
//
// The 10× factor over BLS reflects lattice verification's inherent
// cost (NTT inverse + matrix-vector mul mod q). The per-byte adder
// covers serialization / wire-format parsing. Calibrated against the
// parity audit (luxfi/threshold parity_test): Pulsar verify
// measures ~0.13-0.17 ms across all N on M1 Max and DGX Spark; at
// 12k gas/ms (geth's published gas-per-millisecond yardstick), that
// is ~1.6-2.0k gas of CPU time, dwarfed by the conservative 50k base.
const (
BaseVerifyGas uint64 = 50_000
PerByteGas uint64 = 10
)
// PrecompileCtx is the FIPS 204 context string bound to every P3Q
// verify call. Distinct from the generic 0x012204 Pulsar precompile's
// context ("lux-evm-precompile-pulsar-v1") so a signature minted for
// one slot cannot be replayed against the other.
//
// The context is a public, constant byte string — exposing it does not
// weaken the verifier; rather, it forces an attacker to obtain a fresh
// signature under the P3Q context for any on-chain replay attempt.
var PrecompileCtx = []byte("lux-evm-precompile-p3q-v1")
// ErrUnsupportedMode is returned by Verify, the in-process entry
// point, for a mode byte outside {0x44, 0x65, 0x87}.
//
// Run's own error return carries nothing but contract.ErrOutOfGas.
// Every verification failure — malformed input, unknown kind, unknown
// mode, length mismatch, FIPS 204 reject — is surfaced as
// `(out=[0x00…00], nil error)`, per LP-218 §"Failure modes": no revert
// on cryptographic failure, the Solidity caller observes `false` and
// decides whether that is fatal.
//
// The sentinels below never reach a Solidity caller either. They are
// what verify hands contract.Refused, and they exist because the ABI
// word cannot tell a check that failed from a check that never ran,
// while the code must: a reserved kind and a bad signature are the same
// byte on the wire and different facts in the verifier. Reading them
// back is what the refusal tests do.
var (
ErrUnsupportedMode = errors.New("p3q: unsupported ML-DSA mode (must be 0x44, 0x65, or 0x87)")
ErrKindReserved = errors.New("p3q: kind reserved, no verifier wired")
ErrUnknownKind = errors.New("p3q: kind names no primitive")
ErrSigSize = errors.New("p3q: declared signature length is not the mode's size")
ErrPubKeySize = errors.New("p3q: declared public key length is not the mode's size")
)
type p3qVerifyPrecompile struct{}
// RequiredGas returns BaseVerifyGas + PerByteGas * len(input).
//
// The cost function is monotonic in input length and uniform across
// success / failure paths (per the LP-218 gas-billing contract:
// "audited gas charge"). A short malformed input pays the base; a
// well-formed N-byte input pays base + N * PerByteGas. No reduced
// price for early rejection — uniform billing prevents a gas-side
// channel from leaking which validation stage rejected the input.
func (p *p3qVerifyPrecompile) RequiredGas(input []byte) uint64 {
return BaseVerifyGas + uint64(len(input))*PerByteGas
}
// modeParams resolves the wire-byte mode to (mldsa.Mode, pkSize,
// sigSize). Returns ErrUnsupportedMode on an unrecognised byte.
func modeParams(mode uint8) (m mldsa.Mode, pkSize, sigSize int, err error) {
switch mode {
case ModeMLDSA44:
return mldsa.MLDSA44, MLDSA44PublicKeySize, MLDSA44SignatureSize, nil
case ModeMLDSA65:
return mldsa.MLDSA65, MLDSA65PublicKeySize, MLDSA65SignatureSize, nil
case ModeMLDSA87:
return mldsa.MLDSA87, MLDSA87PublicKeySize, MLDSA87SignatureSize, nil
default:
return 0, 0, 0, ErrUnsupportedMode
}
}
// Run verifies a P3Q-family signature per LP-218 / LP-220. Today only
// KindPulsar (FIPS 204 ML-DSA byte-equal output) is wired; KindCorona
// and KindMagnetar are reserved and refuse until their verifiers land.
//
// Wire format (single-call interface, no function selector):
//
// [ 1 byte ] kind (0x01 Pulsar / 0x02 Corona / 0x03 Magnetar)
// [ 1 byte ] mode (0x44 / 0x65 / 0x87 — primitive-specific)
// [ 4 bytes ] pulsarSig length (BE32)
// [ N bytes ] pulsarSig
// [ 4 bytes ] groupPubKey length (BE32)
// [ M bytes ] groupPubKey
// [ 32 bytes ] messageHash
//
// Output: 32-byte EVM-ABI bool (last byte 0x01 on success, 0x00 on
// any verification failure).
//
// Error returns:
// - contract.ErrOutOfGas when suppliedGas < RequiredGas(input).
//
// Every other outcome — malformed input, unknown kind, unknown mode,
// length mismatch, FIPS 204 reject — is `(word, gasLeft, nil)` with the
// word false, per the LP-218 contract: no revert on cryptographic
// failure. The word is the verdict's own encoding, so the two cases
// the ABI collapses (a check that failed, a check that never ran) stay
// distinct inside this function even though the caller sees one byte.
func (p *p3qVerifyPrecompile) Run(
_ contract.AccessibleState,
_ common.Address,
_ common.Address,
input []byte,
suppliedGas uint64,
_ bool,
) ([]byte, uint64, error) {
gas := p.RequiredGas(input)
remaining, err := contract.DeductGas(suppliedGas, gas)
if err != nil {
return nil, 0, err
}
return verify(input).Word(), remaining, nil
}
// verify parses the wire format and runs the FIPS 204 verifier.
//
// It reads through a contract.Cursor, so no bound in this function is
// written by hand and none can be deleted: an over-read is refused by
// the cursor rather than by a slice expression, which under a real
// precompile input would not refuse at all. See contract/cursor.go for
// why — the short version is that calldata's spare capacity belongs to
// the caller, so reaching past the declared end reads chosen bytes and
// returns a verdict over them.
//
// Every branch on kind, mode and the two length fields is on PUBLIC
// data. No branch here depends on signature or public-key contents,
// which is what keeps the FIPS 204 verifier's constant-time contract
// intact at the precompile layer.
func verify(input []byte) contract.Verdict {
in := contract.Read(input)
// Family-kind dispatch per LP-218 / LP-220 §"Single slot, kind-byte
// dispatch". Today only KindPulsar is wired; KindCorona / KindMagnetar
// reserve their slot and refuse. Adding their verifier paths is a
// one-line change here and nowhere else.
kind, err := in.Byte()
if err != nil {
return contract.Refused(err)
}
switch kind {
case KindPulsar:
// fall through to the FIPS 204 ML-DSA verify path
case KindCorona, KindMagnetar:
return contract.Refused(ErrKindReserved)
default:
return contract.Refused(ErrUnknownKind)
}
mode, err := in.Byte()
if err != nil {
return contract.Refused(err)
}
m, pkSize, sigSize, err := modeParams(mode)
if err != nil {
return contract.Refused(err)
}
// pulsarSig: [4 bytes len][N bytes sig]. The declared length must
// EQUAL the parameter set's size, not merely fit — a length that
// only fits would let a caller pad the field and shift every later
// offset, so the bytes the verifier sees are chosen by the length
// field rather than by the layout.
sigLen, err := in.Uint32()
if err != nil {
return contract.Refused(err)
}
if uint64(sigLen) != uint64(sigSize) {
return contract.Refused(ErrSigSize)
}
pulsarSig, err := in.Bytes(uint64(sigLen))
if err != nil {
return contract.Refused(err)
}
// groupPubKey: [4 bytes len][M bytes pk], same rule.
pkLen, err := in.Uint32()
if err != nil {
return contract.Refused(err)
}
if uint64(pkLen) != uint64(pkSize) {
return contract.Refused(ErrPubKeySize)
}
groupPubKey, err := in.Bytes(uint64(pkLen))
if err != nil {
return contract.Refused(err)
}
// messageHash: exactly 32 bytes. The wire layout places it AFTER the
// variable-length blobs so its offset is content-independent —
// parseable by reading only the length fields, with no scanning.
messageHash, err := in.Bytes(MessageHashSize)
if err != nil {
return contract.Refused(err)
}
// Trailing bytes are accepted, matching the sibling STARK-FRI
// precompile. That is why in.End() is not called here: admitting
// padding is this format's documented behaviour, not an oversight.
pk, err := mldsa.PublicKeyFromBytes(groupPubKey, m)
if err != nil {
return contract.Refused(err)
}
return contract.Checked(pk.VerifySignatureCtx(messageHash, pulsarSig, PrecompileCtx))
}
// Verify is the in-process Pulsar verification entry-point for callers
// outside the EVM precompile path (chains/zkvm dispatch, off-chain
// pre-checks, tests). Bypasses gas accounting and AccessibleState
// (those concerns belong to the precompile wrapper).
//
// Returns nil on a verifying signature; a typed error otherwise. Uses
// the same FIPS 204 context as the precompile path so off-chain
// pre-checks are byte-identical to on-chain verification.
//
// Pre-conditions:
// - mode is one of {ModeMLDSA44, ModeMLDSA65, ModeMLDSA87}
// - len(groupPubKey) matches the mode's pk size
// - len(pulsarSig) matches the mode's sig size
// - len(messageHash) is MessageHashSize (32)
//
// Violations of these pre-conditions return a typed error rather than
// a panic.
func Verify(mode uint8, groupPubKey, pulsarSig, messageHash []byte) error {
m, pkSize, sigSize, err := modeParams(mode)
if err != nil {
return err
}
if len(groupPubKey) != pkSize {
return errors.New("p3q: groupPubKey size mismatch")
}
if len(pulsarSig) != sigSize {
return errors.New("p3q: pulsarSig size mismatch")
}
if len(messageHash) != MessageHashSize {
return errors.New("p3q: messageHash must be 32 bytes")
}
pk, perr := mldsa.PublicKeyFromBytes(groupPubKey, m)
if perr != nil {
return perr
}
if !pk.VerifySignatureCtx(messageHash, pulsarSig, PrecompileCtx) {
return errors.New("p3q: FIPS 204 ML-DSA verify rejected")
}
return nil
}
// EncodeInput is a helper for callers (tests, Solidity adapters,
// off-chain pre-flight checks) to serialize (mode, sig, pk, hash) into
// the wire format the precompile expects. The encoder is also the
// canonical reference for the wire layout — any deviation between
// EncodeInput and Run is a bug in one or the other.
//
// EncodeInput targets KindPulsar (the only kind with a wired verifier
// today). For explicit kind selection use EncodeInputKind.
func EncodeInput(mode uint8, pulsarSig, groupPubKey, messageHash []byte) []byte {
return EncodeInputKind(KindPulsar, mode, pulsarSig, groupPubKey, messageHash)
}
// EncodeInputKind serializes (kind, mode, sig, pk, hash) into the
// canonical LP-218 P3Q wire format with the kind byte at offset 0
// per LP-220 §"Architectural decision — single slot, kind-byte
// dispatch".
func EncodeInputKind(kind, mode uint8, pulsarSig, groupPubKey, messageHash []byte) []byte {
out := make([]byte, 0, KindByte+ModeByte+LengthFieldSize+len(pulsarSig)+LengthFieldSize+len(groupPubKey)+MessageHashSize)
out = append(out, kind)
out = append(out, mode)
var b4 [4]byte
binary.BigEndian.PutUint32(b4[:], uint32(len(pulsarSig)))
out = append(out, b4[:]...)
out = append(out, pulsarSig...)
binary.BigEndian.PutUint32(b4[:], uint32(len(groupPubKey)))
out = append(out, b4[:]...)
out = append(out, groupPubKey...)
// messageHash MUST be exactly 32 bytes. If the caller passes a
// different length, the wire format is malformed; the precompile
// will reject. Surface that to the caller by writing whatever was
// passed (no silent padding / truncation).
out = append(out, messageHash...)
return out
}