Files
node/chains/catchup_frame_test.go
zeekay b52da648ed fix(chains): wire frontier-sync + cert-carrying catch-up so a stranded validator converges
A validator that fell behind during consensus (mainnet luxd-0/2 at 1082780 while
peers reached 1082797) could not recover at runtime: it received no gossip, and a
restart only bootstraps to its own height. Two node-side gaps, now closed:

GAP 1 — frontier-sync was dead. The GetAcceptedFrontier/AcceptedFrontier responders
existed but HandleInbound had no case for either op, so both fell through (dropped)
and nothing ever ASKED. Add the two cases (mirroring GetContext/Context) and a slow
frontier poller (runFrontierPoller, 15s, small peer sample) so a node proactively
learns it is behind and pulls the gap.

GAP 2 — catch-up could not accept already-finalized blocks. handleContext routed
fetched ancestors through the VOTING Put, but the network will not re-vote a decided
height → the gap blocks wedged. Now each catch-up container pairs the block with its
finality cert (encodeCatchupEntry: magic|blockLen|block|certLen|cert, carried by the
existing Ancestors flattener) and the requester finalizes via engine.AcceptCatchupBlock
(the verified-cert path) when a cert is present, voting only on certless/legacy
entries. The 'LCU2' magic makes a cross-version exchange fail CLEANLY (a legacy
decoder self-rejects; the v2 decoder treats a legacy raw block as legacy).

Pins consensus to the local engine commit (replace ../consensus) for the branch;
reviewer swaps to a pseudo-version. Does not weaken the cert-gate.
2026-06-26 14:02:47 -07:00

111 lines
4.6 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// catchup_frame_test.go — the CERT-CARRYING catch-up wire format. These prove the
// load-bearing property: a v2 (block,cert) entry round-trips, an entry with no cert
// routes to the vote path, and a cross-version exchange fails CLEANLY (a legacy
// decoder cannot misparse a v2 frame, and the v2 decoder treats a legacy raw block
// as legacy — never a partial/garbage parse). The cert-accept SEMANTICS are proven
// in the consensus engine tests; here we pin only the framing.
package chains
import (
"bytes"
"encoding/binary"
"testing"
)
func TestCatchupEntry_RoundTrip(t *testing.T) {
block := []byte("\xf9\x02\x00 a realistic-ish block body")
cert := []byte("a-marshaled-quorum-cert")
blk, crt, ok := decodeCatchupEntry(encodeCatchupEntry(block, cert))
if !ok {
t.Fatal("a v2 entry must decode as a v2 entry")
}
if !bytes.Equal(blk, block) {
t.Fatalf("block bytes corrupted: got %q want %q", blk, block)
}
if !bytes.Equal(crt, cert) {
t.Fatalf("cert bytes corrupted: got %q want %q", crt, cert)
}
}
func TestCatchupEntry_EmptyCertRoutesToVotePath(t *testing.T) {
// A still-pending block served on the live missing-parent path carries no cert.
// It must decode as a v2 entry with an EMPTY cert — the requester then votes on
// it (handleContext routes certLen==0 to Put), the legacy behaviour.
block := []byte("pending-block-no-cert")
blk, crt, ok := decodeCatchupEntry(encodeCatchupEntry(block, nil))
if !ok {
t.Fatal("a v2 entry with empty cert must still decode as v2")
}
if !bytes.Equal(blk, block) {
t.Fatalf("block bytes corrupted: got %q", blk)
}
if len(crt) != 0 {
t.Fatalf("cert must be empty, got %d bytes", len(crt))
}
}
func TestCatchupEntry_LegacyRawBlockIsNotV2(t *testing.T) {
// A legacy responder sends the raw block as the container (no magic). The v2
// decoder must report ok=false so handleContext treats it as a raw block (Put),
// never as a malformed v2 entry. Cover several real block-prefix shapes.
for _, raw := range [][]byte{
{0xf9, 0x02, 0x00, 0x11, 0x22}, // EVM/RLP list header
{0x00, 0x00, 0x00, 0x2a}, // P/X-chain codec version prefix
{}, // empty
[]byte("LCU"), // 3 bytes — too short to even hold the magic
} {
if _, _, ok := decodeCatchupEntry(raw); ok {
t.Fatalf("legacy raw block %x must NOT decode as a v2 entry", raw)
}
}
}
func TestCatchupEntry_MagicIsNotAPlausibleLength(t *testing.T) {
// CROSS-VERSION SAFETY (new responder → legacy requester): a legacy decoder reads
// the first 4 bytes of a v2 entry as a uint32 length. The magic must be so large
// that it always exceeds the remaining buffer, so the legacy loop rejects the
// frame (0 blocks processed) rather than consuming garbage. 0x4C435532 ≈ 1.28 GB.
asLen := binary.BigEndian.Uint32(catchupEntryMagic[:])
if asLen < (1 << 30) {
t.Fatalf("magic read as a length (%d) is too small — a legacy decoder could misparse a v2 frame", asLen)
}
// And a full v2 frame's leading length-word (the magic) dwarfs the frame itself,
// so the legacy `blockLen > remaining` guard always fires.
frame := encodeCatchupEntry([]byte("blk"), []byte("crt"))
if uint64(binary.BigEndian.Uint32(frame[:4])) <= uint64(len(frame)) {
t.Fatal("magic-as-length must exceed the frame length so a legacy decoder self-rejects")
}
}
func TestCatchupEntry_CorruptV2FramesRejected(t *testing.T) {
good := encodeCatchupEntry([]byte("block-body"), []byte("cert-body"))
// Truncations inside the v2 structure must fail to ok=false (never a partial
// parse): drop the trailing cert byte, drop the certLen word, etc.
for _, bad := range [][]byte{
good[:len(good)-1], // last cert byte missing → certLen != remaining
good[:len(good)-5], // certLen word + cert missing
good[:8], // magic + blockLen only, block missing
good[:6], // magic + 2 bytes of blockLen
append(append([]byte(nil), good...), 0x00), // trailing byte → does not consume exactly
} {
if _, _, ok := decodeCatchupEntry(bad); ok {
t.Fatalf("a corrupt v2 frame (len %d) must be rejected, not partial-parsed", len(bad))
}
}
// An overflowing blockLen (claims more block than the buffer holds) is rejected.
overflow := append([]byte(nil), catchupEntryMagic[:]...)
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], 0xFFFFFFFF)
overflow = append(overflow, u32[:]...)
overflow = append(overflow, []byte("tiny")...)
if _, _, ok := decodeCatchupEntry(overflow); ok {
t.Fatal("a v2 frame with an overflowing blockLen must be rejected")
}
}