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.
331 lines
9.8 KiB
Go
331 lines
9.8 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package proposer
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"math/bits"
|
|
"time"
|
|
|
|
"gonum.org/v1/gonum/mathext/prng"
|
|
|
|
"github.com/luxfi/container/sampler"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/math"
|
|
"github.com/luxfi/util"
|
|
validators "github.com/luxfi/validators"
|
|
)
|
|
|
|
// 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 settled 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
|
|
|
|
MaxVerifyDelay = MaxVerifyWindows * WindowDuration // 30s at the default
|
|
MaxBuildDelay = MaxBuildWindows * WindowDuration // 5m at the default
|
|
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
|
|
// 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)
|
|
|
|
ErrAnyoneCanPropose = errors.New("anyone can propose")
|
|
ErrUnexpectedSamplerFailure = errors.New("unexpected sampler failure")
|
|
)
|
|
|
|
type Windower interface {
|
|
// Proposers returns the proposer list for building a block at [blockHeight]
|
|
// when the validator set is defined at [pChainHeight]. The list is returned
|
|
// in order. The minimum delay of a validator is the index they appear times
|
|
// [WindowDuration].
|
|
Proposers(
|
|
ctx context.Context,
|
|
blockHeight,
|
|
pChainHeight uint64,
|
|
maxWindows int,
|
|
) ([]ids.NodeID, error)
|
|
|
|
// Delay returns the amount of time that [validatorID] must wait before
|
|
// building a block at [blockHeight] when the validator set is defined at
|
|
// [pChainHeight].
|
|
Delay(
|
|
ctx context.Context,
|
|
blockHeight,
|
|
pChainHeight uint64,
|
|
validatorID ids.NodeID,
|
|
maxWindows int,
|
|
) (time.Duration, error)
|
|
|
|
// In the current per-slot windowing scheme, every validator active at
|
|
// [pChainHeight] gets specific slots it can propose in (instead of being
|
|
// able to propose from a given time on as in the legacy time-window
|
|
// scheme).
|
|
// [ExpectedProposer] calculates which nodeID is scheduled to propose a
|
|
// block of height [blockHeight] at [slot].
|
|
// If no validators are currently available, [ErrAnyoneCanPropose] is
|
|
// returned.
|
|
ExpectedProposer(
|
|
ctx context.Context,
|
|
blockHeight,
|
|
pChainHeight,
|
|
slot uint64,
|
|
) (ids.NodeID, error)
|
|
|
|
// In the current per-slot windowing scheme, every validator active at
|
|
// [pChainHeight] gets specific slots it can propose in (instead of being
|
|
// able to propose from a given time on as in the legacy time-window
|
|
// scheme).
|
|
// [MinDelayForProposer] specifies how long [nodeID] needs to wait for its
|
|
// slot to start. Delay is specified as starting from slot zero start.
|
|
// (which is parent timestamp). For efficiency reasons, we cap the slot
|
|
// search to [MaxLookAheadSlots].
|
|
// If no validators are currently available, [ErrAnyoneCanPropose] is
|
|
// returned.
|
|
MinDelayForProposer(
|
|
ctx context.Context,
|
|
blockHeight,
|
|
pChainHeight uint64,
|
|
nodeID ids.NodeID,
|
|
startSlot uint64,
|
|
) (time.Duration, error)
|
|
}
|
|
|
|
// windower interfaces with P-Chain and it is responsible for calculating the
|
|
// delay for the block submission window of a given validator
|
|
type windower struct {
|
|
state validators.State
|
|
netID ids.ID
|
|
chainSource uint64
|
|
}
|
|
|
|
func New(state validators.State, netID, chainID ids.ID) Windower {
|
|
// chainSource seeds the per-chain PRNG for proposer selection: the first 8
|
|
// bytes of the chainID read little-endian (byte-for-byte the value the
|
|
// retired pcodecs.Packer.UnpackLong produced).
|
|
return &windower{
|
|
state: state,
|
|
netID: netID,
|
|
chainSource: binary.LittleEndian.Uint64(chainID[:8]),
|
|
}
|
|
}
|
|
|
|
func (w *windower) Proposers(ctx context.Context, blockHeight, pChainHeight uint64, maxWindows int) ([]ids.NodeID, error) {
|
|
// Note: The 32-bit prng is used here for legacy reasons. All other usages
|
|
// of a prng in this file should use the 64-bit version.
|
|
source := prng.NewMT19937()
|
|
sampler, validators, err := w.makeSampler(ctx, pChainHeight, source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var totalWeight uint64
|
|
for _, validator := range validators {
|
|
totalWeight, err = math.Add64(totalWeight, validator.weight)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
source.Seed(w.chainSource ^ blockHeight)
|
|
|
|
numToSample := int(min(uint64(maxWindows), totalWeight))
|
|
indices, ok := sampler.Sample(numToSample)
|
|
if !ok {
|
|
return nil, ErrUnexpectedSamplerFailure
|
|
}
|
|
|
|
nodeIDs := make([]ids.NodeID, numToSample)
|
|
for i, index := range indices {
|
|
nodeIDs[i] = validators[index].id
|
|
}
|
|
return nodeIDs, nil
|
|
}
|
|
|
|
func (w *windower) Delay(ctx context.Context, blockHeight, pChainHeight uint64, validatorID ids.NodeID, maxWindows int) (time.Duration, error) {
|
|
if validatorID == ids.EmptyNodeID {
|
|
return time.Duration(maxWindows) * WindowDuration, nil
|
|
}
|
|
|
|
proposers, err := w.Proposers(ctx, blockHeight, pChainHeight, maxWindows)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
delay := time.Duration(0)
|
|
for _, nodeID := range proposers {
|
|
if nodeID == validatorID {
|
|
return delay, nil
|
|
}
|
|
delay += WindowDuration
|
|
}
|
|
return delay, nil
|
|
}
|
|
|
|
func (w *windower) ExpectedProposer(
|
|
ctx context.Context,
|
|
blockHeight,
|
|
pChainHeight,
|
|
slot uint64,
|
|
) (ids.NodeID, error) {
|
|
source := prng.NewMT19937_64()
|
|
sampler, validators, err := w.makeSampler(ctx, pChainHeight, source)
|
|
if err != nil {
|
|
return ids.EmptyNodeID, err
|
|
}
|
|
if len(validators) == 0 {
|
|
return ids.EmptyNodeID, ErrAnyoneCanPropose
|
|
}
|
|
|
|
return w.expectedProposer(
|
|
validators,
|
|
source,
|
|
sampler,
|
|
blockHeight,
|
|
slot,
|
|
)
|
|
}
|
|
|
|
func (w *windower) MinDelayForProposer(
|
|
ctx context.Context,
|
|
blockHeight,
|
|
pChainHeight uint64,
|
|
nodeID ids.NodeID,
|
|
startSlot uint64,
|
|
) (time.Duration, error) {
|
|
source := prng.NewMT19937_64()
|
|
sampler, validators, err := w.makeSampler(ctx, pChainHeight, source)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(validators) == 0 {
|
|
return 0, ErrAnyoneCanPropose
|
|
}
|
|
|
|
maxSlot := startSlot + MaxLookAheadSlots
|
|
for slot := startSlot; slot < maxSlot; slot++ {
|
|
expectedNodeID, err := w.expectedProposer(
|
|
validators,
|
|
source,
|
|
sampler,
|
|
blockHeight,
|
|
slot,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if expectedNodeID == nodeID {
|
|
return time.Duration(slot) * WindowDuration, nil
|
|
}
|
|
}
|
|
|
|
// no slots scheduled for the max window we inspect. Return max delay
|
|
return time.Duration(maxSlot) * WindowDuration, nil
|
|
}
|
|
|
|
func (w *windower) makeSampler(
|
|
ctx context.Context,
|
|
pChainHeight uint64,
|
|
source sampler.Source,
|
|
) (sampler.WeightedWithoutReplacement, []validatorData, error) {
|
|
// Get the canonical representation of the validator set at the provided
|
|
// p-chain height.
|
|
validatorsMap, err := w.state.GetValidatorSet(context.Background(), pChainHeight, w.netID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
delete(validatorsMap, ids.EmptyNodeID) // Ignore inactive LP-77 validators.
|
|
|
|
validators := make([]validatorData, 0, len(validatorsMap))
|
|
for k, v := range validatorsMap {
|
|
validators = append(validators, validatorData{
|
|
id: k,
|
|
weight: v.Light,
|
|
})
|
|
}
|
|
|
|
// Note: validators are sorted by ID. Sorting by weight would not create a
|
|
// canonically sorted list.
|
|
utils.Sort(validators)
|
|
|
|
weights := make([]uint64, len(validators))
|
|
for i, validator := range validators {
|
|
weights[i] = validator.weight
|
|
}
|
|
|
|
sampler := sampler.NewDeterministicWeightedWithoutReplacement(source)
|
|
return sampler, validators, sampler.Initialize(weights)
|
|
}
|
|
|
|
func (w *windower) expectedProposer(
|
|
validators []validatorData,
|
|
source *prng.MT19937_64,
|
|
sampler sampler.WeightedWithoutReplacement,
|
|
blockHeight,
|
|
slot uint64,
|
|
) (ids.NodeID, error) {
|
|
// Slot is reversed to utilize a different state space in the seed than the
|
|
// height. If the slot was not reversed the state space would collide;
|
|
// biasing the seed generation. For example, without reversing the slot
|
|
// height=0 and slot=1 would equal height=1 and slot=0.
|
|
source.Seed(w.chainSource ^ blockHeight ^ bits.Reverse64(slot))
|
|
indices, ok := sampler.Sample(1)
|
|
if !ok {
|
|
return ids.EmptyNodeID, ErrUnexpectedSamplerFailure
|
|
}
|
|
return validators[indices[0]].id, nil
|
|
}
|
|
|
|
func TimeToSlot(start, now time.Time) uint64 {
|
|
if now.Before(start) {
|
|
return 0
|
|
}
|
|
return uint64(now.Sub(start) / WindowDuration)
|
|
}
|