Files
vm/chain/block_accept_ordering_test.go
zeekayandHanzo Dev 52913d973f fix(chain): P0 — accept the inner block BEFORE advancing wrapper state
BlockWrapper.Accept mutated the wrapper's caches and lastAcceptedBlock and
only then called bw.Block.Accept(ctx). If the inner VM refused or failed to
commit, the wrapper had already recorded the block as decided and moved
lastAcceptedBlock to it — a wrapper-vs-inner split that survives the process
and reads on the next boot as:

  proposervm finality index (height N) is BEHIND the inner VM tip (height M)
  ... This cannot be repaired locally

which is precisely the wedge that killed the C-Chain on mainnet luxd-0 and
luxd-2. Reject had the same ordering.

Present in BOTH wrappers: chain/block.go and components/chain/block.go.
Both now accept/reject the underlying block first and mutate only on success,
so a failed inner Accept leaves the block merely verified and the engine free
to retry or re-decide.

Tests (chain/block_accept_ordering_test.go), fault-injecting an inner Accept
failure and asserting lastAcceptedBlock, verifiedBlocks and decidedBlocks are
ALL untouched, plus a positive control that a clean Accept advances all three,
plus the Reject mirror. Proven in both directions: the test FAILS against the
original ordering and PASSES with the fix.

Reported by the owner from a review of the public mirrors; confirmed present
in this tree.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 21:17:30 -07:00

112 lines
3.6 KiB
Go

package chain
import (
"context"
"errors"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/cache/lru"
)
// errInnerAccept is the injected inner-VM failure.
var errInnerAccept = errors.New("inner VM refused to commit")
// failBlock is a Block whose Accept/Reject fail on demand.
type failBlock struct {
Block
id ids.ID
failAccept bool
failReject bool
accepted bool
rejected bool
}
func (b *failBlock) ID() ids.ID { return b.id }
func (b *failBlock) Bytes() []byte { return b.id[:] } // sized-cache weighs entries via Bytes()
func (b *failBlock) Height() uint64 { return 1 }
func (b *failBlock) Accept(context.Context) error {
if b.failAccept {
return errInnerAccept
}
b.accepted = true
return nil
}
func (b *failBlock) Reject(context.Context) error {
if b.failReject {
return errInnerAccept
}
b.rejected = true
return nil
}
// TestBlockWrapper_AcceptDoesNotAdvanceStateOnInnerError pins the ordering invariant:
// wrapper caches and lastAcceptedBlock may ONLY move after the inner VM has actually
// accepted. The pre-fix code mutated first and then returned bw.Block.Accept(ctx), so a
// failing inner Accept left lastAcceptedBlock pointing at a block the VM never committed —
// a wrapper-vs-inner split that resurfaces on the next boot as "proposervm finality index
// is BEHIND the inner VM tip" and refuses to initialize the chain.
func TestBlockWrapper_AcceptDoesNotAdvanceStateOnInnerError(t *testing.T) {
prevID, blkID := ids.GenerateTestID(), ids.GenerateTestID()
prev := &BlockWrapper{Block: &failBlock{id: prevID}}
st := &State{
verifiedBlocks: map[ids.ID]*BlockWrapper{},
lastAcceptedBlock: prev,
}
st.decidedBlocks = lru.NewSizedCache[ids.ID, *BlockWrapper](1<<20, cachedBlockSize)
inner := &failBlock{id: blkID, failAccept: true}
bw := &BlockWrapper{Block: inner, state: st}
st.verifiedBlocks[blkID] = bw
if err := bw.Accept(context.Background()); !errors.Is(err, errInnerAccept) {
t.Fatalf("expected the inner error to propagate, got %v", err)
}
if st.lastAcceptedBlock != prev {
t.Fatal("lastAcceptedBlock MOVED despite the inner VM refusing to accept")
}
if _, ok := st.verifiedBlocks[blkID]; !ok {
t.Fatal("block was dropped from verifiedBlocks despite a failed Accept")
}
if _, ok := st.decidedBlocks.Get(blkID); ok {
t.Fatal("block was cached as DECIDED despite a failed Accept")
}
// Positive control: a succeeding Accept must advance everything.
inner.failAccept = false
if err := bw.Accept(context.Background()); err != nil {
t.Fatalf("clean Accept failed: %v", err)
}
if st.lastAcceptedBlock != bw {
t.Fatal("lastAcceptedBlock did NOT advance on a successful Accept")
}
if _, ok := st.verifiedBlocks[blkID]; ok {
t.Fatal("accepted block still in verifiedBlocks")
}
if _, ok := st.decidedBlocks.Get(blkID); !ok {
t.Fatal("accepted block not cached as decided")
}
}
// TestBlockWrapper_RejectDoesNotCacheOnInnerError is the Reject-side mirror.
func TestBlockWrapper_RejectDoesNotCacheOnInnerError(t *testing.T) {
blkID := ids.GenerateTestID()
st := &State{verifiedBlocks: map[ids.ID]*BlockWrapper{}}
st.decidedBlocks = lru.NewSizedCache[ids.ID, *BlockWrapper](1<<20, cachedBlockSize)
inner := &failBlock{id: blkID, failReject: true}
bw := &BlockWrapper{Block: inner, state: st}
st.verifiedBlocks[blkID] = bw
if err := bw.Reject(context.Background()); !errors.Is(err, errInnerAccept) {
t.Fatalf("expected the inner error to propagate, got %v", err)
}
if _, ok := st.verifiedBlocks[blkID]; !ok {
t.Fatal("block dropped from verifiedBlocks despite a failed Reject")
}
if _, ok := st.decidedBlocks.Get(blkID); ok {
t.Fatal("block cached as DECIDED despite a failed Reject")
}
}