node(chains): receive-side epoch recency gate + bounded heights map + frame discriminator (HIGH-1 b / MEDIUM-3 / LOW-2)

Closes the receive-side gaps the b2 build-side stamping (4c7bab464c) left
open. The build-side epoch stamp max(currentH, parentH) is PROPOSER-ONLY;
a follower must independently re-gate every gossiped block. Three fixes,
one per gap, in the transport wrapper pchain_height_vm.go:

  HIGH-1 (predicate b — RECENCY, upper bound). ParseBlock now rejects a
  framed block whose stamped P-chain epoch H exceeds THIS node's live
  P-chain height by more than pChainHeightRecencySlack (256) — an
  absurd-future epoch a Byzantine proposer claims for a set the network
  has not reached. Rejected fail-closed (errPChainHeightNotRecent): the
  block is dropped, never tracked or voted. This is the UPPER half of the
  epoch bound; the engine-side monotone gate (consensus 7cabd6faa) is the
  LOWER half (>= parent's recorded epoch), so a tracked block's epoch is
  pinned to [parentEpoch, localH+slack]. Fail-soft when no P-chain view
  exists (nil state -> admit; the verifier still fails closed at
  set-resolution) — a defensive path, not a mode.

  MEDIUM-3 (heights-map DoS — WATERMARK PRUNE, not blind LRU). ParseBlock
  runs before the engine dedup/Verify, so a peer can stream distinct
  unverified blocks, each adding a permanent heights entry. Each entry now
  carries its value-chain height; Accept advances a finalized-height
  watermark and prunes every entry at/below it (a finalized block never
  needs a GetBlock epoch re-attach), so the map plateaus under steady
  finality. A hard cap (4096) backstops a sustained unverified flood,
  evicting HIGHEST-height-first so the near-tip pending band survives — a
  still-pending block's epoch is never evicted (a blind LRU could reset it
  to 0 = re-freeze).

  LOW-2 (frame magic DISCRIMINATOR). The 4-byte magic collides at 2^-32
  with the raw hash-prefix some VMs use as Bytes() (dexvm/dchain serialize
  parentID[0:32]||...). A magic match alone no longer means "framed": the
  inner re-parse of the framed payload must ALSO succeed. On re-parse
  failure ParseBlock falls back to a raw whole-block parse — removing the
  bootstrap stall a collision used to cause (mis-framed -> inner decode
  fails closed -> stall on that chain).

TDD pchain_height_hardening_test.go (all pass, CGO_ENABLED=0):
  far-future epoch REJECTED; honest within-slack skew (P-chain advance
  during a staking change) ACCEPTED; nil-state admits; magic-colliding raw
  parses RAW; genuine frame still parses; heights map plateaus at the
  watermark; a pending block is never evicted by the prune; a flood is
  capped preserving the near-tip band. The b2 build-side finality proofs
  still pass (no regression to delivered-epoch stamping).
This commit is contained in:
zeekay
2026-06-22 23:41:59 -07:00
parent 4c7bab464c
commit 4150bc12a8
2 changed files with 528 additions and 48 deletions
+327
View File
@@ -0,0 +1,327 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// pchain_height_hardening_test.go — the receive-side hardening proofs for the b2
// transport wrapper (Red round): the three node-layer fixes that close the
// receive-side gaps the build-side stamping left open.
//
// (HIGH-1, predicate b — RECENCY) ParseBlock rejects a wrapped block whose
// stamped P-chain epoch exceeds this node's live P-chain height by more than the
// recency slack (an absurd-future epoch). Honest forward skew within the slack —
// including a legitimate P-chain advance during a staking change — still parses.
//
// (MEDIUM-3 — MAP DoS) the heights map is bounded: it plateaus at the finalized
// watermark under mass parse+accept, and a still-pending block's epoch is never
// evicted by the watermark prune. A sustained unverified-parse flood is capped.
//
// (LOW-2 — FRAME DISCRIMINATOR) a raw inner block whose first bytes coincidentally
// equal the 4-byte frame magic is NOT mis-parsed as framed: the inner re-parse of
// the framed payload fails, so ParseBlock falls back to a raw whole-block parse.
package chains
import (
"context"
"testing"
"github.com/luxfi/ids"
)
// --- (HIGH-1 b) RECENCY: reject absurd-future epoch ---------------------------
// TestParseBlock_RejectsFarFutureEpoch is the receive-side upper-bound proof. A
// node whose live P-chain height is 100 parses a wrapped block stamped at epoch
// 10_000 (far beyond 100 + slack). ParseBlock REJECTS it fail-closed — the block
// is dropped, never returned to be tracked. This stops a Byzantine proposer from
// pinning a block to an epoch the network has not reached.
func TestParseBlock_RejectsFarFutureEpoch(t *testing.T) {
const localH = uint64(100)
netID := ids.GenerateTestID()
v := newBLSValidator(t, 20)
state := stateByHeight(netID, localH, map[uint64][]blsValidator{localH: {v}})
wrapper := newPChainHeightVM(newFakeInnerVM(), state, netID)
// Craft a frame stamped at an absurd-future epoch, with a registered inner block
// so the ONLY reason to reject is the recency bound (not an inner parse failure).
inner := newFakeInner(5_000_000, ids.Empty, "far-future-epoch")
wrapper.inner.(*fakeInnerVM).register(inner)
framed := wrapPChainHeight(inner.Bytes(), localH+pChainHeightRecencySlack+1) // just past the bound
if _, err := wrapper.ParseBlock(context.Background(), framed); err != errPChainHeightNotRecent {
t.Fatalf("ParseBlock(far-future epoch) err = %v, want errPChainHeightNotRecent — an absurd-future "+
"P-chain epoch must be rejected fail-closed so a follower never tracks a block at an unreachable epoch", err)
}
}
// TestParseBlock_AcceptsEpochWithinSlack proves the recency gate admits HONEST
// forward skew: a follower at local P-chain height 100 parses a block stamped at
// 100 + slack (the boundary — the largest legitimate skew, e.g. a proposer that
// saw P-chain blocks this follower has not yet synced during a staking change).
// The block parses and carries its real epoch.
func TestParseBlock_AcceptsEpochWithinSlack(t *testing.T) {
const localH = uint64(100)
netID := ids.GenerateTestID()
v := newBLSValidator(t, 20)
state := stateByHeight(netID, localH, map[uint64][]blsValidator{localH: {v}})
wrapper := newPChainHeightVM(newFakeInnerVM(), state, netID)
inner := newFakeInner(5_000_000, ids.Empty, "within-slack-epoch")
wrapper.inner.(*fakeInnerVM).register(inner)
atBound := localH + pChainHeightRecencySlack // exactly at the bound — must be admitted
framed := wrapPChainHeight(inner.Bytes(), atBound)
parsed, err := wrapper.ParseBlock(context.Background(), framed)
if err != nil {
t.Fatalf("ParseBlock(epoch within slack) err = %v, want nil — honest forward skew up to the slack "+
"(a real P-chain advance during a staking change) must still parse", err)
}
if got := parsed.(*pChainHeightBlock).PChainHeight(); got != atBound {
t.Fatalf("parsed epoch = %d, want %d — the wrapper must carry the real (recent) epoch unchanged", got, atBound)
}
}
// TestParseBlock_RecencyDisabledWithoutState proves the fail-soft: a wrapper with
// no P-chain view (nil state, current height 0) cannot bound recency, so it admits
// the block — the verifier still fails closed on an unresolvable future epoch at
// set-resolution time. (Production installs the wrapper only on K>1 chains the
// manager guards to have a live state, so this is a defensive path, not a mode.)
func TestParseBlock_RecencyDisabledWithoutState(t *testing.T) {
wrapper := newPChainHeightVM(newFakeInnerVM(), nil, ids.GenerateTestID())
inner := newFakeInner(7, ids.Empty, "no-state-epoch")
wrapper.inner.(*fakeInnerVM).register(inner)
framed := wrapPChainHeight(inner.Bytes(), 9_999_999) // would be far-future if state existed
if _, err := wrapper.ParseBlock(context.Background(), framed); err != nil {
t.Fatalf("ParseBlock with nil state must admit (recency unenforceable, verifier fails closed downstream): %v", err)
}
}
// --- (LOW-2) FRAME DISCRIMINATOR: a magic-colliding raw block is NOT mis-framed -
// TestParseBlock_MagicCollisionParsesRaw is the discriminator proof. A raw inner
// block whose Bytes() coincidentally BEGIN with the 4-byte frame magic (the
// 2^-32 collision the raw-hash-prefix VMs hit) must parse as a RAW block, not be
// mis-classified as framed (which used to fail the inner decode → bootstrap stall).
// The inner re-parse of the framed payload fails (the payload is the block minus
// its first 12 bytes — not a valid inner block), so ParseBlock falls back to a raw
// whole-block parse.
func TestParseBlock_MagicCollisionParsesRaw(t *testing.T) {
netID := ids.GenerateTestID()
v := newBLSValidator(t, 20)
state := stateByHeight(netID, 50, map[uint64][]blsValidator{50: {v}})
inner := newFakeInnerVM()
wrapper := newPChainHeightVM(inner, state, netID)
// A raw block whose bytes START with the exact magic prefix, then arbitrary
// payload. Length is >= the header width so the prefix check would treat it as a
// candidate frame. Crucially, bytes[12:] is NOT a registered inner block, so the
// framed-payload re-parse fails and the whole-bytes parse must be used.
raw := &fakeInnerBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1234,
bytes: append(append([]byte{}, pChainHeightMagic[:]...),
[]byte("RAW-BLOCK-COLLIDES-WITH-MAGIC-PREFIX-payload")...),
}
inner.register(raw) // registers byBytes[whole] = raw; bytes[12:] stays UNregistered
parsed, err := wrapper.ParseBlock(context.Background(), raw.bytes)
if err != nil {
t.Fatalf("ParseBlock(magic-colliding raw block) err = %v, want nil — a raw block whose prefix collides "+
"with the frame magic must parse as RAW (the old behavior mis-framed it → inner decode fail → bootstrap stall)", err)
}
if parsed.ID() != raw.id {
t.Fatalf("parsed block ID %s != raw block ID %s — the colliding block must decode to the SAME raw inner block", parsed.ID(), raw.id)
}
// A raw block falls back to the genesis-set epoch (0): it was NOT framed, so no
// proposer epoch was carried.
if got := parsed.(*pChainHeightBlock).PChainHeight(); got != 0 {
t.Fatalf("magic-colliding raw block epoch = %d, want 0 — it must NOT be read as a framed epoch", got)
}
// Its re-gossip bytes must be the raw passthrough (the inner bytes verbatim), not
// re-framed — so a follower of THIS node also sees it raw.
if string(parsed.Bytes()) != string(raw.bytes) {
t.Fatal("a raw colliding block must re-gossip its inner bytes verbatim (passthrough), not a new frame")
}
}
// TestParseBlock_GenuineFrameStillParses guards the discriminator's other side: a
// GENUINE frame (whose payload IS a valid inner block) still parses as framed and
// recovers the stamped epoch. This pins that the collision fix did not break the
// normal framed path.
func TestParseBlock_GenuineFrameStillParses(t *testing.T) {
netID := ids.GenerateTestID()
v := newBLSValidator(t, 20)
const epoch = uint64(33)
state := stateByHeight(netID, epoch, map[uint64][]blsValidator{epoch: {v}})
inner := newFakeInnerVM()
wrapper := newPChainHeightVM(inner, state, netID)
innerBlk := newFakeInner(9000, ids.Empty, "genuine-frame")
inner.register(innerBlk)
framed := wrapPChainHeight(innerBlk.Bytes(), epoch)
parsed, err := wrapper.ParseBlock(context.Background(), framed)
if err != nil {
t.Fatalf("ParseBlock(genuine frame) err = %v, want nil", err)
}
if parsed.ID() != innerBlk.ID() {
t.Fatalf("genuine frame parsed ID %s != inner ID %s", parsed.ID(), innerBlk.ID())
}
if got := parsed.(*pChainHeightBlock).PChainHeight(); got != epoch {
t.Fatalf("genuine frame epoch = %d, want %d", got, epoch)
}
}
// --- (MEDIUM-3) MAP BOUND: plateau at watermark, pending never evicted --------
// TestHeightsMap_PlateausAtWatermark is the DoS bound proof. We parse a long run
// of distinct framed blocks (each adds a heights entry) and ACCEPT them in order;
// each Accept advances the finalized-height watermark and prunes entries at/below
// it. The map plateaus — it does NOT grow without bound as the chain advances.
func TestHeightsMap_PlateausAtWatermark(t *testing.T) {
netID := ids.GenerateTestID()
v := newBLSValidator(t, 20)
const epoch = uint64(10)
state := stateByHeight(netID, epoch, map[uint64][]blsValidator{epoch: {v}})
inner := newFakeInnerVM()
wrapper := newPChainHeightVM(inner, state, netID)
const n = 500
var lastParsed *pChainHeightBlock
for h := uint64(1); h <= n; h++ {
blk := newFakeInner(h, ids.Empty, "plateau-"+itoa(h))
inner.register(blk)
framed := wrapPChainHeight(blk.Bytes(), epoch)
parsed, err := wrapper.ParseBlock(context.Background(), framed)
if err != nil {
t.Fatalf("ParseBlock #%d: %v", h, err)
}
// Accept the PREVIOUS block (so the most-recent one is still "pending").
if lastParsed != nil {
if err := lastParsed.Accept(context.Background()); err != nil {
t.Fatalf("Accept #%d: %v", h-1, err)
}
}
lastParsed = parsed.(*pChainHeightBlock)
// After each accept the map must be bounded: only entries strictly above the
// watermark survive. We accept (h-1) blocks, so at most a tiny constant remain
// (the just-parsed, not-yet-accepted block, plus any equal-height entries).
wrapper.mu.Lock()
size := len(wrapper.heights)
wm := wrapper.finalizedHeight
wrapper.mu.Unlock()
if size > 4 { // generous constant: the pending working set, not O(n)
t.Fatalf("heights map grew to %d entries at height %d (watermark %d) — it must plateau at the "+
"finalized watermark, not accrete one permanent entry per parsed block (MEDIUM-3 DoS)", size, h, wm)
}
}
}
// TestHeightsMap_PendingNeverEvictedByWatermark proves the watermark prune NEVER
// drops a still-pending block's epoch. We track a pending block at height 1000
// (epoch recalled), accept a LOWER-height block (watermark advances only to that
// lower height), and assert the pending block's epoch is still recallable. A blind
// LRU would have dropped it; the watermark prune must not.
func TestHeightsMap_PendingNeverEvictedByWatermark(t *testing.T) {
netID := ids.GenerateTestID()
v := newBLSValidator(t, 20)
const epoch = uint64(12)
state := stateByHeight(netID, epoch, map[uint64][]blsValidator{epoch: {v}})
inner := newFakeInnerVM()
wrapper := newPChainHeightVM(inner, state, netID)
// Pending block at a HIGH value height (1000).
pending := newFakeInner(1000, ids.Empty, "pending-high")
inner.register(pending)
pendingFrame := wrapPChainHeight(pending.Bytes(), epoch)
if _, err := wrapper.ParseBlock(context.Background(), pendingFrame); err != nil {
t.Fatalf("ParseBlock(pending): %v", err)
}
// A different block at a LOW value height (5) finalizes → watermark = 5.
low := newFakeInner(5, ids.Empty, "finalized-low")
inner.register(low)
lowFrame := wrapPChainHeight(low.Bytes(), epoch)
lowParsed, err := wrapper.ParseBlock(context.Background(), lowFrame)
if err != nil {
t.Fatalf("ParseBlock(low): %v", err)
}
if err := lowParsed.Accept(context.Background()); err != nil {
t.Fatalf("Accept(low): %v", err)
}
// The watermark advanced to 5 (the low block). The PENDING block at height 1000
// is strictly above the watermark, so its epoch MUST survive the prune.
if got, ok := wrapper.recall(pending.ID()); !ok || got != epoch {
t.Fatalf("pending block's epoch recall = (%d,%v), want (%d,true) — the watermark prune (wm=5) must NEVER "+
"evict a still-pending block at height 1000 (that would reset its epoch to 0 = re-freeze)", got, ok, epoch)
}
// And the finalized low block's entry WAS pruned (it is at/below the watermark).
if _, ok := wrapper.recall(low.ID()); ok {
t.Fatal("the finalized low block's entry should have been pruned at/below the watermark (loss-free: epoch already captured)")
}
}
// TestHeightsMap_FloodCappedPreservingNearTip proves the hard-cap backstop: a
// sustained UNVERIFIED-parse flood (blocks that never finalize, so the watermark
// never advances) cannot grow the map past the cap, AND the cap evicts
// highest-value-height-first so the near-tip pending band survives. We seed a
// near-tip pending entry (low height), then flood with far-future-height frames;
// the map stays at the cap and the near-tip entry is retained.
func TestHeightsMap_FloodCappedPreservingNearTip(t *testing.T) {
netID := ids.GenerateTestID()
v := newBLSValidator(t, 20)
const epoch = uint64(8)
// Slack must admit the flood frames' epoch; we stamp them at the current epoch so
// the recency gate is not what bounds them — the CAP is what we are testing.
state := stateByHeight(netID, epoch, map[uint64][]blsValidator{epoch: {v}})
inner := newFakeInnerVM()
wrapper := newPChainHeightVM(inner, state, netID)
// Near-tip pending block at LOW value height 1.
nearTip := newFakeInner(1, ids.Empty, "near-tip")
inner.register(nearTip)
if _, err := wrapper.ParseBlock(context.Background(), wrapPChainHeight(nearTip.Bytes(), epoch)); err != nil {
t.Fatalf("ParseBlock(near-tip): %v", err)
}
// Flood: cap + 200 distinct frames at FAR-future value heights (never accepted).
for i := 0; i < pChainHeightMapCap+200; i++ {
h := uint64(1_000_000 + i) // far above the near-tip height
blk := newFakeInner(h, ids.Empty, "flood-"+itoa(h))
inner.register(blk)
if _, err := wrapper.ParseBlock(context.Background(), wrapPChainHeight(blk.Bytes(), epoch)); err != nil {
t.Fatalf("ParseBlock(flood %d): %v", i, err)
}
}
wrapper.mu.Lock()
size := len(wrapper.heights)
_, nearTipKept := wrapper.heights[nearTip.ID()]
wrapper.mu.Unlock()
if size > pChainHeightMapCap {
t.Fatalf("heights map = %d entries, must be capped at %d under flood (MEDIUM-3 backstop)", size, pChainHeightMapCap)
}
if !nearTipKept {
t.Fatal("the near-tip pending entry (height 1) must SURVIVE the cap — eviction is highest-height-first, " +
"so a flood of far-future-height spam is shed before any near-tip pending block (NOT a blind LRU)")
}
}
// itoa is a tiny, allocation-light uint64→string for unique test tags (avoids
// importing strconv into a hot test loop and keeps byBytes keys distinct).
func itoa(v uint64) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}
+201 -48
View File
@@ -59,6 +59,7 @@ package chains
import (
"context"
"encoding/binary"
"errors"
"sync"
"time"
@@ -77,6 +78,38 @@ var pChainHeightMagic = [4]byte{'L', 'X', 'P', 0x01}
// pChainHeightHeaderLen is the fixed transport-header width: magic(4) + height(8).
const pChainHeightHeaderLen = 4 + 8
// pChainHeightRecencySlack is the receive-side UPPER bound on how far a gossiped
// block's stamped P-chain epoch height H may exceed THIS node's live P-chain
// height before the block is rejected as absurd-future (HIGH-1, predicate b). The
// proposer stamps H = its own GetCurrentHeight (the proposervm selectChildPChainHeight
// rule, ≤ its current); a follower lagging the P-chain sees a smaller current, so
// some forward skew is LEGITIMATE during a staking change or a gossip burst. 256
// P-chain heights swamps any honest inter-node skew (P-chain blocks are seconds
// apart; gossip+verify is sub-second) while still rejecting a wildly-future H. The
// bound is a LIVENESS/DoS sanity gate, NOT the safety bound (that is the monotone
// gate in the engine): a future H always FAILS set resolution at the verifier
// (errUnfinalizedHeight) and never finalizes against a bogus set regardless — this
// just fails it fast and keeps the heights map from accreting unresolvable entries.
const pChainHeightRecencySlack = uint64(256)
// pChainHeightMapCap is the hard backstop on the heights map size — the cap that
// bounds the gossip-parse DoS (MEDIUM-3): ParseBlock runs before the engine's
// dedup/Verify, so an attacker peer could stream distinct unverified blocks, each
// adding a permanent entry. The map plateaus far below this in steady state (the
// watermark prune evicts every finalized block's entry as finality advances); the
// cap fires only under a sustained flood, and then evicts HIGHEST-chain-height
// first — spam claims wild heights, real pending blocks cluster just above the
// finalized watermark, so the near-tip pending band is preserved (NOT a blind LRU
// that could drop a live block). Chosen >> any realistic pending working set.
const pChainHeightMapCap = 4096
// errPChainHeightNotRecent is returned by ParseBlock when a wrapped block's stamped
// P-chain epoch exceeds this node's live P-chain height by more than the recency
// slack. Returning an error fails CLOSED: HandleIncomingBlock drops the block (it
// is never tracked or voted), which is the correct response to an absurd-future
// epoch a follower cannot resolve a set at.
var errPChainHeightNotRecent = errors.New("chains: gossiped block P-chain epoch height exceeds local P-chain height + recency slack (absurd-future epoch, rejected fail-closed)")
// pChainHeightVM wraps a chain BlockBuilder so the block the consensus engine
// sees carries the proposer's P-chain epoch height. It is the ONLY thing that
// changes between the engine and the inner VM; everything else is the inner VM,
@@ -91,16 +124,35 @@ type pChainHeightVM struct {
state validators.State
networkID ids.ID
// heights memoises blockID → stamped P-chain height for blocks this node has
// built or parsed, so GetBlock can re-attach the height a block was stamped
// with (the inner VM stores only the inner bytes, which carry no height).
// Best-effort: a cache miss yields H=0 (the genesis-set fallback). GetBlock is
// NOT on the finality-capture path — the engine records a block's epoch height
// the first time it BUILDS or PARSES the block (where the height is always
// heights memoises blockID → (stamped P-chain epoch, value-chain height) for
// blocks this node has built or parsed, so GetBlock can re-attach the epoch a
// block was stamped with (the inner VM stores only the inner bytes, which carry
// no epoch). Best-effort: a cache miss yields H=0 (the genesis-set fallback).
// GetBlock is NOT on the finality-capture path — the engine records a block's
// epoch the first time it BUILDS or PARSES the block (where the epoch is always
// present) and reads it back from its own pending-block record, never by
// re-fetching via GetBlock — so a miss here cannot drop a live block's epoch.
mu sync.Mutex
heights map[ids.ID]uint64
// re-fetching via GetBlock — so a miss here cannot drop a LIVE block's epoch
// (the consensus PendingBlock holds it, not this map).
//
// BOUNDED (MEDIUM-3): the value-chain height tagged on each entry lets onAccepted
// PRUNE every entry at or below the finalized-height watermark (a finalized block
// never needs a GetBlock epoch re-attach), so under steady finality the map
// plateaus at the watermark. finalizedHeight is that watermark, advanced as
// blocks Accept. A hard cap (pChainHeightMapCap) backstops a sustained
// unverified-parse flood, evicting highest-height-first to preserve the near-tip
// pending band. A still-PENDING block (height above the watermark) is never
// evicted by the watermark prune.
mu sync.Mutex
heights map[ids.ID]heightEntry
finalizedHeight uint64
}
// heightEntry records, for a remembered block, both the P-chain EPOCH it was
// stamped with (re-attached by GetBlock) and its VALUE-CHAIN height (the prune key:
// an entry at/below the finalized watermark is evictable).
type heightEntry struct {
epoch uint64
chainHeight uint64
}
// newPChainHeightVM wraps inner with P-chain-epoch-height stamping backed by the
@@ -116,29 +168,71 @@ func newPChainHeightVM(inner consensuschain.BlockBuilder, state validators.State
inner: inner,
state: state,
networkID: networkID,
heights: make(map[ids.ID]uint64),
heights: make(map[ids.ID]heightEntry),
}
}
var _ consensuschain.BlockBuilder = (*pChainHeightVM)(nil)
// remember records the height a block was stamped with so GetBlock can re-attach
// it. Bounded by nothing here on purpose — the engine prunes finalized blocks and
// a node's live working set is small; if this ever needs eviction it should
// mirror the engine's finalized-height watermark, not a blind LRU (dropping a
// still-pending block's height would silently reset its epoch to 0). For the b2
// scope the live set is small and short-lived.
func (vm *pChainHeightVM) remember(id ids.ID, h uint64) {
// remember records the epoch a block (at value-chain height chainHeight) was
// stamped with so GetBlock can re-attach it. BOUNDED (MEDIUM-3): the map is pruned
// against the finalized-height watermark by onAccepted, so it plateaus under steady
// finality; remember additionally enforces the hard cap as a flood backstop. The
// chainHeight is the prune key.
func (vm *pChainHeightVM) remember(id ids.ID, epoch, chainHeight uint64) {
vm.mu.Lock()
vm.heights[id] = h
vm.mu.Unlock()
defer vm.mu.Unlock()
vm.heights[id] = heightEntry{epoch: epoch, chainHeight: chainHeight}
vm.enforceCapLocked()
}
func (vm *pChainHeightVM) recall(id ids.ID) (uint64, bool) {
vm.mu.Lock()
h, ok := vm.heights[id]
e, ok := vm.heights[id]
vm.mu.Unlock()
return h, ok
return e.epoch, ok
}
// onAccepted advances the finalized-height watermark to acceptedHeight and PRUNES
// every remembered entry at or below it (MEDIUM-3). A finalized block's epoch is
// already captured in the engine's records and will never be re-attached via
// GetBlock, so dropping its entry is loss-free — and a still-PENDING block (height
// strictly above the watermark) is NEVER evicted by this prune, so its GetBlock
// epoch survives. Called from the wrapped block's Accept (the block tells its VM
// the height at which it finalized). Idempotent and monotone: a re-accept or an
// out-of-order lower height never lowers the watermark.
func (vm *pChainHeightVM) onAccepted(acceptedHeight uint64) {
vm.mu.Lock()
defer vm.mu.Unlock()
if acceptedHeight > vm.finalizedHeight {
vm.finalizedHeight = acceptedHeight
}
for id, e := range vm.heights {
if e.chainHeight <= vm.finalizedHeight {
delete(vm.heights, id)
}
}
}
// enforceCapLocked is the flood backstop: if the map exceeds the hard cap, evict
// the entry with the HIGHEST value-chain height. Real pending blocks cluster just
// above the finalized watermark (the next few heights); an unverified-parse flood
// claims arbitrary, typically far-future heights — so evicting highest-first sheds
// spam while preserving the near-tip pending band (NOT a blind LRU that could drop
// a live block's epoch). The cap is reached only under attack: the watermark prune
// keeps the steady-state map far below it. Caller holds vm.mu.
func (vm *pChainHeightVM) enforceCapLocked() {
for len(vm.heights) > pChainHeightMapCap {
var victim ids.ID
var maxHeight uint64
first := true
for id, e := range vm.heights {
if first || e.chainHeight > maxHeight {
victim, maxHeight, first = id, e.chainHeight, false
}
}
delete(vm.heights, victim)
}
}
// currentPChainHeight returns the proposer's live P-chain height, the upper end
@@ -183,8 +277,8 @@ func (vm *pChainHeightVM) BuildBlock(ctx context.Context) (block.Block, error) {
if ph := vm.parentHeight(inner.ParentID()); ph > h {
h = ph
}
vm.remember(inner.ID(), h)
return newPChainHeightBlock(inner, h), nil
vm.remember(inner.ID(), h, inner.Height())
return newPChainHeightBlock(vm, inner, h), nil
}
// ParseBlock recovers a block from transport bytes. Wrapped bytes
@@ -193,20 +287,60 @@ func (vm *pChainHeightVM) BuildBlock(ctx context.Context) (block.Block, error) {
// gossiped bytes recovers the identical epoch height. Bytes without the magic are
// a raw inner block (a pre-wrapper peer, GetAncestors, genesis): parsed straight
// through with H=0, the SAFE genesis-set fallback.
//
// FRAME DISCRIMINATOR (LOW-2). The 4-byte magic collides at 2^-32 with the raw
// hash prefix some VMs use as their Bytes() (dexvm/quantumvm/dchain serialize
// parentID[0:32]||…). The magic match alone is NOT sufficient: a frame is real
// only if the inner re-parse of the framed payload ALSO succeeds. So when the
// prefix matches, attempt to parse b[header:] as an inner block; if that FAILS,
// fall back to parsing the WHOLE b as a raw inner block (the colliding raw block,
// whose bytes minus the 12-byte prefix are not a valid inner block). This removes
// the bootstrap stall a magic collision used to cause (mis-framed → inner decode
// fails closed → stall on that chain).
//
// RECENCY GATE (HIGH-1, predicate b). A real frame's stamped epoch H must be
// recent relative to THIS node's live P-chain height: H ≤ localCurrentH + slack.
// An absurd-future H (a Byzantine proposer claiming an epoch the network has not
// reached) is rejected fail-closed (the block is dropped, never tracked). This is
// the upper half of the epoch bound; the engine's monotone gate is the lower half
// (≥ parent's recorded epoch), so the epoch is pinned to [parentEpoch, localH+slack].
func (vm *pChainHeightVM) ParseBlock(ctx context.Context, b []byte) (block.Block, error) {
innerBytes, h, wrapped := unwrapPChainHeight(b)
inner, err := vm.inner.ParseBlock(ctx, innerBytes)
candidateInner, h, magicMatched := unwrapPChainHeight(b)
if magicMatched {
// The prefix looks like a frame. It IS a frame only if the framed payload
// parses as an inner block AND the stamped epoch is recent.
if inner, err := vm.inner.ParseBlock(ctx, candidateInner); err == nil {
if !vm.epochRecent(ctx, h) {
return nil, errPChainHeightNotRecent
}
vm.remember(inner.ID(), h, inner.Height())
return newPChainHeightBlock(vm, inner, h), nil
}
// Magic matched but the framed payload did NOT parse → this is a RAW block
// whose first bytes coincidentally equal the magic. Parse the whole b raw.
}
// Raw inner block: no proposer epoch available → genesis-set fallback. Still
// wrap (uniform block type to the engine) but with H=0 and a passthrough
// Bytes() so re-gossip of a raw block stays raw.
inner, err := vm.inner.ParseBlock(ctx, b)
if err != nil {
return nil, err
}
if wrapped {
vm.remember(inner.ID(), h)
return newPChainHeightBlock(inner, h), nil
return newRawPChainHeightBlock(vm, inner), nil
}
// epochRecent reports whether a stamped P-chain epoch height is within the recency
// slack of this node's live P-chain height (HIGH-1, predicate b). A node with no
// resolvable current height (state nil / read error → 0) cannot bound recency, so
// it admits the block (the verifier still fails closed on an unresolvable future
// epoch at set-resolution time — recency here is a fast-fail/DoS sanity gate, not
// the safety bound). Heights at or below local are always recent.
func (vm *pChainHeightVM) epochRecent(ctx context.Context, h uint64) bool {
localH := vm.currentPChainHeight(ctx)
if localH == 0 {
return true
}
// Raw inner block: no proposer height available → genesis-set fallback. Still
// wrap (uniform block type to the engine) but with H=0 and a passthrough
// Bytes() so re-gossip of a raw block stays raw.
return newRawPChainHeightBlock(inner), nil
return h <= localH+pChainHeightRecencySlack
}
// GetBlock fetches a block from the inner VM and re-attaches the P-chain height
@@ -221,9 +355,9 @@ func (vm *pChainHeightVM) GetBlock(ctx context.Context, id ids.ID) (block.Block,
return nil, err
}
if h, ok := vm.recall(id); ok {
return newRawPChainHeightBlockWithHeight(inner, h), nil
return newRawPChainHeightBlockWithHeight(vm, inner, h), nil
}
return newRawPChainHeightBlock(inner), nil
return newRawPChainHeightBlock(vm, inner), nil
}
// LastAccepted delegates to the inner VM.
@@ -247,12 +381,15 @@ func wrapPChainHeight(innerBytes []byte, height uint64) []byte {
return out
}
// unwrapPChainHeight splits transport bytes. Returns (innerBytes, height, true)
// for a wrapped block, or (b, 0, false) for raw bytes (no/!magic). A wrapped
// frame must be at least the header width AND carry the magic; anything else is
// treated as raw (fail-soft — a malformed prefix can never be mistaken for a
// height, it simply parses as an inner block which then fails its own decode).
func unwrapPChainHeight(b []byte) (innerBytes []byte, height uint64, wrapped bool) {
// unwrapPChainHeight is a PURE prefix splitter: it reports whether b carries the
// frame magic at the header width and, if so, returns the candidate payload and
// the encoded height. magicMatched==true means ONLY that the prefix looks like a
// frame — NOT that b is definitely framed. ParseBlock makes the real decision by
// re-parsing the candidate payload (LOW-2): a raw block whose first bytes collide
// with the magic (2^-32) yields magicMatched==true here but its payload fails the
// inner re-parse, so ParseBlock falls back to a raw whole-b parse. Keeping this a
// pure split (no VM dependency) localizes the collision handling to ParseBlock.
func unwrapPChainHeight(b []byte) (payload []byte, height uint64, magicMatched bool) {
if len(b) < pChainHeightHeaderLen ||
b[0] != pChainHeightMagic[0] || b[1] != pChainHeightMagic[1] ||
b[2] != pChainHeightMagic[2] || b[3] != pChainHeightMagic[3] {
@@ -275,6 +412,7 @@ func unwrapPChainHeight(b []byte) (innerBytes []byte, height uint64, wrapped boo
// (genesis-set fallback) it is the inner bytes verbatim (passthrough) so re-gossip
// stays raw.
type pChainHeightBlock struct {
vm *pChainHeightVM
inner block.Block
pChainHeight uint64
bytes []byte
@@ -282,8 +420,9 @@ type pChainHeightBlock struct {
// newPChainHeightBlock wraps inner with height h and a FRAMED Bytes()
// ([magic|h|inner]) — the proposer/parse path, where H must travel to followers.
func newPChainHeightBlock(inner block.Block, h uint64) *pChainHeightBlock {
func newPChainHeightBlock(vm *pChainHeightVM, inner block.Block, h uint64) *pChainHeightBlock {
return &pChainHeightBlock{
vm: vm,
inner: inner,
pChainHeight: h,
bytes: wrapPChainHeight(inner.Bytes(), h),
@@ -292,16 +431,16 @@ func newPChainHeightBlock(inner block.Block, h uint64) *pChainHeightBlock {
// newRawPChainHeightBlock wraps inner with H=0 and a PASSTHROUGH Bytes() (the
// inner bytes verbatim) — the genesis-set fallback for a raw inner block.
func newRawPChainHeightBlock(inner block.Block) *pChainHeightBlock {
return &pChainHeightBlock{inner: inner, pChainHeight: 0, bytes: inner.Bytes()}
func newRawPChainHeightBlock(vm *pChainHeightVM, inner block.Block) *pChainHeightBlock {
return &pChainHeightBlock{vm: vm, inner: inner, pChainHeight: 0, bytes: inner.Bytes()}
}
// newRawPChainHeightBlockWithHeight re-attaches a recalled height to an inner
// block fetched via GetBlock while keeping a PASSTHROUGH Bytes() (the inner VM's
// at-rest bytes). Used off the finality-capture path; the height is for the
// engine's in-memory epoch read, not for re-gossip framing.
func newRawPChainHeightBlockWithHeight(inner block.Block, h uint64) *pChainHeightBlock {
return &pChainHeightBlock{inner: inner, pChainHeight: h, bytes: inner.Bytes()}
func newRawPChainHeightBlockWithHeight(vm *pChainHeightVM, inner block.Block, h uint64) *pChainHeightBlock {
return &pChainHeightBlock{vm: vm, inner: inner, pChainHeight: h, bytes: inner.Bytes()}
}
func (b *pChainHeightBlock) ID() ids.ID { return b.inner.ID() }
@@ -316,12 +455,26 @@ func (b *pChainHeightBlock) Bytes() []byte { return b.bytes }
// reason this wrapper exists. The inner block does not expose it; this does.
func (b *pChainHeightBlock) PChainHeight() uint64 { return b.pChainHeight }
// Verify/Accept/Reject delegate to the inner VM block: the wrapper changes the
// epoch the engine SEES, never the block's validity or state transition.
// Verify/Reject delegate to the inner VM block: the wrapper changes the epoch the
// engine SEES, never the block's validity or state transition.
func (b *pChainHeightBlock) Verify(ctx context.Context) error { return b.inner.Verify(ctx) }
func (b *pChainHeightBlock) Accept(ctx context.Context) error { return b.inner.Accept(ctx) }
func (b *pChainHeightBlock) Reject(ctx context.Context) error { return b.inner.Reject(ctx) }
// Accept finalizes the inner block, then advances the VM's finalized-height
// watermark and prunes the heights map at/below it (MEDIUM-3). The inner Accept
// runs FIRST: finalization must not depend on the bookkeeping prune, and the prune
// is a pure memory-management side effect that can never fail. The block carries
// its own height, so the VM learns the exact finalized height with no extra read.
func (b *pChainHeightBlock) Accept(ctx context.Context) error {
if err := b.inner.Accept(ctx); err != nil {
return err
}
if b.vm != nil {
b.vm.onAccepted(b.inner.Height())
}
return nil
}
// Unwrap exposes the inner block for code that must reach the underlying VM block
// (e.g. a missing-context reparse). Kept narrow on purpose.
func (b *pChainHeightBlock) Unwrap() block.Block { return b.inner }