Files
consensus/engine/chain/catchup_needs_cert_test.go
zeekayandHanzo Dev 4e67c9ce2c chain: replay finalized blocks the VM missed, on signed authority
A validator keeps two records of the chain: the consensus ledger (what a quorum
decided) and the VM (what this node executed). Finalization folds the ledger across
blocks the VM never applied, so the ledger legitimately runs ahead. Both accept lanes
— runtime catch-up and bootstrap — gated fetched blocks on the ledger height, so a
node whose ledger led its VM discarded every block in the gap it had just asked for and
counted the batch accepted. It re-asked forever. Found wedged on five live fleets.

Position is now the LOWER of the two heads, in both lanes; a VM naming no block, or one
whose head is unreadable, does not lower it (absence is not a reading of zero). Blocks
between the applied head and the ledger are executed straight onto the VM — the network
settled those heights long ago, so what is missing is execution, not a decision.

Two authorities gate a replay, in order. The ledger's own per-height record first: it
names which block belongs at a height (canonical or envelope), and a block it finalized
differently is refused without consulting anything else. Where the ledger cannot speak —
below its boot seed or pruning window — a verified quorum cert may vouch.

The cert path binds on what the cert SIGNS, never on transport. The outer BlockID is not
in the signed message, so a held cert can be repointed at any block without breaking a
signature; binding on it let any peer apply an arbitrary block to a catching-up
validator's VM (red PoC, refused now). It binds on the signed canonical tuple instead,
verifies at the block's own P-chain epoch (not height 0, which resolves the genesis set
— rejecting every honest cert and admitting a long-range one), cross-checks the set-root,
and requires the irreversible Quasar tier. Replay also refuses a non-contiguous block
before Accept, since proposervm commits its outer envelope before the EVM refuses.

Nothing here decides: no vote, no cert consumed, no ledger movement. A tracked
above-band block returns ErrCatchupDeferred, distinct from a rejection, so a converging
node stops logging its own progress as failure.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 20:27:29 -07:00

267 lines
10 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// catchup_needs_cert_test.go — tracking is not finalizing.
//
// The catch-up path verifies and TRACKS blocks that arrive above the node's next
// contiguous height, so that a later verified quorum cert on a descendant can
// finalize the run behind it. That is only sound if a tracked block can never
// become final on its own. These tests pin that: no cert, no finality, no matter
// how many blocks a peer hands us or how well-formed they are.
package chain
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// parseVM hands back the block a peer's bytes name, so the catch-up path can be
// driven with blocks at chosen heights.
type parseVM struct{ byBytes map[string]*verifyOnceBlock }
func (v *parseVM) BuildBlock(context.Context) (block.Block, error) {
return nil, errors.New("parseVM does not build")
}
func (v *parseVM) GetBlock(_ context.Context, id ids.ID) (block.Block, error) {
for _, b := range v.byBytes {
if b.id == id {
return b, nil
}
}
return nil, errors.New("not held")
}
func (v *parseVM) ParseBlock(_ context.Context, bytes []byte) (block.Block, error) {
if b, ok := v.byBytes[string(bytes)]; ok {
return b, nil
}
return nil, errors.New("unknown bytes")
}
func (v *parseVM) LastAccepted(context.Context) (ids.ID, error) { return ids.Empty, nil }
func (v *parseVM) SetPreference(context.Context, ids.ID) error { return nil }
// TestTrackedCatchupBlockIsNotFinal is the security property the tracking change
// rests on: a block delivered WITHOUT a cert is verified and kept, and is still
// not accepted afterwards. If this ever passes finality to an untracked-cert
// block, a peer can decide our chain by sending us bytes.
func TestTrackedCatchupBlockIsNotFinal(t *testing.T) {
vm := &parseVM{byBytes: map[string]*verifyOnceBlock{}}
e := NewWithConfig(Config{Params: params5()}, WithVM(vm))
rt := &Runtime{Transitive: e, config: NetworkConfig{
ChainID: ids.GenerateTestID(), Logger: log.Noop(), VM: vm,
}}
tip := ids.GenerateTestID()
e.consensus.mu.Lock()
e.consensus.ledger = seedLedger(tip, tip, 8)
e.consensus.mu.Unlock()
// A run of well-formed blocks well above our next height (9), none certified.
var ids20 []ids.ID
parent := tip
for h := 20; h < 26; h++ {
raw := fmt.Sprintf("uncertified-%d", h)
blk := &verifyOnceBlock{
id: ids.GenerateTestID(), parentID: parent, height: uint64(h),
timestamp: time.Now(), bytes: []byte(raw),
}
vm.byBytes[raw] = blk
ids20 = append(ids20, blk.id)
parent = blk.id
err := rt.AcceptCatchupBlock(context.Background(), []byte(raw), nil)
if err == nil {
t.Fatalf("height %d: AcceptCatchupBlock reported SUCCESS with no cert — a peer could finalize our chain by sending bytes", h)
}
// These heights sit ABOVE our contiguous next block (9), so they are verified and
// TRACKED, awaiting the fold — ErrCatchupDeferred. That is not a finalize and not a
// failure; the point of this test is that tracked never leaks into final. A block AT
// the next height with no cert would return ErrCatchupCertRejected instead; either
// way nothing is finalized without a cert.
if !errors.Is(err, ErrCatchupDeferred) && !errors.Is(err, ErrCatchupCertRejected) {
t.Fatalf("height %d: want deferred-or-rejected (no cert, no finality), got %v", h, err)
}
}
// The whole run is held, and not one block of it is final.
for i, id := range ids20 {
if e.IsAccepted(id) {
t.Fatalf("block %d of the uncertified run is ACCEPTED — tracking leaked into finality", i)
}
}
if fh, _ := e.consensus.GetFinalizedHeight(); fh != 8 {
t.Fatalf("finalized height moved to %d on uncertified blocks; must still be 8", fh)
}
}
// TestUncertifiedRunLeavesFinalityWhereItWas: repeated delivery of the same
// uncertified blocks must not accumulate into anything. A peer that re-sends
// forever gets no further than one that sends once.
func TestUncertifiedRunLeavesFinalityWhereItWas(t *testing.T) {
vm := &parseVM{byBytes: map[string]*verifyOnceBlock{}}
e := NewWithConfig(Config{Params: params5()}, WithVM(vm))
rt := &Runtime{Transitive: e, config: NetworkConfig{
ChainID: ids.GenerateTestID(), Logger: log.Noop(), VM: vm,
}}
tip := ids.GenerateTestID()
e.consensus.mu.Lock()
e.consensus.ledger = seedLedger(tip, tip, 8)
e.consensus.mu.Unlock()
blk := &verifyOnceBlock{
id: ids.GenerateTestID(), parentID: tip, height: 40,
timestamp: time.Now(), bytes: []byte("resent"),
}
vm.byBytes["resent"] = blk
for i := 0; i < 50; i++ {
if err := rt.AcceptCatchupBlock(context.Background(), []byte("resent"), nil); err == nil {
t.Fatalf("delivery %d finalized an uncertified block", i)
}
}
if e.IsAccepted(blk.id) {
t.Fatal("50 uncertified deliveries accepted the block — repetition must not substitute for a cert")
}
if fh, _ := e.consensus.GetFinalizedHeight(); fh != 8 {
t.Fatalf("finalized height moved to %d; must still be 8", fh)
}
}
// TestGarbageCatchupBytesAreRefused: bytes that do not parse never reach the
// tracker at all.
func TestGarbageCatchupBytesAreRefused(t *testing.T) {
vm := &parseVM{byBytes: map[string]*verifyOnceBlock{}}
e := NewWithConfig(Config{Params: params5()}, WithVM(vm))
rt := &Runtime{Transitive: e, config: NetworkConfig{
ChainID: ids.GenerateTestID(), Logger: log.Noop(), VM: vm,
}}
if err := rt.AcceptCatchupBlock(context.Background(), []byte("not a block"), nil); err == nil {
t.Fatal("unparseable bytes were accepted")
}
if err := rt.AcceptCatchupBlock(context.Background(), []byte("not a block"), []byte("forged cert")); err == nil {
t.Fatal("unparseable bytes with a forged cert were accepted")
}
}
// TestHeldBlockIsNotReVerified: when our VM already holds the block, catch-up must
// use that copy instead of asking the VM to verify it again. Re-verification is an
// insert of an old canonical block, which a VM correctly refuses — and a node whose
// finality has fallen behind its own applied head is served nothing BUT blocks it
// already has, so that refusal used to reject the entire response.
func TestHeldBlockIsNotReVerified(t *testing.T) {
vm := &parseVM{byBytes: map[string]*verifyOnceBlock{}}
e := NewWithConfig(Config{Params: params5()}, WithVM(vm))
rt := &Runtime{Transitive: e, config: NetworkConfig{
ChainID: ids.GenerateTestID(), Logger: log.Noop(), VM: vm,
}}
tip := ids.GenerateTestID()
e.consensus.mu.Lock()
e.consensus.ledger = seedLedger(tip, tip, 8)
e.consensus.mu.Unlock()
blk := &verifyOnceBlock{
id: ids.GenerateTestID(), parentID: tip, height: 30,
timestamp: time.Now(), bytes: []byte("already-held"),
}
vm.byBytes["already-held"] = blk
// The block was verified when we first accepted it; a second Verify now fails,
// exactly as a VM refusing to re-insert its own canonical block does.
if err := blk.Verify(context.Background()); err != nil {
t.Fatalf("first verify should succeed: %v", err)
}
err := rt.AcceptCatchupBlock(context.Background(), []byte("already-held"), nil)
if err == nil {
t.Fatal("a block with no cert must not finalize")
}
if errors.Is(err, errVerifiedAlready) {
t.Fatal("catch-up re-verified a block the VM already holds — the whole response would be rejected")
}
if got := blk.VerifyCalls(); got != 1 {
t.Fatalf("Verify called %d times; the held copy must be used instead of re-verifying", got)
}
if e.IsAccepted(blk.id) {
t.Fatal("held block was finalized without a cert")
}
}
// TestHeldBlockAtNextHeightIsNotReVerified: the height that MOVES the ledger is
// finalized+1, and it took a different code path from every other height. A node
// behind on certs holds its whole gap, so re-verification refused the one block
// whose tracking the fold depends on — and pathFromTip then defers every cert above
// that hole.
func TestHeldBlockAtNextHeightIsNotReVerified(t *testing.T) {
vm := &parseVM{byBytes: map[string]*verifyOnceBlock{}}
e := NewWithConfig(Config{Params: params5()}, WithVM(vm))
rt := &Runtime{Transitive: e, config: NetworkConfig{
ChainID: ids.GenerateTestID(), Logger: log.Noop(), VM: vm,
}}
tip := ids.GenerateTestID()
e.consensus.mu.Lock()
e.consensus.ledger = seedLedger(tip, tip, 8)
e.consensus.mu.Unlock()
// Height 9 == finalized+1: the contiguous next block.
blk := &verifyOnceBlock{
id: ids.GenerateTestID(), parentID: tip, height: 9,
timestamp: time.Now(), bytes: []byte("held-next"),
}
vm.byBytes["held-next"] = blk
if err := blk.Verify(context.Background()); err != nil { // accepted earlier
t.Fatalf("first verify: %v", err)
}
err := rt.AcceptCatchupBlock(context.Background(), []byte("held-next"), nil)
if errors.Is(err, errVerifiedAlready) {
t.Fatal("finalized+1 was re-verified — the one height the fold needs tracked is the one we refuse")
}
if got := blk.VerifyCalls(); got != 1 {
t.Fatalf("Verify called %d times at finalized+1; the held copy must be used", got)
}
if e.IsAccepted(blk.id) {
t.Fatal("finalized+1 was finalized without a cert")
}
}
// TestRecallIsWiredWhenTheVMArrivesAfterConstruction: the node builds the engine
// with no VM and attaches one later, so wiring the VM read-back only at construction
// left it off in production while every test that passed WithVM saw it on.
func TestRecallIsWiredWhenTheVMArrivesAfterConstruction(t *testing.T) {
vm := &parseVM{byBytes: map[string]*verifyOnceBlock{}}
blk := &verifyOnceBlock{
id: ids.GenerateTestID(), parentID: ids.GenerateTestID(), height: 77,
timestamp: time.Now(), bytes: []byte("late-vm"),
}
vm.byBytes["late-vm"] = blk
// Exactly the node's order: construct without a VM, attach afterwards.
e := NewWithParams(params5())
if _, _, _, _, ok := e.consensus.ancestry().Parent(blk.id); ok {
t.Fatal("resolved a block before any VM was attached")
}
e.SetVM(vm)
_, height, _, _, ok := e.consensus.ancestry().Parent(blk.id)
if !ok {
t.Fatal("after SetVM the walk still cannot read the VM — recall is unwired on the path the node actually uses")
}
if height != 77 {
t.Fatalf("resolved height %d, want 77", height)
}
}