diff --git a/chains/manager.go b/chains/manager.go index 1511dfc17..47faf721f 100644 --- a/chains/manager.go +++ b/chains/manager.go @@ -372,8 +372,11 @@ 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 - StakingBLSKey bls.Signer - TracingEnabled bool + // 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. Tracer trace.Tracer Log log.Logger @@ -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)) diff --git a/config/config.go b/config/config.go index d466dff34..9d87d4dfe 100644 --- a/config/config.go +++ b/config/config.go @@ -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 { diff --git a/config/node/config.go b/config/node/config.go index 62691ed14..3d6a51956 100644 --- a/config/node/config.go +++ b/config/node/config.go @@ -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"` diff --git a/node/node.go b/node/node.go index db1b204ba..8960531f5 100644 --- a/node/node.go +++ b/node/node.go @@ -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, diff --git a/vms/proposervm/block.go b/vms/proposervm/block.go index b9271b857..5a79f6db8 100644 --- a/vms/proposervm/block.go +++ b/vms/proposervm/block.go @@ -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 } diff --git a/vms/proposervm/pre_fork_block.go b/vms/proposervm/pre_fork_block.go index 695b4eb2f..e06fb34f5 100644 --- a/vms/proposervm/pre_fork_block.go +++ b/vms/proposervm/pre_fork_block.go @@ -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 } diff --git a/vms/proposervm/proposer/windower.go b/vms/proposervm/proposer/windower.go index 4fdaa0811..e615090e7 100644 --- a/vms/proposervm/proposer/windower.go +++ b/vms/proposervm/proposer/windower.go @@ -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 diff --git a/vms/proposervm/vm.go b/vms/proposervm/vm.go index 274bb4f48..47b7f6294 100644 --- a/vms/proposervm/vm.go +++ b/vms/proposervm/vm.go @@ -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