Files
chains/aivm/redb_consensus_test.go
zeekay eb02967a9a aivm: one staging layer, one commit, one chain
A-Chain kept the tip only in memory, staged every pending block's writes
in one shared versiondb, and accepted a block by committing that layer
before storing the block. So a restart came back at genesis, accepting A
published B's intent, rejecting B discarded A's, and a failed store left
the state durable and the block gone.

Verify and BuildBlock now decide against an overlay over committed state
and throw it away, so a block that is proposed or checked moves nothing.
Accept stages the transition, the block, its height entry and the tip
through one view and commits them once. Custody moves into engine state,
so balances commit with the stake records that depend on them instead of
living beside them in memory.

Verify also gains the lineage, time and size checks it never had: the
parent must be the tip or a block in flight above it, the height must
follow, the timestamp must sit between the parent's and the local clock
plus a skew allowance, and the encoding must be one a peer will accept.
Accept re-checks the tip under the lock that commits, because it moves
between the two calls.

A block is now named under its chain id, which never travels, so the same
bytes name different blocks on different chains. Nested payloads must
account for their blob exactly, id lists must be whole ids, and an
element the encoder cannot write is reported rather than dropped. A
merkle proof's index must be spent by the fold, so an out-of-range index
no longer verifies on its low bits.

Also: the running flag is read under the lock that writes it, a vertex no
longer moves the block chain's head, and eligibleSet says what its quorum
is a quorum of.

The ten probe files that proved all this are converted to regression
tests, not deleted.
2026-08-28 15:03:32 -07:00

165 lines
6.7 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package aivm
// redb_consensus_test.go — Red-B consensus-safety regressions for the VM-level
// import seam (BuildBlock / Verify / Accept / Reject). These pin the fix for the
// CRITICAL finding "engine state committed before Accept": engine state + ledger
// value now stage in vm.qdb (versiondb) + a ledger snapshot and commit ONLY at
// Block.Accept; a rejected/discarded block rolls everything back.
import (
"context"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/database/memdb"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
"github.com/stretchr/testify/require"
)
// newAIVMForConsensus builds an initialized VM with an in-memory DB, accept-all
// commit verifier, and a funded ledger with `eligible` registered operators. The
// seed registrations are COMMITTED (vm.commitEngine) so they are durable and
// survive a later abortEngine — exactly like operators registered in prior
// accepted blocks on a live chain.
func newAIVMForConsensus(t *testing.T) (*VM, common.Address, []common.Address) {
t.Helper()
logger := log.NewNoOpLogger()
rt := &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: logger}
v := &VM{}
require.NoError(t, v.Initialize(context.Background(), vmcore.Init{
Runtime: rt,
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 8),
Log: logger,
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
}))
v.SetCommitVerifier(acceptAll)
e, st, _ := v.QuorumEngine()
reqr := addr(0xF0)
reward := uint256.NewInt(1_000_000_000_000_000_000)
fund := new(uint256.Int).Mul(reward, uint256.NewInt(uint64(testN)))
feeTotal := new(uint256.Int).Mul(RequestFeePerOperator, uint256.NewInt(uint64(testN)))
fund.Add(fund, feeTotal)
fund.Mul(fund, uint256.NewInt(8))
opening := map[common.Address]*uint256.Int{reqr: fund}
ops := make([]common.Address, eligible)
for i := 0; i < eligible; i++ {
ops[i] = addr(byte(0x10 + i))
opening[ops[i]] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
require.NoError(t, v.FundLedger(opening))
for i, op := range ops {
stake := new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(2))
require.NoError(t, e.RegisterOperator(st, v.qledger, op, stake, modelSpec, h(byte(0x80+i))))
}
return v, reqr, ops
}
// REGRESSION (CRITICAL fix) — a REJECTED block rolls back all engine state and
// ledger value it staged: funds restored, intent NOT consumed (re-importable),
// no orphan task. Before the fix, importPending wrote straight to the durable DB
// and Reject discarded nothing.
func TestRegression_RejectedBlockRollsBackEngineState(t *testing.T) {
require := require.New(t)
v, reqr, _ := newAIVMForConsensus(t)
e, st, lg := v.QuorumEngine()
reward := uint256.NewInt(1_000_000_000_000_000_000)
intent := mkIntent(e, reqr, testN, testThr, uint256.NewInt(1), reward)
balBefore := lg.GetBalance(reqr)
escBefore := lg.GetBalance(EscrowAccount)
v.EnqueueCommittedIntent(intent)
blk, err := v.BuildBlock(context.Background())
require.NoError(err)
b := blk.(*Block)
require.Len(b.ImportedIntents, 1, "intent staged during BuildBlock")
// Lose the round: reject.
require.NoError(b.Reject(context.Background()))
// Everything rolled back.
require.Equal(balBefore.String(), lg.GetBalance(reqr).String(),
"requester funds restored after reject")
require.Equal(escBefore.String(), lg.GetBalance(EscrowAccount).String(),
"escrow restored after reject")
require.False(isSet(st.GetState(slotHash(nsIntentSeen, intent.IntentID))),
"intent NOT consumed by a rejected block (re-importable)")
// The intent can be re-imported into a fresh block and this time accepted.
v.EnqueueCommittedIntent(intent)
blk2, err := v.BuildBlock(context.Background())
require.NoError(err)
require.Len(blk2.(*Block).ImportedIntents, 1,
"intent survives a rejected block and imports cleanly afterward")
require.NoError(blk2.(*Block).Accept(context.Background()))
// Now (and only now) is it durably consumed.
require.True(isSet(st.GetState(slotHash(nsIntentSeen, intent.IntentID))),
"intent consumed only after the importing block is ACCEPTED")
}
// REGRESSION (CRITICAL fix) — an ACCEPTED block durably commits the import:
// funds moved, intent consumed, task created, and the side effects persist.
func TestRegression_AcceptedBlockCommitsEngineState(t *testing.T) {
require := require.New(t)
v, reqr, _ := newAIVMForConsensus(t)
e, st, lg := v.QuorumEngine()
reward := uint256.NewInt(1_000_000_000_000_000_000)
intent := mkIntent(e, reqr, testN, testThr, uint256.NewInt(1), reward)
balBefore := lg.GetBalance(reqr)
v.EnqueueCommittedIntent(intent)
blk, err := v.BuildBlock(context.Background())
require.NoError(err)
b := blk.(*Block)
require.NoError(b.Accept(context.Background()))
require.True(lg.GetBalance(reqr).Lt(balBefore), "requester funds moved on accept")
require.True(isSet(st.GetState(slotHash(nsIntentSeen, intent.IntentID))), "intent consumed on accept")
taskID := st.GetState(slotHash(nsIntentTask, intent.IntentID))
require.NotEqual(common.Hash{}, taskID, "task created on accept")
require.Equal(TaskCommitting, e.GetTask(st, taskID).Status)
}
// REGRESSION — the default (no SetCommitVerifier) verifier is fail-closed: it
// admits NO buffered intent, so no task is created from the boundary.
func TestRegression_DefaultVerifierFailClosed(t *testing.T) {
require := require.New(t)
logger := log.NewNoOpLogger()
rt := &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: logger}
v := &VM{}
require.NoError(v.Initialize(context.Background(), vmcore.Init{
Runtime: rt, DB: memdb.New(), ToEngine: make(chan vmcore.Message, 8),
Log: logger, Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
}))
e, _, _ := v.QuorumEngine()
reqr := addr(0xF0)
reward := uint256.NewInt(1_000_000_000_000_000_000)
fund := new(uint256.Int).Mul(reward, uint256.NewInt(uint64(testN*100)))
opening := map[common.Address]*uint256.Int{reqr: fund}
for i := 0; i < eligible; i++ {
opening[addr(byte(0x10+i))] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
require.NoError(v.FundLedger(opening))
for i := 0; i < eligible; i++ {
require.NoError(e.RegisterOperator(v.qstate, v.qledger, addr(byte(0x10+i)),
new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(2)), modelSpec, h(byte(0x80+i))))
}
intent := mkIntent(e, reqr, testN, testThr, uint256.NewInt(1), reward)
v.EnqueueCommittedIntent(intent)
blk, err := v.BuildBlock(context.Background())
require.NoError(err)
require.Len(blk.(*Block).ImportedIntents, 0, "default fail-closed verifier admits NO intent")
}