mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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.
360 lines
12 KiB
Go
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))
|
|
}
|