mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
summary: ship the file the package is built out of
source.go carries Source, VM, Offer and Ballot — every type summary.go names. It was never tracked, so the published module did not compile at all for a consumer: undefined Source, undefined VM, undefined Ballot. The package's own tests passed the whole time, because a test builds against the working tree. Also corrects two of my own from the previous commit. A seed at or below the recovery hint stands again: the certified frontier belonging under what the VM has already applied is the safe direction, and the dangerous one — above — is what the walk-from-hint arm exists for. And the quorum-engine test harness now wires stake weighting, so a transport-authenticated vote is judged the way production judges it; without it the membership check refused every vote, including the validators' own. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -375,6 +375,10 @@ func newQuorumEngineOpts(t *testing.T, params config.Parameters, vs *testValidat
|
||||
chainID := ids.GenerateTestID()
|
||||
opts := append([]Option{
|
||||
WithQuorumCert(chainID, vs.nodeID(self), vs, gossiper, vs.signerFor(self)),
|
||||
// Every K>1 chain runs with a stake source — the node refuses to start one
|
||||
// without live validator state — so a quorum engine built without one is not
|
||||
// a configuration these tests should be reasoning about.
|
||||
WithStakeWeighting(vs),
|
||||
}, extra...)
|
||||
e := NewWithConfig(Config{Params: params}, opts...)
|
||||
if err := e.Start(context.Background(), true); err != nil {
|
||||
|
||||
+6
-11
@@ -302,18 +302,13 @@ func Finalize(led FinalityLedger, cert Cert, dag Ancestry) (FinalityLedger, Plan
|
||||
// height over blocks the VM never executed (the post-restart wedge): an unheld
|
||||
// ancestry fails closed (AncestorNotTracked → a behind-node DEFER, fetch the gap),
|
||||
// while a tracked run folds and EXECUTES every height from the applied head up. With
|
||||
// A cert AT the hint is the applied head's own first cert and seeds directly, even
|
||||
// naming a canonical the hint guessed wrong — the hint is a local guess, and
|
||||
// refusing there would strand a node whose head sits on a branch the network did
|
||||
// not finalize. BELOW the hint it is refused: seeding the certified frontier under
|
||||
// the node's own applied head moves the finalized height backwards and leaves every
|
||||
// later cert deferring on an ancestry walk that has to reach back down to it. With
|
||||
// no hint at all there is no applied head to anchor to, so any height seeds.
|
||||
// no hint (a genuine genesis, no applied head to anchor to) or a cert at/below the
|
||||
// hint (the applied head's own first cert, or a straggler), seed the cert directly —
|
||||
// even for a different canonical id than the hint guessed, the hint never blocks it.
|
||||
// At or below the hint is the safe direction: the certified frontier sits under what
|
||||
// the VM has already applied, which is where it belongs. The dangerous direction is
|
||||
// above, and that is the walk this arm exists for.
|
||||
if !led.set {
|
||||
if led.hasHint && cert.Height < led.hintHeight {
|
||||
return led, Plan{}, fmt.Errorf("%w: seed at height %d under an applied head at %d",
|
||||
ErrNonMonotonicFinalizedHeight, cert.Height, led.hintHeight)
|
||||
}
|
||||
if led.hasHint && cert.Height > led.hintHeight {
|
||||
floor := FinalityLedger{tip: led.hint, height: led.hintHeight, set: true}
|
||||
path, err := pathFromTip(floor, cert, dag)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package chain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// The recovery hint is this node's own applied head — a local guess about what the
|
||||
// network finalized, not authority over it. A cert at the hint's height seeds even
|
||||
// when it names a different canonical, because the alternative is stranding a node
|
||||
// whose head sits on a branch the network did not finalize.
|
||||
func TestSeedStandsAtTheHintWithADifferentCanonical(t *testing.T) {
|
||||
head, certified := ids.GenerateTestID(), ids.GenerateTestID()
|
||||
led := FinalityLedger{}.withHint(head, 1_000_000)
|
||||
|
||||
next, plan, err := Finalize(led, Cert{Block: certified, Height: 1_000_000, Canonical: certified}, mapDAG{})
|
||||
if err != nil {
|
||||
t.Fatalf("a cert at the hint's own height must seed: %v", err)
|
||||
}
|
||||
if h, set := next.Height(); !set || h != 1_000_000 {
|
||||
t.Fatalf("seeded height = %d set=%v, want 1000000 set", h, set)
|
||||
}
|
||||
if len(plan.Accept) != 1 || plan.Accept[0] != certified {
|
||||
t.Fatalf("plan accepts %v, want exactly the certified block", plan.Accept)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// source.go — the two things the round needs from outside itself: the network
|
||||
// (Source) and the local VM. Both are interfaces, so the round runs against an
|
||||
// in-memory network in a test and so the consensus module carries NO transport —
|
||||
// no request ids, no pending or failed sets, no outstanding-request window, no
|
||||
// timeout manager, no validator set. Every wire concern sits behind Source, the
|
||||
// same division the bootstrapper draws at BlockSource.
|
||||
package summary
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/luxfi/consensus/engine/chain/block"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// Offer is one beacon's answer in the discovery round: the raw bytes of the newest
|
||||
// summary that beacon holds. The bytes stay opaque here — only the VM can read
|
||||
// them — and a beacon that answers with junk is still a beacon that answered.
|
||||
type Offer struct {
|
||||
NodeID ids.NodeID
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
// Ballot is one beacon's answer in the ratification round: which of the requested
|
||||
// heights it actually holds, named by summary id, with that beacon's stake already
|
||||
// attached. The node attaches the stake because the consensus module has no
|
||||
// validator set — it can weigh a vote but it cannot look one up.
|
||||
//
|
||||
// A beacon that holds none of the requested heights sends an empty Held; a beacon
|
||||
// that never answers has no Ballot at all. Both are a vote for nothing, which is
|
||||
// why neither needs a path of its own.
|
||||
//
|
||||
// Held is carried per beacon rather than as a flat list of (id, weight) pairs so
|
||||
// that a beacon which can prove a height already carries a ⅔-stake certificate has
|
||||
// somewhere to say so — a certificate settles ratification outright, and adding it
|
||||
// is then a field, not a new shape.
|
||||
type Ballot struct {
|
||||
NodeID ids.NodeID
|
||||
Weight uint64
|
||||
Held []ids.ID
|
||||
}
|
||||
|
||||
// Source is the network, as the round sees it: two blocking calls that each ask a
|
||||
// question and return whatever arrived before their window closed.
|
||||
type Source interface {
|
||||
// Offers asks the connected beacons which summary each holds and returns the
|
||||
// replies that arrived inside the collection window — at most one per beacon,
|
||||
// returning early once every connected beacon has answered.
|
||||
//
|
||||
// An empty result with a nil error is an answer: the beacons were asked and
|
||||
// none of them had anything. A non-nil error means the beacon set could not be
|
||||
// ASKED at all — not enough of it ever connected — and that is the one
|
||||
// condition the caller must not read as "the network has nothing for me". Wait
|
||||
// for connectivity here, bounded by a deadline, exactly as the bootstrapper
|
||||
// waits before it will name a frontier.
|
||||
Offers(ctx context.Context) ([]Offer, error)
|
||||
|
||||
// Ballots asks EVERY beacon — not a sample — which of these heights it holds,
|
||||
// and returns the ballots that arrived inside the collection window together
|
||||
// with total, the stake of the whole beacon set.
|
||||
//
|
||||
// The whole set is the denominator because this is where safety lives.
|
||||
// Discovery only produced a list of heights, and a wrong height there costs one
|
||||
// extra entry in this request; everything that decides what the node adopts is
|
||||
// weighed here. Weighing it against a sample would let an adversary who happens
|
||||
// to be over-represented in that sample decide what state the node keeps.
|
||||
//
|
||||
// total counts exactly the beacons this call is willing to collect from, and
|
||||
// this node's own stake belongs in it if and only if this node is a beacon and
|
||||
// its own ballot comes back. Answering your own question while leaving yourself
|
||||
// out of total — or the reverse — judges a summary against a set that is not
|
||||
// the set that voted.
|
||||
Ballots(ctx context.Context, heights []uint64) (ballots []Ballot, total uint64, err error)
|
||||
}
|
||||
|
||||
// VM is the local state-syncable VM, narrowed to what this round actually calls. It
|
||||
// is a strict subset of block.StateSyncableVM, so a VM that supports state sync
|
||||
// already satisfies it and the node writes no adapter.
|
||||
//
|
||||
// The caller decides whether the round runs at all (block.StateSyncableVM's
|
||||
// StateSyncEnabled) and owns VM state across it: the VM is already syncing when Run
|
||||
// is called and stays where the caller put it whichever Outcome comes back. This
|
||||
// package never moves the VM between states. Two owners for one lifecycle is how a
|
||||
// VM ends up live on state it never finished fetching.
|
||||
type VM interface {
|
||||
// GetOngoingSyncStateSummary returns the summary this node was already syncing
|
||||
// toward when it stopped, so a restart can finish the trie it has partly
|
||||
// fetched instead of throwing it away. database.ErrNotFound means there is no
|
||||
// such sync, which is an ordinary answer and not a failure.
|
||||
GetOngoingSyncStateSummary(context.Context) (block.StateSummary, error)
|
||||
|
||||
// ParseStateSummary turns a beacon's reply into a summary. It must preserve
|
||||
// identity — ParseStateSummary(s.Bytes()).ID() == s.ID() — because the id it
|
||||
// yields is the name stake is counted against in ratification. A summary whose
|
||||
// id changed on the way through parsing would collect votes cast for a
|
||||
// different one.
|
||||
ParseStateSummary(context.Context, []byte) (block.StateSummary, error)
|
||||
|
||||
// LastAccepted and GetBlock give the height this node already stands at. It is
|
||||
// the floor no candidate may sit at or below: adopting a summary throws away
|
||||
// everything below it, so adopting one at or under the local tip trades real
|
||||
// history for nothing. A majority that happens to be behind must never be able
|
||||
// to drag a node backwards.
|
||||
LastAccepted(context.Context) (ids.ID, error)
|
||||
GetBlock(context.Context, ids.ID) (block.Block, error)
|
||||
}
|
||||
|
||||
// A state-syncable VM satisfies VM as it stands. The narrowing bounds what this
|
||||
// package may call; it is not a second interface for the node to implement.
|
||||
var _ VM = (block.StateSyncableVM)(nil)
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
// processed whether or not this node ever polled the sender.
|
||||
func TestAuthVote_StrangerMovesTheAcceptTally(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{}, WithStakeWeighting(vs))
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
|
||||
alpha := e.consensus.Alpha()
|
||||
|
||||
blk := newTestBlock(90001, ids.GenerateTestID(), "stranger-accept")
|
||||
@@ -66,7 +66,7 @@ func TestAuthVote_StrangerMovesTheAcceptTally(t *testing.T) {
|
||||
// errors, its preference is filed as that stranger's REJECT.
|
||||
func TestAuthVote_StrangerMovesTheRejectTally(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{}, WithStakeWeighting(vs))
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
|
||||
alpha := e.consensus.Alpha()
|
||||
|
||||
blk := newTestBlock(90002, ids.GenerateTestID(), "stranger-reject")
|
||||
@@ -92,7 +92,7 @@ func TestAuthVote_StrangerMovesTheRejectTally(t *testing.T) {
|
||||
// not the engine.
|
||||
func TestAuthVote_ValidatorsStillCount(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{}, WithStakeWeighting(vs))
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
|
||||
alpha := e.consensus.Alpha()
|
||||
|
||||
blk := newTestBlock(90003, ids.GenerateTestID(), "control")
|
||||
|
||||
Reference in New Issue
Block a user