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
+14 -11
View File
@@ -369,7 +369,10 @@ type ManagerConfig struct {
// Proposer() matches the ML-DSA-keyed validator set. Nil ⇒ classical TLS-leaf.
StakingMLDSASigner *mldsa.PrivateKey
StakingMLDSAPub []byte
StakingBLSKey bls.Signer
// ProposerWindowDuration overrides the proposervm proposer-slot spacing (0 ⇒
// the 5s mainnet default). Small local/dev nets set it low for fast cadence.
ProposerWindowDuration time.Duration
StakingBLSKey bls.Signer
TracingEnabled bool
// Must not be used unless [TracingEnabled] is true as this may be nil.
Tracer trace.Tracer
@@ -1287,15 +1290,16 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
return nil, fmt.Errorf("failed to register proposervm metrics for chain %s: %w", chainParams.ID, regErr)
}
engineVM = proposervm.New(vmTyped, proposervm.Config{
Upgrades: m.Upgrades,
NetworkID: networkID, // CRITICAL-3: windower uses the SAME set ID as the cert side
MinBlkDelay: proposervm.DefaultMinBlockDelay,
NumHistoricalBlocks: proposervm.DefaultNumHistoricalBlocks,
StakingLeafSigner: m.StakingTLSSigner,
StakingCertLeaf: m.StakingTLSCert,
StakingMLDSASigner: m.StakingMLDSASigner,
StakingMLDSAPub: m.StakingMLDSAPub,
Registerer: proposervmReg,
Upgrades: m.Upgrades,
NetworkID: networkID, // CRITICAL-3: windower uses the SAME set ID as the cert side
MinBlkDelay: proposervm.DefaultMinBlockDelay,
NumHistoricalBlocks: proposervm.DefaultNumHistoricalBlocks,
StakingLeafSigner: m.StakingTLSSigner,
StakingCertLeaf: m.StakingTLSCert,
StakingMLDSASigner: m.StakingMLDSASigner,
StakingMLDSAPub: m.StakingMLDSAPub,
ProposerWindowDuration: m.ProposerWindowDuration,
Registerer: proposervmReg,
})
m.Log.Info("wrapping chain VM in proposervm for single-proposer-per-height block production",
log.Stringer("chainID", chainParams.ID),
@@ -4333,7 +4337,6 @@ func (g *networkGossiper) BroadcastVote(chainID ids.ID, networkID ids.ID, blockI
// it fed no longer exists in the consensus engine (174af3c31). Nova sampling decides; the ⅔
// Quasar attestation (a plain accept-vote, gossiped via BroadcastVote) trails it. Keep the braid dead.
// GossipCert broadcasts an assembled α-of-K finality cert to ALL validators so
// followers finalize blockID on a verifiable proof (HandleIncomingCert), not a
// fast-follow guess. Best effort: the gossiping node's own finality is already
+4
View File
@@ -1762,6 +1762,10 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) {
if nodeConfig.HealthCheckFreq < 0 {
return node.Config{}, fmt.Errorf("%s must be positive", HealthCheckFreqKey)
}
nodeConfig.ProposerWindowDuration = v.GetDuration(ProposerVMWindowDurationKey)
if nodeConfig.ProposerWindowDuration < 0 {
return node.Config{}, fmt.Errorf("%s must not be negative", ProposerVMWindowDurationKey)
}
// Halflife of continuous averager used in health checks
healthCheckAveragerHalflife := v.GetDuration(HealthCheckAveragerHalflifeKey)
if healthCheckAveragerHalflife <= 0 {
+1
View File
@@ -369,6 +369,7 @@ func addNodeFlags(fs *pflag.FlagSet) {
// ProposerVM
fs.Bool(ProposerVMUseCurrentHeightKey, false, "Have the ProposerVM always report the last accepted P-chain block height")
fs.Duration(ProposerVMMinBlockDelayKey, proposervm.DefaultMinBlockDelay, "Minimum delay to enforce when building a chain++ block for the primary network chains and the default minimum delay for chains")
fs.Duration(ProposerVMWindowDurationKey, 0, "Proposer-slot spacing for block production (0 uses the 5s mainnet default); shrink for fast cadence on small/local networks")
// Metrics
fs.Bool(MeterVMsEnabledKey, true, "Enable Meter VMs to track VM performance with more granularity")
+1
View File
@@ -187,6 +187,7 @@ const (
ConsensusFrontierPollFrequencyKey = "consensus-frontier-poll-frequency"
ProposerVMUseCurrentHeightKey = "proposervm-use-current-height"
ProposerVMMinBlockDelayKey = "proposervm-min-block-delay"
ProposerVMWindowDurationKey = "proposervm-window-duration"
FdLimitKey = "fd-limit"
IndexEnabledKey = "index-enabled"
IndexAllowIncompleteKey = "index-allow-incomplete"
+5
View File
@@ -177,6 +177,11 @@ type Config struct {
// Health
HealthCheckFreq time.Duration `json:"healthCheckFreq"`
// ProposerWindowDuration overrides the proposervm proposer-slot spacing.
// Zero keeps the 5s mainnet default; small local/dev nets set it low (e.g.
// 1s) so block cadence is not floored at 5s per proposer slot.
ProposerWindowDuration time.Duration `json:"proposerWindowDuration"`
// Network configuration
NetworkConfig network.Config `json:"networkConfig"`
+1 -1
View File
@@ -26,7 +26,7 @@ require (
github.com/huin/goupnp v1.3.0
github.com/jackpal/gateway v1.1.1
github.com/jackpal/go-nat-pmp v1.0.2
github.com/luxfi/consensus v1.36.6
github.com/luxfi/consensus v1.36.7
github.com/luxfi/crypto v1.20.2
github.com/luxfi/database v1.21.1
github.com/luxfi/ids v1.3.2
+2 -10
View File
@@ -315,16 +315,8 @@ github.com/luxfi/compress v0.1.1 h1:cQjRYQFRrw8HinjW1T8FmKgqdRwrFYDCdecc1t0i+uQ=
github.com/luxfi/compress v0.1.1/go.mod h1:d4CkRRmwPzafIp57Jxra3RmAmNwNwU8Oc0QBBRlTqGo=
github.com/luxfi/concurrent v0.1.1 h1:LkAmNXybOsAm97nFILqMDBp/YLgleuLmyGomFT7YNxU=
github.com/luxfi/concurrent v0.1.1/go.mod h1:GlkfiJtPBpatNxvROf7hkPi4gsnmdHGmqhDnVWFrkZE=
github.com/luxfi/consensus v1.36.2 h1:IbeWQF1wEcA/MNxe4fKniSICPndAGEysV/A3DZGMXFM=
github.com/luxfi/consensus v1.36.2/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.3 h1:pEfHf7asnIKQ2Dr2tWe5nKv/0lRRa/iAQ9IyevcRU3s=
github.com/luxfi/consensus v1.36.3/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.4 h1:Pe6srq5vD+ObRQRR8L1F/QVvsW+F86BJMKEiofz7qeQ=
github.com/luxfi/consensus v1.36.4/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.5 h1:kVJp8celtf2RJmMYkkELnrqNXu7HGLmFC+aR6LsBZ34=
github.com/luxfi/consensus v1.36.5/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.6 h1:iXmYQ20A7j8spZldfU/1cbabqolniec/WTseBDH18WQ=
github.com/luxfi/consensus v1.36.6/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.7 h1:ijJh/4sl1Y65ziUFsQJa2DLwnAhb1h/c72FHybEXKps=
github.com/luxfi/consensus v1.36.7/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/constants v1.6.2 h1:pXHdKIFbfE9qX4xOjq2LxYvagNhhNvspUVEbPcIEKfA=
github.com/luxfi/constants v1.6.2/go.mod h1:r0oH8C/+r/XFYBq1AJxt6zWRKKRKgDzrEMop/CCs9rI=
github.com/luxfi/container v0.2.1 h1:MTnfKXzS5+oxV5jKZerdOxSA6iMPaQI9/FWGufizzaw=
+1
View File
@@ -1356,6 +1356,7 @@ func (n *Node) initChainManager(utxoAssetID ids.ID) error {
StakingTLSCert: n.StakingTLSCert,
StakingMLDSASigner: n.Config.StakingConfig.StakingMLDSA,
StakingMLDSAPub: n.Config.StakingConfig.StakingMLDSAPub,
ProposerWindowDuration: n.Config.ProposerWindowDuration,
StakingBLSKey: n.Config.StakingSigningKey,
Log: n.Log,
LogFactory: n.LogFactory,
+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