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.
315 lines
11 KiB
Go
315 lines
11 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// statesync_client.go — the wire under state-summary adoption.
|
|
//
|
|
// The adoption round itself lives in consensus (engine/chain/summary): it decides
|
|
// which summary the network stands behind and hands it to the VM. It carries no
|
|
// transport at all — no request ids, no pending sets, no timeouts — the same
|
|
// division bootstrap draws at BlockSource. This file is the other side of that
|
|
// line: it asks the beacons and returns what arrived before the window closed.
|
|
//
|
|
// Two rounds, two questions. Discovery asks what each beacon holds and carries no
|
|
// threshold, because a wrong height there costs one extra entry in the next
|
|
// request. Ratification asks the WHOLE beacon set which of those heights it holds
|
|
// and attaches each beacon's stake, because that is where the decision is made and
|
|
// a sample would let whoever is over-represented in it choose the node's state.
|
|
package chains
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/luxfi/consensus/engine/chain/summary"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/math/set"
|
|
"github.com/luxfi/vm/chain"
|
|
)
|
|
|
|
// errNoBeaconsToAsk separates "the beacons were asked and had nothing" from "the
|
|
// beacons could not be asked". Only the first is an answer; reading the second as
|
|
// one tells a node the network has nothing for it when nobody was ever reached.
|
|
var errNoBeaconsToAsk = errors.New("state summary: no beacon set to ask")
|
|
|
|
const (
|
|
// summaryOfferWindow bounds discovery. A beacon that has not answered a
|
|
// question this cheap inside it is not going to.
|
|
summaryOfferWindow = 10 * time.Second
|
|
// summaryBallotWindow bounds ratification. Longer than discovery: this round
|
|
// asks every beacon rather than a sample, and its answer decides whether the
|
|
// node keeps its state.
|
|
summaryBallotWindow = 30 * time.Second
|
|
)
|
|
|
|
// summarySource is summary.Source over this chain's network.
|
|
type summarySource struct{ b *blockHandler }
|
|
|
|
var _ summary.Source = (*summarySource)(nil)
|
|
|
|
// Offers asks the beacons which summary each of them holds.
|
|
//
|
|
// An error here means the beacons could not be ASKED — the one condition the
|
|
// round must not read as "the network has nothing for me". An empty result with
|
|
// no error is an answer: they were asked and had nothing.
|
|
func (s *summarySource) Offers(ctx context.Context) ([]summary.Offer, error) {
|
|
b := s.b
|
|
if b.net == nil || b.msgCreator == nil {
|
|
return nil, nil
|
|
}
|
|
weights, _, ok := b.beaconWeights()
|
|
if !ok || len(weights) == 0 {
|
|
return nil, errNoBeaconsToAsk
|
|
}
|
|
asked := set.NewSet[ids.NodeID](len(weights))
|
|
for id := range weights {
|
|
asked.Add(id)
|
|
}
|
|
|
|
requestID, ch, done := b.openSummaryRound(asked)
|
|
defer done()
|
|
|
|
msg, err := b.msgCreator.GetStateSummaryFrontier(b.chainID, requestID, summaryOfferWindow)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b.net.Send(msg, asked, b.networkID, 0)
|
|
|
|
// One reply per beacon, and stop as soon as every beacon has answered rather
|
|
// than sitting out the window for replies that cannot arrive.
|
|
offers := make([]summary.Offer, 0, asked.Len())
|
|
seen := set.NewSet[ids.NodeID](asked.Len())
|
|
deadline := time.After(summaryOfferWindow)
|
|
for seen.Len() < asked.Len() {
|
|
select {
|
|
case o := <-ch:
|
|
if seen.Contains(o.NodeID) {
|
|
continue
|
|
}
|
|
seen.Add(o.NodeID)
|
|
if len(o.Bytes) > 0 {
|
|
offers = append(offers, o)
|
|
}
|
|
case <-deadline:
|
|
return offers, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
return offers, nil
|
|
}
|
|
|
|
// Ballots asks every beacon which of these heights it holds, and returns the whole
|
|
// set's stake as the denominator. The denominator is the whole set and not the
|
|
// responders: shrinking it to whoever answered is what lets an eclipse make a
|
|
// sliver of stake look like a supermajority.
|
|
func (s *summarySource) Ballots(ctx context.Context, heights []uint64) ([]summary.Ballot, uint64, error) {
|
|
b := s.b
|
|
if b.net == nil || b.msgCreator == nil {
|
|
return nil, 0, nil
|
|
}
|
|
weights, total, ok := b.beaconWeights()
|
|
if !ok || len(weights) == 0 {
|
|
return nil, 0, errNoBeaconsToAsk
|
|
}
|
|
asked := set.NewSet[ids.NodeID](len(weights))
|
|
for id := range weights {
|
|
asked.Add(id)
|
|
}
|
|
|
|
requestID, ch, done := b.openBallotRound(asked)
|
|
defer done()
|
|
|
|
msg, err := b.msgCreator.GetAcceptedStateSummary(b.chainID, requestID, summaryBallotWindow, heights)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
b.net.Send(msg, asked, b.networkID, 0)
|
|
|
|
ballots := make([]summary.Ballot, 0, asked.Len())
|
|
seen := set.NewSet[ids.NodeID](asked.Len())
|
|
deadline := time.After(summaryBallotWindow)
|
|
for seen.Len() < asked.Len() {
|
|
select {
|
|
case v := <-ch:
|
|
if seen.Contains(v.NodeID) {
|
|
continue
|
|
}
|
|
seen.Add(v.NodeID)
|
|
// The stake is attached here because the consensus module can weigh a
|
|
// vote but cannot look one up. A reply from a node the beacon set does
|
|
// not name weighs nothing, so it is not a ballot.
|
|
w, isBeacon := weights[v.NodeID]
|
|
if !isBeacon {
|
|
continue
|
|
}
|
|
v.Weight = w
|
|
ballots = append(ballots, v)
|
|
case <-deadline:
|
|
return ballots, total, nil
|
|
case <-ctx.Done():
|
|
return nil, 0, ctx.Err()
|
|
}
|
|
}
|
|
return ballots, total, nil
|
|
}
|
|
|
|
// openSummaryRound registers a collector for one discovery round and returns the
|
|
// request id, the channel replies land on, and the teardown.
|
|
func (b *blockHandler) openSummaryRound(asked set.Set[ids.NodeID]) (uint32, chan summary.Offer, func()) {
|
|
b.contextRequestMu.Lock()
|
|
b.requestIDCounter++
|
|
requestID := b.requestIDCounter
|
|
b.contextRequestMu.Unlock()
|
|
|
|
ch := make(chan summary.Offer, asked.Len())
|
|
b.bsMu.Lock()
|
|
if b.summaryOfferCh == nil {
|
|
b.summaryOfferCh = map[uint32]chan summary.Offer{}
|
|
b.summaryPeers = map[uint32]set.Set[ids.NodeID]{}
|
|
}
|
|
b.summaryOfferCh[requestID] = ch
|
|
// Remember who was asked. A request id is ours, not a secret: any connected
|
|
// peer can send a reply carrying it, and a stranger's answer to a question we
|
|
// never put to it is not evidence.
|
|
b.summaryPeers[requestID] = asked
|
|
b.bsMu.Unlock()
|
|
|
|
return requestID, ch, func() {
|
|
b.bsMu.Lock()
|
|
delete(b.summaryOfferCh, requestID)
|
|
delete(b.summaryPeers, requestID)
|
|
b.bsMu.Unlock()
|
|
}
|
|
}
|
|
|
|
// openBallotRound is openSummaryRound for the ratification round.
|
|
func (b *blockHandler) openBallotRound(asked set.Set[ids.NodeID]) (uint32, chan summary.Ballot, func()) {
|
|
b.contextRequestMu.Lock()
|
|
b.requestIDCounter++
|
|
requestID := b.requestIDCounter
|
|
b.contextRequestMu.Unlock()
|
|
|
|
ch := make(chan summary.Ballot, asked.Len())
|
|
b.bsMu.Lock()
|
|
if b.summaryBallotCh == nil {
|
|
b.summaryBallotCh = map[uint32]chan summary.Ballot{}
|
|
b.summaryPeers = map[uint32]set.Set[ids.NodeID]{}
|
|
}
|
|
b.summaryBallotCh[requestID] = ch
|
|
b.summaryPeers[requestID] = asked
|
|
b.bsMu.Unlock()
|
|
|
|
return requestID, ch, func() {
|
|
b.bsMu.Lock()
|
|
delete(b.summaryBallotCh, requestID)
|
|
delete(b.summaryPeers, requestID)
|
|
b.bsMu.Unlock()
|
|
}
|
|
}
|
|
|
|
// deliverOffer routes a discovery reply to the round that asked for it. A reply
|
|
// carrying a request id we are not collecting, or from a peer we did not ask, is
|
|
// dropped.
|
|
func (b *blockHandler) deliverOffer(requestID uint32, nodeID ids.NodeID, bytes []byte) {
|
|
b.bsMu.Lock()
|
|
ch, collecting := b.summaryOfferCh[requestID]
|
|
peers, known := b.summaryPeers[requestID]
|
|
b.bsMu.Unlock()
|
|
if !collecting || !known || !peers.Contains(nodeID) {
|
|
return
|
|
}
|
|
select {
|
|
case ch <- summary.Offer{NodeID: nodeID, Bytes: bytes}:
|
|
default: // the round already has this beacon's answer or has moved on
|
|
}
|
|
}
|
|
|
|
// deliverBallot is deliverOffer for the ratification round.
|
|
func (b *blockHandler) deliverBallot(requestID uint32, nodeID ids.NodeID, held []ids.ID) {
|
|
b.bsMu.Lock()
|
|
ch, collecting := b.summaryBallotCh[requestID]
|
|
peers, known := b.summaryPeers[requestID]
|
|
b.bsMu.Unlock()
|
|
if !collecting || !known || !peers.Contains(nodeID) {
|
|
return
|
|
}
|
|
select {
|
|
case ch <- summary.Ballot{NodeID: nodeID, Held: held}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// adoptSummary runs the adoption round before bootstrap.
|
|
//
|
|
// It runs at all only for a VM that can sync state and says it wants to; every
|
|
// other chain goes straight to the descent. Whatever the round decides, the
|
|
// caller bootstraps afterwards — an adopted summary moves the floor the descent
|
|
// starts from, and a skipped one leaves it where it was. Nothing here is fatal:
|
|
// a node that cannot reach the beacons for this still has the descent, which is
|
|
// the same answer it had before this pass existed.
|
|
func (b *blockHandler) adoptSummary(ctx context.Context) {
|
|
syncable, ok := b.vm.(chain.StateSyncableVM)
|
|
if !ok {
|
|
// Said out loud. A VM that cannot sync state is ordinary, but so is a VM
|
|
// that can and whose summary type this assertion did not match — and those
|
|
// two look identical from a silent return, which is how a wired client can
|
|
// sit in a build doing nothing at all.
|
|
b.logger.Info("state-summary adoption skipped — this VM does not offer the sync surface")
|
|
return
|
|
}
|
|
enabled, err := b.stateSyncEnabled(ctx)
|
|
switch {
|
|
case errors.Is(err, chain.ErrStateSyncableVMNotImplemented):
|
|
// The same fact as a failed assertion, learned one layer in: this chain's
|
|
// VM does not carry the surface. Reporting it as a VM that could not
|
|
// answer would send a reader looking for a fault where there is none.
|
|
b.logger.Info("state-summary adoption skipped — this VM does not offer the sync surface")
|
|
return
|
|
case err != nil:
|
|
b.logger.Info("state-summary adoption skipped — the VM could not say whether it syncs", log.Err(err))
|
|
return
|
|
}
|
|
if !enabled {
|
|
b.logger.Info("state-summary adoption skipped — the VM has state sync turned off")
|
|
return
|
|
}
|
|
outcome, err := summary.New(summary.Config[chain.StateSummary]{
|
|
Source: &summarySource{b: b},
|
|
VM: &syncVM{StateSyncableVM: syncable, b: b},
|
|
Log: b.logger,
|
|
}).Run(ctx)
|
|
if err != nil {
|
|
b.logger.Info("state-summary adoption did not run; bootstrapping from the local tip",
|
|
log.Err(err))
|
|
return
|
|
}
|
|
b.logger.Info("state-summary adoption finished", log.String("outcome", outcome.String()))
|
|
}
|
|
|
|
// syncVM answers the round's two summary questions from the VM and the third —
|
|
// how far this node stands — from the node itself. The height is what the node
|
|
// has EXECUTED, not what its wrapper committed, because adopting a summary
|
|
// throws away everything below it and the wrapper's head is not a floor this
|
|
// node can vouch for.
|
|
type syncVM struct {
|
|
chain.StateSyncableVM
|
|
b *blockHandler
|
|
}
|
|
|
|
func (v *syncVM) Tip(ctx context.Context) (uint64, error) { return v.b.appliedHeight(ctx) }
|
|
|
|
// stateSyncEnabled asks the VM whether it wants a summary at all. A VM that does
|
|
// not answer is one that does not want one.
|
|
func (b *blockHandler) stateSyncEnabled(ctx context.Context) (bool, error) {
|
|
type enabler interface {
|
|
StateSyncEnabled(context.Context) (bool, error)
|
|
}
|
|
e, ok := b.vm.(enabler)
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
return e.StateSyncEnabled(ctx)
|
|
}
|