mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
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:
@@ -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
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user