Files
chains/schain/commit_test.go
zeekay 87cb72352a schain: an authorization is spent once, and a block commits what it contains
An AllocateTx's nonce was covered by the owner's signature and compared to
nothing. Every gate it passed the first time passed again for the identical
bytes: the epoch was still current, the fingerprint still matched, the signer was
still the range owner, the ML-DSA signature still verified. Anyone who saw an
owner's allocate could resubmit it and consume the range's id space again, for
free, as often as they liked. A range now carries the nonce of the allocation
that last moved it, in the same key as its cursor, and takes only a strictly
greater one. The state root covers it, so a chain cannot disagree about what has
been spent and share a root.

Accept committed whatever was staged. Every apply starts by dropping the version
layer, so the layer belongs to whichever block was processed LAST — verify a
sibling between a block's Verify and its Accept and the commit carried the
sibling's writes under this block's id. Accept re-applies its own transactions
from committed state, commits, and only then advances the chain in memory, under
the lock it was mutating without.

Block position is now checked before block content: the parent must be a block
this node holds, the height must be parent+1, and the timestamp must not rewind
or run past a skew ceiling floored at the parent. None of it was checked, so a
block could claim any height — and the height it claimed became the chain's
height and part of every state root computed after it.

A proposer now leaves out what it cannot build rather than failing the whole
build: one unauthorized allocate anyone could submit used to drain the mempool
and destroy every manifest queued behind it. A verifier still refuses the whole
block, which is the half that carries the safety property.

Also: the height lookup reads an index instead of scanning the block map and
returning whichever entry map iteration reached first; abandoned blocks are
released; a rejected block leaves the index; boot no longer panics on a nil
logger when the manager supplies no runtime; and the empty-set check in the owner
gate is gone — pinning.Owner already answers that question, and answers the wider
one the length check missed.

Coverage 69.9% -> 97.9%, with 32 guards mutation-checked. The stale premise in
SECURITY_review.md is recorded at its head: it reviewed a checkout that did not
contain this VM.
2026-08-28 09:00:18 -07:00

360 lines
12 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// commit_test.go — the block-lifecycle proofs: what a block commits, when it
// commits it, where it may sit in the chain, and what a proposer puts in one.
package schain
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/database"
"github.com/luxfi/database/zapdb"
"github.com/luxfi/ids"
"github.com/luxfi/metric"
"github.com/luxfi/chains/schain/txs"
)
// manifestTx is a well-formed PutManifest mutation.
func manifestTx(bucket, object string) []byte {
return txs.NewPutManifestTx(bucket, object, []string{"1,2a,3"}, 7, "e").Bytes()
}
// seal drives one submitted transaction through Build -> Verify -> Accept the
// way the engine does.
func seal(t *testing.T, cvm *ChainVM, txBytes []byte) *Block {
t.Helper()
ctx := context.Background()
require.NoError(t, cvm.SubmitTx(txBytes))
blk, err := cvm.BuildBlock(ctx)
require.NoError(t, err)
require.NoError(t, blk.Verify(ctx))
require.NoError(t, blk.Accept(ctx))
return blk.(*Block)
}
// TestAcceptCommitsItsOwnWrites is the regression test for a block committing
// whatever happened to be staged. Every apply starts by dropping the version
// layer, so the layer belongs to whichever block was processed LAST — verify a
// sibling between a block's Verify and its Accept and the commit carried the
// sibling's writes under this block's id. Accept re-applies its own
// transactions from committed state, so what it commits is what it contains.
func TestAcceptCommitsItsOwnWrites(t *testing.T) {
ctx := context.Background()
cvm, _ := newTestVM(t)
// Two competing blocks at height 1, each carrying a different manifest.
mine := carry(t, cvm, manifestTx("b", "mine"))
sibling := carry(t, cvm, manifestTx("b", "sibling"))
require.NoError(t, mine.Verify(ctx))
// The engine verifies the sibling before deciding — which drops my staging
// and replaces it with the sibling's.
require.NoError(t, sibling.Verify(ctx))
require.NoError(t, mine.Accept(ctx))
_, found, err := cvm.inner.GetManifest("b", "mine")
require.NoError(t, err)
require.True(t, found, "the accepted block must commit its OWN manifest")
_, found, err = cvm.inner.GetManifest("b", "sibling")
require.NoError(t, err)
require.False(t, found, "a block that was never accepted must commit nothing")
}
// brokenBatchDB refuses the batch write a commit performs, so a commit can be
// made to fail without the apply failing.
type brokenBatchDB struct {
database.Database
mu sync.Mutex
broken bool
}
func (d *brokenBatchDB) breakCommit() {
d.mu.Lock()
d.broken = true
d.mu.Unlock()
}
func (d *brokenBatchDB) failing() bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.broken
}
var errStoreDown = errors.New("store is down")
func (d *brokenBatchDB) NewBatch() database.Batch {
return &brokenBatch{Batch: d.Database.NewBatch(), db: d}
}
type brokenBatch struct {
database.Batch
db *brokenBatchDB
}
func (b *brokenBatch) Write() error {
if b.db.failing() {
return errStoreDown
}
return b.Batch.Write()
}
// newVMOn builds an initialized ChainVM over a caller-supplied database.
func newVMOn(t *testing.T, db database.Database) *ChainVM {
t.Helper()
cvm := NewChainVM(logNoop())
require.NoError(t, cvm.Initialize(context.Background(), initFor(db)))
return cvm
}
// TestFailedCommitDoesNotAdvanceTheChain proves the tip follows the commit
// rather than leading it. A chain whose last-accepted pointer moved ahead of a
// write that never landed serves reads out of state that does not exist.
func TestFailedCommitDoesNotAdvanceTheChain(t *testing.T) {
ctx := context.Background()
raw, err := zapdb.New(t.TempDir(), nil, "schain-commit-test", metric.NewRegistry())
require.NoError(t, err)
t.Cleanup(func() { _ = raw.Close() })
db := &brokenBatchDB{Database: raw}
cvm := newVMOn(t, db)
require.NoError(t, cvm.SubmitTx(manifestTx("b", "durable")))
blk, err := cvm.BuildBlock(ctx)
require.NoError(t, err)
require.NoError(t, blk.Verify(ctx))
tipBefore, err := cvm.LastAccepted(ctx)
require.NoError(t, err)
db.breakCommit()
require.ErrorIs(t, blk.Accept(ctx), errStoreDown)
tipAfter, err := cvm.LastAccepted(ctx)
require.NoError(t, err)
require.Equal(t, tipBefore, tipAfter, "an uncommitted block must not become the tip")
require.Equal(t, uint8(StatusProcessing), blk.Status(), "an uncommitted block is not accepted")
_, err = cvm.GetBlockIDAtHeight(ctx, 1)
require.ErrorIs(t, err, errBlockNotFound, "an uncommitted block must not be indexed")
}
// TestBlockPositionIsChecked proves a block must sit in the chain before its
// contents are applied. None of this was checked: a block could name any parent,
// claim any height and carry any timestamp — and the height it claimed became
// the chain's height and part of every state root computed after it.
func TestBlockPositionIsChecked(t *testing.T) {
ctx := context.Background()
cvm, _ := newTestVM(t)
seal(t, cvm, manifestTx("b", "first"))
cvm.lock.RLock()
tip, tipHeight := cvm.lastAcceptedID, cvm.lastAcceptedHeight
tipTime := cvm.blocks[tip].timestamp
cvm.lock.RUnlock()
at := func(parent ids.ID, height uint64, ts time.Time) *Block {
b := &Block{vm: cvm, parentID: parent, height: height, timestamp: ts,
txs: [][]byte{manifestTx("b", "positioned")}, status: StatusProcessing}
return b
}
require.ErrorIs(t, at(ids.GenerateTestID(), tipHeight+1, tipTime).Verify(ctx), errBlockNotFound)
require.ErrorIs(t, at(tip, tipHeight, tipTime).Verify(ctx), errBadHeight)
require.ErrorIs(t, at(tip, tipHeight+9, tipTime).Verify(ctx), errBadHeight)
require.ErrorIs(t, at(tip, tipHeight+1, tipTime.Add(-time.Second)).Verify(ctx), errTimeRewound)
require.ErrorIs(t, at(tip, tipHeight+1, time.Now().Add(time.Hour)).Verify(ctx), errTimeAhead)
// Nothing above was applied: the refused blocks touched no state.
_, found, err := cvm.inner.GetManifest("b", "positioned")
require.NoError(t, err)
require.False(t, found)
// The honest position is accepted, which is what makes the refusals above
// about position and not about the manifest.
honest := carry(t, cvm, manifestTx("b", "positioned"))
require.NoError(t, honest.Verify(ctx))
require.NoError(t, honest.Accept(ctx))
_, found, err = cvm.inner.GetManifest("b", "positioned")
require.NoError(t, err)
require.True(t, found)
}
// TestSkewCeilingFloorsAtTheParent proves a node whose clock has slipped behind
// the tip still accepts a block stamped at the parent's own time — otherwise it
// refuses every block including the ones it builds, and can never catch up.
func TestSkewCeilingFloorsAtTheParent(t *testing.T) {
cvm, _ := newTestVM(t)
// The chain's tip is stamped an hour ahead of this node's clock.
future := time.Now().Add(time.Hour)
cvm.inner.clock.Set(future)
seal(t, cvm, manifestTx("b", "ahead"))
cvm.inner.clock.Set(time.Now()) // the node's clock is now an hour behind the tip
cvm.lock.RLock()
tip, height := cvm.lastAcceptedID, cvm.lastAcceptedHeight
parentTime := cvm.blocks[tip].timestamp
cvm.lock.RUnlock()
atParentTime := &Block{vm: cvm, parentID: tip, height: height + 1, timestamp: parentTime,
txs: [][]byte{manifestTx("b", "catchup")}, status: StatusProcessing}
require.NoError(t, cvm.checkPosition(atParentTime),
"a time the chain already accepted must stay legal")
beyond := &Block{vm: cvm, parentID: tip, height: height + 1,
timestamp: parentTime.Add(maxFutureSkew + time.Second), status: StatusProcessing}
require.ErrorIs(t, cvm.checkPosition(beyond), errTimeAhead,
"the floor is the parent's time, not a licence to run further ahead")
}
// TestAcceptReleasesAbandonedBlocks proves the block index is bounded. The
// engine issues a block per build and abandons the losers of a poll without ever
// rejecting them; a block only ever released on accept or reject accumulates
// without limit.
func TestAcceptReleasesAbandonedBlocks(t *testing.T) {
ctx := context.Background()
cvm, _ := newTestVM(t)
var abandoned []ids.ID
for i := 0; i < 3; i++ {
blk := carry(t, cvm, manifestTx("b", string(rune('a'+i))))
abandoned = append(abandoned, blk.id)
}
winner := carry(t, cvm, manifestTx("b", "winner"))
require.NoError(t, winner.Verify(ctx))
require.NoError(t, winner.Accept(ctx))
cvm.lock.RLock()
defer cvm.lock.RUnlock()
for _, id := range abandoned {
require.NotContains(t, cvm.blocks, id,
"a block abandoned at or below the accepted height must be released")
}
require.Contains(t, cvm.blocks, winner.id, "the accepted chain is retained")
require.Contains(t, cvm.blocks, genesisBlockID)
}
// TestRejectReleasesTheBlock proves a rejected block is dropped from the index
// and its staged writes with it.
func TestRejectReleasesTheBlock(t *testing.T) {
ctx := context.Background()
cvm, _ := newTestVM(t)
blk := carry(t, cvm, manifestTx("rb", "ro"))
require.NoError(t, blk.Verify(ctx))
require.NoError(t, blk.Reject(ctx))
require.Equal(t, uint8(StatusRejected), blk.Status())
cvm.lock.RLock()
_, held := cvm.blocks[blk.id]
cvm.lock.RUnlock()
require.False(t, held, "a rejected block must not be retained")
_, found, err := cvm.inner.GetManifest("rb", "ro")
require.NoError(t, err)
require.False(t, found, "a rejected block's staged writes must be dropped")
}
// TestHeightIndexNamesTheAcceptedBlock proves the height lookup reads an index
// written when a block was accepted, rather than scanning the block map and
// returning whichever matching entry map iteration reached first — which for two
// blocks at one height is a different answer on different runs.
func TestHeightIndexNamesTheAcceptedBlock(t *testing.T) {
ctx := context.Background()
cvm, _ := newTestVM(t)
genesis, err := cvm.GetBlockIDAtHeight(ctx, 0)
require.NoError(t, err)
require.Equal(t, genesisBlockID, genesis)
loser := carry(t, cvm, manifestTx("b", "loser"))
require.NoError(t, loser.Verify(ctx))
_, err = cvm.GetBlockIDAtHeight(ctx, 1)
require.ErrorIs(t, err, errBlockNotFound, "a verified-but-unaccepted block is not indexed")
winner := carry(t, cvm, manifestTx("b", "winner"))
require.NoError(t, winner.Verify(ctx))
require.NoError(t, winner.Accept(ctx))
for i := 0; i < 20; i++ {
got, err := cvm.GetBlockIDAtHeight(ctx, 1)
require.NoError(t, err)
require.Equal(t, winner.id, got, "the height index must name the ACCEPTED block, every time")
}
_, err = cvm.GetBlockIDAtHeight(ctx, 99)
require.ErrorIs(t, err, errBlockNotFound)
}
// TestBlockIndexIsUnderTheLock is the race regression. Accept used to write the
// block index, the last-accepted pointer and the block's status with no lock
// held, while GetBlock, ParseBlock and BuildBlock read and wrote the same map
// under cvm.lock — which the Go runtime answers with a fatal throw, not an
// error. Run under -race this fails on the old code.
func TestBlockIndexIsUnderTheLock(t *testing.T) {
ctx := context.Background()
cvm, _ := newTestVM(t)
const rounds = 300
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
blk := carry(t, cvm, manifestTx("b", "raced"))
if err := blk.Verify(ctx); err == nil {
_ = blk.Accept(ctx)
}
}
}()
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
_, _ = cvm.GetBlock(ctx, genesisBlockID)
_, _ = cvm.LastAccepted(ctx)
_, _ = cvm.GetBlockIDAtHeight(ctx, 0)
}
}()
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
_, _ = cvm.ParseBlock(ctx, []byte("too short"))
_ = cvm.SetPreference(ctx, genesisBlockID)
}
}()
wg.Wait()
}
// TestBlocksAreFoundByID proves the block index answers for what it holds and
// refuses what it does not.
func TestBlocksAreFoundByID(t *testing.T) {
ctx := context.Background()
cvm, _ := newTestVM(t)
blk := seal(t, cvm, manifestTx("b", "found"))
got, err := cvm.GetBlock(ctx, blk.id)
require.NoError(t, err)
require.Equal(t, blk.id, got.ID())
require.Equal(t, blk.parentID, got.(*Block).Parent())
require.Equal(t, blk.parentID, got.(*Block).ParentID())
require.Equal(t, blk.timestamp.UnixNano(), got.(*Block).Timestamp().UnixNano())
require.Equal(t, uint8(StatusAccepted), got.(*Block).Status())
_, err = cvm.GetBlock(ctx, ids.GenerateTestID())
require.ErrorIs(t, err, errBlockNotFound)
require.ErrorContains(t, cvm.SetPreference(ctx, ids.GenerateTestID()), "block not found")
require.NoError(t, cvm.SetPreference(ctx, blk.id))
}