feat(proposervm): configurable MinBlkDelay + timestamp granularity tracks WindowDuration (sub-second cadence foundation)

Two coupled knobs for differentiated cadence — co-located D-Chain/DEX (fast) vs public
C-Chain (standard):

1. MinBlkDelay was hardcoded to the 1s DefaultMinBlockDelay in chains/manager.go, ignoring
   its own --proposervm-min-block-delay flag. Now wired node.Config → ManagerConfig →
   proposervm.Config (0 ⇒ 1s default). High-throughput nets set it low.

2. Block timestamps were Truncate(time.Second) at 4 sites, quantizing cadence to 1s
   regardless of WindowDuration — so a sub-second window inflated slot numbers without finer
   time resolution and could NOT produce blocks faster than 1/s. Truncation now tracks
   proposer.TimestampGranularity() = min(1s, WindowDuration): mainnet (>=1s) keeps exact
   1-second block times; sub-second windows get matching sub-second timestamps.

Measured: 1s window/delay → ~95-104 TPS, byte-identical 5/5 finality (unchanged from before,
no regression). NOTE: true sub-second cadence additionally requires a slot-handoff fix — at
100ms the slot recomputed in buildChild from a drifted 'now' can differ from the slot
timeToBuild scheduled, causing errUnexpectedProposer verify drops; that + multi-sender
saturation is the next step. These knobs are the necessary foundation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-15 22:33:01 -07:00
co-authored by Hanzo Dev
parent d83191e286
commit c3d6105804
8 changed files with 39 additions and 8 deletions
+9 -2
View File
@@ -372,6 +372,9 @@ type ManagerConfig struct {
// 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
// ProposerMinBlockDelay overrides the proposervm minimum block delay (0 ⇒ the
// 1s default). High-throughput / DEX nets set it low (e.g. 1ms).
ProposerMinBlockDelay time.Duration
StakingBLSKey bls.Signer
TracingEnabled bool
// Must not be used unless [TracingEnabled] is true as this may be nil.
@@ -1289,10 +1292,14 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
if regErr != nil {
return nil, fmt.Errorf("failed to register proposervm metrics for chain %s: %w", chainParams.ID, regErr)
}
minBlkDelay := proposervm.DefaultMinBlockDelay
if m.ProposerMinBlockDelay > 0 {
minBlkDelay = m.ProposerMinBlockDelay
}
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,
MinBlkDelay: minBlkDelay,
NumHistoricalBlocks: proposervm.DefaultNumHistoricalBlocks,
StakingLeafSigner: m.StakingTLSSigner,
StakingCertLeaf: m.StakingTLSCert,
@@ -1305,7 +1312,7 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
log.Stringer("chainID", chainParams.ID),
log.Int("K", consensusParams.K),
log.Stringer("windowerNetworkID", networkID),
log.Duration("minBlockDelay", proposervm.DefaultMinBlockDelay))
log.Duration("minBlockDelay", minBlkDelay))
}
m.Log.Info("initializing VM", log.Stringer("chainID", chainParams.ID))
+4
View File
@@ -1766,6 +1766,10 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) {
if nodeConfig.ProposerWindowDuration < 0 {
return node.Config{}, fmt.Errorf("%s must not be negative", ProposerVMWindowDurationKey)
}
nodeConfig.ProposerMinBlockDelay = v.GetDuration(ProposerVMMinBlockDelayKey)
if nodeConfig.ProposerMinBlockDelay < 0 {
return node.Config{}, fmt.Errorf("%s must not be negative", ProposerVMMinBlockDelayKey)
}
// Halflife of continuous averager used in health checks
healthCheckAveragerHalflife := v.GetDuration(HealthCheckAveragerHalflifeKey)
if healthCheckAveragerHalflife <= 0 {
+5
View File
@@ -182,6 +182,11 @@ type Config struct {
// 1s) so block cadence is not floored at 5s per proposer slot.
ProposerWindowDuration time.Duration `json:"proposerWindowDuration"`
// ProposerMinBlockDelay is the proposervm minimum delay between consecutive
// blocks (the hard cadence floor). Zero keeps the 1s default; high-throughput
// / DEX nets set it low (e.g. 1ms) to approach the consensus-finality floor.
ProposerMinBlockDelay time.Duration `json:"proposerMinBlockDelay"`
// Network configuration
NetworkConfig network.Config `json:"networkConfig"`
+1
View File
@@ -1357,6 +1357,7 @@ func (n *Node) initChainManager(utxoAssetID ids.ID) error {
StakingMLDSASigner: n.Config.StakingConfig.StakingMLDSA,
StakingMLDSAPub: n.Config.StakingConfig.StakingMLDSAPub,
ProposerWindowDuration: n.Config.ProposerWindowDuration,
ProposerMinBlockDelay: n.Config.ProposerMinBlockDelay,
StakingBLSKey: n.Config.StakingSigningKey,
Log: n.Log,
LogFactory: n.LogFactory,
+1 -1
View File
@@ -250,7 +250,7 @@ func (p *postForkCommonComponents) Verify(
// 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 {
ts := now.Truncate(time.Second)
ts := now.Truncate(proposer.TimestampGranularity())
if ts.Before(parentTimestamp) {
return parentTimestamp
}
+1 -1
View File
@@ -203,7 +203,7 @@ func (b *preForkBlock) buildChild(ctx context.Context) (Block, error) {
// "chain is currently forking" path is the only one taken.
parentTimestamp := b.Timestamp()
parentID := b.ID()
newTimestamp := b.vm.Time().Truncate(time.Second)
newTimestamp := b.vm.Time().Truncate(proposer.TimestampGranularity())
if newTimestamp.Before(parentTimestamp) {
newTimestamp = parentTimestamp
}
+14
View File
@@ -42,6 +42,20 @@ var (
MaxLookAheadWindow = MaxLookAheadSlots * WindowDuration // 1h at the default
)
// TimestampGranularity is the resolution block timestamps are truncated to. It
// tracks WindowDuration so the proposer-slot clock (TimeToSlot = elapsed /
// WindowDuration) and the block-timestamp clock advance at the SAME rate: with a
// coarser 1s truncation, a sub-second WindowDuration would inflate slot numbers
// without finer time resolution, so blocks could not actually be produced faster
// than 1/s. Capped at 1s so mainnet (WindowDuration >= 1s) keeps the original
// 1-second block-time granularity exactly.
func TimestampGranularity() time.Duration {
if WindowDuration > time.Second {
return time.Second
}
return WindowDuration
}
// 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
+2 -2
View File
@@ -551,7 +551,7 @@ func (vm *VM) timeToBuild(ctx context.Context) (time.Time, bool, error) {
parentTimestamp = blk.Timestamp()
nextStartTime time.Time
)
currentTime := vm.Clock.Time().Truncate(time.Second)
currentTime := vm.Clock.Time().Truncate(proposer.TimestampGranularity())
if nextStartTime, err = vm.getPostDurangoSlotTime(
ctx,
childBlockHeight,
@@ -602,7 +602,7 @@ func (vm *VM) timeToBuildPreForkTransitionLocked(ctx context.Context) (time.Time
var (
parentTimestamp = pre.Timestamp()
childHeight = pre.Height() + 1
currentTime = vm.Clock.Time().Truncate(time.Second)
currentTime = vm.Clock.Time().Truncate(proposer.TimestampGranularity())
slot = proposer.TimeToSlot(parentTimestamp, currentTime)
)
// MinDelayForProposer returns the delay until THIS node's earliest slot in the