mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
The module already called itself github.com/luxfi/node while containing no node in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and the Go column of the matrix measured a VM plugin. A repo that fetches the thing it claims to be has not replaced it, so the Go sources move in — 1515 files, the daemon and everything under it — and the build reaches outside this module for nothing. Rust and C++ sit beside it unchanged and are still measured against it by the differential. That is the point of one repo: the reference implementation and the two ports are the same checkout, so parity is a thing you can run rather than a thing you assert. Verified: builds, and the closure carries no lux-private — checked at build time rather than believed. The daemon starts twelve chains on a local network (P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
375 lines
14 KiB
Go
375 lines
14 KiB
Go
// 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 {
|
||
// Inner before outer, as in postForkBlock.Accept. Pre-fork the outer accept is
|
||
// a no-op, so the order costs nothing here and the two paths read alike.
|
||
if err := b.acceptInnerBlk(ctx); err != nil {
|
||
return err
|
||
}
|
||
return b.acceptOuterBlk()
|
||
}
|
||
|
||
// acceptOuterBlk is a NO-OP for a genuine pre-fork block — a pre-fork block has no
|
||
// outer envelope, so there is nothing to index — and a hard REFUSAL once the chain
|
||
// has forked.
|
||
//
|
||
// The refusal is the second half of the finality-index root-cause fix (the first is
|
||
// vm.getPreForkBlock). Accepting a pre-fork block at or above the fork height
|
||
// advances the inner VM while leaving the proposervm index exactly where it was:
|
||
// the index silently falls behind the inner tip during normal operation, and the
|
||
// NEXT boot cannot start the chain. Refusing here converts that invisible,
|
||
// deferred, fatal corruption into an immediate, attributable error at the exact
|
||
// accept that would have caused it — and the engine's accept path is fail-closed,
|
||
// so finality stops rather than diverging.
|
||
func (b *preForkBlock) acceptOuterBlk() error {
|
||
return b.vm.refusePreForkAfterFork(b.Height())
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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 does not crash the fleet. Resolving
|
||
// ExpectedProposer correctly on a sovereign L1 depends on the windower reading
|
||
// the L1's own validator set rather than 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 the
|
||
// per-height finality guard 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.
|
||
|
||
// The inner VM builds on ITS OWN head, and verifyPostForkChild below requires the
|
||
// transition block's inner parent to be exactly THIS block's inner block. Anchor the
|
||
// two together before delegating (see VM.anchorInnerBuildParent): at the fork height
|
||
// every validator may emit its own unsigned transition candidate, so a gossiped
|
||
// sibling that verifies here would otherwise drift the inner head and wedge the
|
||
// transition — the same self-rejection loop, at chain start.
|
||
// [innerHead] is held ACROSS the anchor and the build for the same reason as in
|
||
// postForkCommonComponents.buildChild: the anchor is consumed a ZAP round-trip
|
||
// later, and a concurrent inner Verify would move the head inside that window.
|
||
innerBlock, err := func() (chain.Block, error) {
|
||
b.vm.innerHead.Lock()
|
||
defer b.vm.innerHead.Unlock()
|
||
|
||
if err := b.vm.anchorInnerBuildParent(ctx, parentID, b.Block.ID()); err != nil {
|
||
return nil, err
|
||
}
|
||
if b.vm.blockBuilderVM != nil {
|
||
// VM supports BuildBlockWithRuntime
|
||
return b.vm.blockBuilderVM.BuildBlockWithRuntime(ctx, &runtime.Runtime{})
|
||
}
|
||
// VM doesn't support BuildBlockWithRuntime, use BuildBlock
|
||
return b.vm.ChainVM.BuildBlock(ctx)
|
||
}()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Fail closed on the artifact — see the equivalent check in
|
||
// postForkCommonComponents.buildChild. At the fork height every validator may emit
|
||
// its own transition candidate, so this is exactly where a drifted head is most
|
||
// likely and where an emitted-then-dropped block wedges the transition.
|
||
if innerBlock.Parent() != b.Block.ID() {
|
||
return nil, fmt.Errorf("%w: built inner %s extends %s, expected this block's inner %s",
|
||
errInnerParentMismatch, innerBlock.ID(), innerBlock.Parent(), b.Block.ID())
|
||
}
|
||
|
||
// 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)
|
||
}
|