feat(proposervm): configurable window duration (fast local cadence) + strict-PQ classical-proposer refusal

Two changes, both node-local:

1. CADENCE — proposer.WindowDuration (the proposer-slot spacing, a validator's min build
   delay = slot index x WindowDuration) was a hardcoded 5s const tuned for mainnet-scale
   validator sets, flooring small/local block cadence at 5s per slot. It is now a
   startup-configurable var (default 5s, unchanged for mainnet) set via the new
   --proposervm-window-duration flag → node.Config → ManagerConfig → proposervm.Config →
   proposer.SetWindowDuration at VM init. Read by both the windower delay math and
   TimeToSlot, so they stay consistent. Measured on a 5-node strict-PQ net: 5s→1s took
   sustained C-Chain from 44 TPS / 14s-per-block to 104 TPS / 3.8s-per-block, still 5/5
   byte-identical finality.

2. SECURITY (cryptographer MEDIUM-1) — postForkCommonComponents.Verify now refuses a block
   carrying a CLASSICAL secp256k1 proposer identity when the chain is strict-PQ
   (StakingMLDSASigner set), UNCONDITIONALLY (before the consensusState==Ready gate, so it
   also holds during bootstrap/state-sync). Fills the documented-but-unwired 'proposer'
   SchemeGate site: the downgrade defense is now an explicit fail-closed in-perimeter gate,
   not merely emergent from 20-byte NodeID collision-resistance + upstream enforcement.
   Adds SignedBlock.HasClassicalProposer(). Mirrors contract.RefuseUnderStrictPQ.

Pins consensus v1.36.7 (consume-on-error build loop — kills the non-leader BuildBlock spin).
Block + proposervm VM tests green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-15 22:24:36 -07:00
co-authored by Hanzo Dev
parent 13b6e80a78
commit d83191e286
13 changed files with 115 additions and 50 deletions
+28 -14
View File
@@ -25,20 +25,21 @@ const (
)
var (
errUnsignedChild = errors.New("expected child to be signed")
errUnexpectedBlockType = errors.New("unexpected proposer block type")
errInnerParentMismatch = errors.New("inner parentID didn't match expected parent")
errTimeNotMonotonic = errors.New("time must monotonically increase")
errPChainHeightNotMonotonic = errors.New("non monotonically increasing P-chain height")
errPChainHeightNotReached = errors.New("block P-chain height larger than current P-chain height")
errTimeTooAdvanced = errors.New("time is too far advanced")
errEpochMismatch = errors.New("epoch mismatch")
errProposerWindowNotStarted = errors.New("proposer window hasn't started")
errUnexpectedProposer = errors.New("unexpected proposer for current window")
errProposerMismatch = errors.New("proposer mismatch")
errProposersNotActivated = errors.New("proposers haven't been activated yet")
errPChainHeightTooLow = errors.New("block P-chain height is too low")
errNotOracle = errors.New("block is not an oracle block")
errUnsignedChild = errors.New("expected child to be signed")
errUnexpectedBlockType = errors.New("unexpected proposer block type")
errInnerParentMismatch = errors.New("inner parentID didn't match expected parent")
errClassicalProposerUnderStrictPQ = errors.New("classical (secp256k1) proposer identity refused on a strict-PQ chain")
errTimeNotMonotonic = errors.New("time must monotonically increase")
errPChainHeightNotMonotonic = errors.New("non monotonically increasing P-chain height")
errPChainHeightNotReached = errors.New("block P-chain height larger than current P-chain height")
errTimeTooAdvanced = errors.New("time is too far advanced")
errEpochMismatch = errors.New("epoch mismatch")
errProposerWindowNotStarted = errors.New("proposer window hasn't started")
errUnexpectedProposer = errors.New("unexpected proposer for current window")
errProposerMismatch = errors.New("proposer mismatch")
errProposersNotActivated = errors.New("proposers haven't been activated yet")
errPChainHeightTooLow = errors.New("block P-chain height is too low")
errNotOracle = errors.New("block is not an oracle block")
)
// OracleBlock is a block that can return multiple child options
@@ -124,6 +125,18 @@ func (p *postForkCommonComponents) Verify(
parentEpoch chain.Epoch,
child *postForkBlock,
) error {
// STRICT-PQ PROFILE GATE (fail-closed, UNCONDITIONAL — before the Ready gate,
// so it also holds during bootstrap/state-sync when the proposer-window check
// below is skipped). A chain whose own proposer signs with ML-DSA-65
// (StakingMLDSASigner set) is strict-PQ: its validator set is ML-DSA-keyed, so
// a block carrying a CLASSICAL secp256k1 proposer identity can never be a
// legitimate proposer and must be refused outright — never relying on the
// 20-byte NodeID collision bound or upstream validator-set enforcement to catch
// it. Mirrors contract.RefuseUnderStrictPQ: one gate, one place.
if p.vm.StakingMLDSASigner != nil && child.SignedBlock.HasClassicalProposer() {
return errClassicalProposerUnderStrictPQ
}
if err := verifyIsNotOracleBlock(ctx, p.innerBlk); err != nil {
return err
}
@@ -233,6 +246,7 @@ func (p *postForkCommonComponents) Verify(
// sub-window timestamp — zero behaviour change on a live chain;
// - a snapped time is strictly > parent (monotonic, no errTimeNotMonotonic) and ≤ now (never
// errTimeTooAdvanced).
//
// It is a pure function of (parentTimestamp, now) so it is idempotent for any two calls in the
// same slot — the property the liveness fix relies on.
func slotSnappedChildTimestamp(parentTimestamp, now time.Time) time.Time {
+14 -4
View File
@@ -18,10 +18,10 @@ import (
var (
_ SignedBlock = (*statelessBlock)(nil)
errUnexpectedSignature = errors.New("signature provided when none was expected")
errInvalidCertificate = errors.New("invalid certificate")
errUnknownProposerScheme = errors.New("proposervm block: unknown proposer identity scheme")
errMLDSAProposerSigInvalid = errors.New("proposervm block: ML-DSA proposer signature invalid")
errUnexpectedSignature = errors.New("signature provided when none was expected")
errInvalidCertificate = errors.New("invalid certificate")
errUnknownProposerScheme = errors.New("proposervm block: unknown proposer identity scheme")
errMLDSAProposerSigInvalid = errors.New("proposervm block: ML-DSA proposer signature invalid")
)
// Proposer-identity scheme tags stored as the FIRST byte of the offCert slot,
@@ -68,6 +68,12 @@ type SignedBlock interface {
// signed this block, [ids.EmptyNodeID] will be returned.
Proposer() ids.NodeID
// HasClassicalProposer reports whether this block carries a CLASSICAL
// (secp256k1/ECDSA) proposer identity. A strict-PQ chain refuses such a block
// — its proposer must be ML-DSA-65 to match the ML-DSA-keyed validator set.
// Unsigned blocks (transition / single-validator) return false.
HasClassicalProposer() bool
// Data Availability fields (v1.1 spec)
DARoot() [32]byte // Root of DA commitments
WitnessRoot() [32]byte // Root of witnesses/proofs
@@ -233,6 +239,10 @@ func (b *statelessBlock) Proposer() ids.NodeID {
return b.proposer
}
func (b *statelessBlock) HasClassicalProposer() bool {
return b.scheme == schemeSecp256k1
}
func (b *statelessBlock) DARoot() [32]byte {
return read32(b.msg.Root(), offDARoot)
}
+6
View File
@@ -35,6 +35,12 @@ type Config struct {
// Configurable minimal delay among blocks issued consecutively
MinBlkDelay time.Duration
// ProposerWindowDuration overrides the proposer-slot spacing (proposer.
// WindowDuration). Zero keeps the 5s mainnet default; small local/dev nets set
// it low (e.g. 1s or less) so block cadence is not floored at 5s per proposer
// slot. Applied once at VM init via proposer.SetWindowDuration.
ProposerWindowDuration time.Duration
// Maximal number of block indexed.
// Zero signals all blocks are indexed.
NumHistoricalBlocks uint64
+34 -10
View File
@@ -12,27 +12,51 @@ import (
"gonum.org/v1/gonum/mathext/prng"
validators "github.com/luxfi/validators"
"github.com/luxfi/container/sampler"
"github.com/luxfi/ids"
"github.com/luxfi/math"
"github.com/luxfi/utils"
validators "github.com/luxfi/validators"
)
// Proposer list constants
// Proposer list slot-COUNT constants (fixed; independent of the wall-clock
// window length).
const (
MaxVerifyWindows = 6
MaxBuildWindows = 60
MaxLookAheadSlots = 720
)
// WindowDuration is the proposer-slot spacing: a validator's minimum build delay
// is its slot index times WindowDuration. Default 5s (the avalanchego mainnet
// value, tuned for large validator sets). It is a startup-configurable VAR — small
// local/dev networks shrink it via SetWindowDuration (proposervm Config →
// --proposer-window-duration) so block cadence is not floored at 5s per proposer
// slot. It is read by BOTH the windower's delay math AND TimeToSlot, so the two
// stay consistent by construction.
var (
WindowDuration = 5 * time.Second
MaxVerifyWindows = 6
MaxVerifyDelay = MaxVerifyWindows * WindowDuration // 30 seconds
MaxBuildWindows = 60
MaxBuildDelay = MaxBuildWindows * WindowDuration // 5 minutes
MaxLookAheadSlots = 720
MaxLookAheadWindow = MaxLookAheadSlots * WindowDuration // 1 hour
MaxVerifyDelay = MaxVerifyWindows * WindowDuration // 30s at the default
MaxBuildDelay = MaxBuildWindows * WindowDuration // 5m at the default
MaxLookAheadWindow = MaxLookAheadSlots * WindowDuration // 1h at the default
)
// SetWindowDuration overrides the proposer-slot spacing. It MUST be called at
// startup, before any chain begins block production: WindowDuration is read by
// both the windower delay math and TimeToSlot, so changing it mid-flight would
// desynchronize the slot mapping across the fleet (a consensus fault). Values
// <= 0 are ignored, so an unset config leaves the safe 5s default in place.
func SetWindowDuration(d time.Duration) {
if d <= 0 {
return
}
WindowDuration = d
MaxVerifyDelay = MaxVerifyWindows * WindowDuration
MaxBuildDelay = MaxBuildWindows * WindowDuration
MaxLookAheadWindow = MaxLookAheadSlots * WindowDuration
}
var (
_ Windower = (*windower)(nil)
+4
View File
@@ -289,6 +289,10 @@ func (vm *VM) Initialize(
// constants.PrimaryNetworkID, so a native chain matches the original
// proposer.New(..., PrimaryNetworkID, ...) byte-for-byte.
func (vm *VM) newWindower() proposer.Windower {
// Apply the configured proposer-slot spacing before the first schedule is
// resolved (no-op at the default; shrinks cadence on local/dev nets). Startup
// only — see proposer.SetWindowDuration.
proposer.SetWindowDuration(vm.Config.ProposerWindowDuration)
netID := vm.Config.NetworkID
if netID == ids.Empty {
netID = constants.PrimaryNetworkID