Files
node/vms/proposervm/pre_fork_block.go
T
zeekayandHanzo Dev c3d6105804 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>
2026-07-15 22:33:01 -07:00

343 lines
11 KiB
Go
Raw 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) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package proposervm
import (
"context"
"errors"
"fmt"
"time"
"github.com/luxfi/log"
"github.com/luxfi/ids"
"github.com/luxfi/node/vms/proposervm/block"
"github.com/luxfi/node/vms/proposervm/lp181"
"github.com/luxfi/node/vms/proposervm/proposer"
"github.com/luxfi/runtime"
chain "github.com/luxfi/vm/chain"
)
var (
_ Block = (*preForkBlock)(nil)
errChildOfPreForkBlockHasProposer = errors.New("child of pre-fork block has proposer")
)
type preForkBlock struct {
chain.Block
vm *VM
}
// EpochBit returns the epoch bit for FPC
func (b *preForkBlock) EpochBit() bool {
// Forward to inner block if it supports it
if innerBlk, ok := b.Block.(interface{ EpochBit() bool }); ok {
return innerBlk.EpochBit()
}
return false
}
// FPCVotes returns embedded fast-path vote references
func (b *preForkBlock) FPCVotes() [][]byte {
// Forward to inner block if it supports it
if innerBlk, ok := b.Block.(interface{ FPCVotes() [][]byte }); ok {
return innerBlk.FPCVotes()
}
return nil
}
// Timestamp returns the timestamp of the inner block
func (b *preForkBlock) Timestamp() time.Time {
// Forward to inner block if it supports it
if innerBlk, ok := b.Block.(interface{ Timestamp() time.Time }); ok {
return innerBlk.Timestamp()
}
// Fallback to current time
return b.vm.Time()
}
func (b *preForkBlock) Accept(ctx context.Context) error {
if err := b.acceptOuterBlk(); err != nil {
return err
}
return b.acceptInnerBlk(ctx)
}
func (*preForkBlock) acceptOuterBlk() error {
return nil
}
func (b *preForkBlock) acceptInnerBlk(ctx context.Context) error {
return b.Block.Accept(ctx)
}
func (b *preForkBlock) Verify(ctx context.Context) error {
parent, err := b.vm.getPreForkBlock(ctx, b.Block.Parent())
if err != nil {
return err
}
return parent.verifyPreForkChild(ctx, b)
}
func (b *preForkBlock) Options(ctx context.Context) ([2]chain.Block, error) {
oracleBlk, ok := b.Block.(OracleBlock)
if !ok {
return [2]chain.Block{}, errNotOracle
}
options, err := oracleBlk.Options(ctx)
if err != nil {
return [2]chain.Block{}, err
}
// A pre-fork block's child options are always pre-fork blocks
return [2]chain.Block{
&preForkBlock{
Block: options[0],
vm: b.vm,
},
&preForkBlock{
Block: options[1],
vm: b.vm,
},
}, nil
}
func (b *preForkBlock) getInnerBlk() chain.Block {
return b.Block
}
func (b *preForkBlock) verifyPreForkChild(ctx context.Context, child *preForkBlock) error {
// preFork children are only allowed when the parent is an oracle block.
if err := verifyIsOracleBlock(ctx, b.Block); err != nil {
return errUnexpectedBlockType
}
b.vm.logger.Debug("allowing pre-fork block after the fork time",
log.String("reason", "parent is an oracle block"),
log.Stringer("blkID", b.ID()),
)
return child.Block.Verify(ctx)
}
// This method only returns nil once (during the transition)
func (b *preForkBlock) verifyPostForkChild(ctx context.Context, child *postForkBlock) error {
// FIX 4: Oracle parent validation - check if parent is oracle
parentIsOracle := verifyIsOracleBlock(ctx, b.Block) == nil
if parentIsOracle && child.SignedBlock.Proposer() != ids.EmptyNodeID {
return errChildOfPreForkBlockHasProposer
}
if err := verifyIsNotOracleBlock(ctx, b.Block); err != nil {
return err
}
childID := child.ID()
childPChainHeight := child.PChainHeight()
currentPChainHeight, err := b.vm.validatorState.GetCurrentHeight(ctx)
if err != nil {
b.vm.logger.Error("block verification failed",
log.String("reason", "failed to get current P-Chain height"),
log.Stringer("blkID", childID),
log.Err(err),
)
return err
}
if childPChainHeight > currentPChainHeight {
return fmt.Errorf("%w: %d > %d",
errPChainHeightNotReached,
childPChainHeight,
currentPChainHeight,
)
}
// Make sure [b] is the parent of [child]'s inner block
expectedInnerParentID := b.ID()
innerParentID := child.innerBlk.Parent()
if innerParentID != expectedInnerParentID {
return errInnerParentMismatch
}
// pre-fork → post-fork transition condition is permanently satisfied.
parentTimestamp := b.Timestamp()
// Child's timestamp must be at or after its parent's timestamp
childTimestamp := child.Timestamp()
if childTimestamp.Before(parentTimestamp) {
return errTimeNotMonotonic
}
// Validate epoch (LP-181 epoching, always-on under activate-all-implicitly).
// Pre-fork blocks always have empty epoch, so use that as parent epoch
parentEpoch := block.Epoch{} // Pre-fork blocks have no epoch
childEpoch := child.PChainEpoch()
// For pre-fork blocks, we don't have explicit P-chain height tracking.
// We use 0 as the parent P-chain height for genesis/pre-fork blocks.
parentPChainHeight := uint64(0)
expectedEpoch := lp181.NewEpoch(b.vm.Upgrades, parentPChainHeight, parentEpoch, parentTimestamp, childTimestamp)
if childEpoch != expectedEpoch {
return fmt.Errorf("%w: epoch %v != expected %v", errEpochMismatch, childEpoch, expectedEpoch)
}
// Child timestamp can't be too far in the future
maxTimestamp := b.vm.Time().Add(maxSkew)
if childTimestamp.After(maxTimestamp) {
return errTimeTooAdvanced
}
// Verify the lack of signature on the node
if child.SignedBlock.Proposer() != ids.EmptyNodeID {
return errChildOfPreForkBlockHasProposer
}
// Verify the inner block and track it as verified
return b.vm.verifyAndRecordInnerBlk(ctx, nil, child)
}
func (*preForkBlock) verifyPostForkOption(context.Context, *postForkOption) error {
return errUnexpectedBlockType
}
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(proposer.TimestampGranularity())
if newTimestamp.Before(parentTimestamp) {
newTimestamp = parentTimestamp
}
// The child's P-Chain height is proposed as the optimal P-Chain height
// available; under activate-all-implicitly there is no historical
// minimum-height floor.
pChainHeight, err := b.vm.selectChildPChainHeight(ctx, 0)
if err != nil {
b.vm.logger.Error("unexpected build block failure",
log.String("reason", "failed to calculate optimal P-chain height"),
log.Stringer("parentID", parentID),
log.Err(err),
)
return nil, err
}
// CRITICAL-1: single-proposer transition. The pre-fork → post-fork transition
// block (the first post-fork block, child of the last pre-fork block) MUST stay
// UNSIGNED — verifyPostForkChild rejects a signed transition
// (errChildOfPreForkBlockHasProposer), and an unsigned block carries no
// verifiable proposer binding, so the wire format cannot be made single-proposer
// by signing it. But WHO builds it can and MUST be gated. Without this gate every
// validator builds its OWN unsigned transition block stamped with its LOCAL
// wall-clock second (newTimestamp); a fleet that crosses into Ready at different
// instants then emits DIFFERENT height-(N+1) blocks → two valid blocks at one
// height → a FORK at chain start, which every fresh net (devnet/Zoo/Hanzo) and
// every existing-chain upgrade traverses. Gate the builder with the SAME windower
// the post-fork path uses (shouldBuildSignedBlockPostDurango): only the elected
// proposer for the CURRENT slot builds; as wall-clock advances the eligible set
// widens (slot progression), so a down leader does not stall the transition —
// liveness is provided by vm.timeToBuild windowing the wait. Non-leaders return
// WITHOUT building and adopt the leader's gossiped transition block through the
// α-of-K cert path. This makes the HONEST fleet emit exactly ONE transition block.
// The residual case — a Byzantine node publishing a competing UNSIGNED transition
// block (which verifyPostForkChild still admits, since an unsigned block cannot be
// bound to a proposer) — is rendered SAFE by the per-height finality guard (only
// one block finalizes at a height) and no longer crashes the fleet (consensus
// CRITICAL-2). Correct resolution of ExpectedProposer on a sovereign L1 depends on
// CRITICAL-3 (the windower reading the L1's own validator set, not an empty
// primary set).
childHeight := b.Height() + 1
slot := proposer.TimeToSlot(parentTimestamp, newTimestamp)
expectedProposerID, err := b.vm.Windower.ExpectedProposer(ctx, childHeight, pChainHeight, slot)
switch {
case errors.Is(err, proposer.ErrAnyoneCanPropose):
// No proposer schedule (empty/degenerate validator set — e.g. K==1, or a
// chain whose windower set is not yet populated). Fall through to the legacy
// unsigned build: single-proposer cannot hold without a schedule, and
// CRITICAL-2 makes the residual equivocation survivable.
case err != nil:
b.vm.logger.Error("unexpected build block failure",
log.String("reason", "failed to calculate expected transition proposer"),
log.Stringer("parentID", parentID),
log.Err(err),
)
return nil, err
case expectedProposerID != b.vm.rt.NodeID:
// Not our turn at this slot — DO NOT build. vm.timeToBuild windows the wait
// so we adopt the elected leader's gossiped transition block; a later slot
// elects us iff the leader is down.
b.vm.logger.Debug("transition build dropped: not our slot",
log.Stringer("parentID", parentID),
log.Uint64("childHeight", childHeight),
log.Uint64("slot", slot),
log.Stringer("expectedProposer", expectedProposerID),
)
return nil, fmt.Errorf("%w: slot %d expects %s", errUnexpectedProposer, slot, expectedProposerID)
}
// else: we ARE the elected proposer for this slot — build the unsigned block.
var innerBlock chain.Block
if b.vm.blockBuilderVM != nil {
// VM supports BuildBlockWithRuntime
builtBlock, err := b.vm.blockBuilderVM.BuildBlockWithRuntime(ctx, &runtime.Runtime{})
if err != nil {
return nil, err
}
innerBlock = builtBlock
} else {
// VM doesn't support BuildBlockWithRuntime, use BuildBlock
engineBlock, err := b.vm.ChainVM.BuildBlock(ctx)
if err != nil {
return nil, err
}
innerBlock = engineBlock
}
// Calculate the epoch for the child block (LP-181, always-on under
// activate-all-implicitly).
parentEpoch := block.Epoch{} // Pre-fork blocks have no epoch
// For pre-fork blocks, we don't have explicit P-chain height tracking.
// We use 0 as the parent P-chain height for genesis/pre-fork blocks.
parentPChainHeight := uint64(0)
childEpoch := lp181.NewEpoch(b.vm.Upgrades, parentPChainHeight, parentEpoch, parentTimestamp, newTimestamp)
statelessBlock, err := block.BuildUnsigned(
parentID,
newTimestamp,
pChainHeight,
childEpoch,
innerBlock.Bytes(),
)
if err != nil {
return nil, err
}
blk := &postForkBlock{
SignedBlock: statelessBlock,
postForkCommonComponents: postForkCommonComponents{
vm: b.vm,
innerBlk: innerBlock,
},
}
b.vm.logger.Info("built block",
log.Stringer("blkID", blk.ID()),
log.Stringer("innerBlkID", innerBlock.ID()),
log.Uint64("height", blk.Height()),
log.Uint64("pChainHeight", pChainHeight),
log.Time("parentTimestamp", parentTimestamp),
log.Time("blockTimestamp", newTimestamp))
return blk, nil
}
func (*preForkBlock) pChainHeight(context.Context) (uint64, error) {
return 0, nil
}
func (*preForkBlock) pChainEpoch(context.Context) (chain.Epoch, error) {
return chain.Epoch{}, nil
}
func (b *preForkBlock) selectChildPChainHeight(ctx context.Context) (uint64, error) {
return b.vm.selectChildPChainHeight(ctx, 0)
}