aivm: an error that says the opposite of what happened, and a guard that guarded nothing

Two of the fifteen defects a probe suite confirmed and nobody had fixed. Both
are self-contained; the rest are one architectural change to the staging layer
and belong together.

WithdrawStake refused an operator that was NOT unbonding and reported
ErrOperatorUnbonding -- the opposite of what had happened. An operator reading
it would conclude it had already deregistered and wait out a cooldown that
never started. Its own comment said "must Deregister first", which is what the
error now says. The existing test asserted the wrong constant while its comment
described the right behaviour, so the assertion was updated to match the
comment rather than the code.

Initialize guarded the runtime for one field and dereferenced it on the next
line, so the guard read as defensive and the nil panic happened anyway. A VM
with no runtime has no logger, no network and no identity, so it refuses.

Still open, and deliberately not touched here: the tip is never persisted, the
staging layer is shared across in-flight blocks so Accept commits a sibling's
writes and Reject drops them, Accept commits engine state before storing the
block, there is no tip check, no size bound, no chain binding, no timestamp
bounds, and a Merkle proof verifies at an index outside its tree. Those are one
job, not nine, and the shape is already settled in fhevm and chain/.
This commit is contained in:
2026-08-28 12:05:06 -07:00
parent 6595c4ff2e
commit a927ce8a7e
14 changed files with 656 additions and 140 deletions
+41 -41
View File
@@ -237,27 +237,27 @@ type ModelRegistryEntry struct {
// AuditAnchor mirrors aivm::cuda::AuditAnchor (128 bytes).
type AuditAnchor struct {
CommitRoot [32]byte
ParentRoot [32]byte
ValidatorSetRootAtCommit [32]byte
Height uint64
TimestampNS uint64
Occupied uint32
_pad0 uint32
_pad1 uint64
CommitRoot [32]byte
ParentRoot [32]byte
ValidatorSetRootAtCommit [32]byte
Height uint64
TimestampNS uint64
Occupied uint32
_pad0 uint32
_pad1 uint64
}
// AIVMEpochState mirrors aivm::cuda::AIVMEpochState (160 bytes).
type AIVMEpochState struct {
CurrentEpoch uint64
NextEpochHeight uint64
TotalActiveAttestations uint64
ActiveModelCount uint32
ExpiredAttestationCount uint32
AttestationRoot [32]byte
ModelRegistryRoot [32]byte
AuditRoot [32]byte
AIVMStateRoot [32]byte
CurrentEpoch uint64
NextEpochHeight uint64
TotalActiveAttestations uint64
ActiveModelCount uint32
ExpiredAttestationCount uint32
AttestationRoot [32]byte
ModelRegistryRoot [32]byte
AuditRoot [32]byte
AIVMStateRoot [32]byte
}
// AIVMRoundDescriptor mirrors aivm::cuda::AIVMRoundDescriptor (96 bytes).
@@ -308,14 +308,14 @@ type ModelOp struct {
// AnchorOp mirrors aivm::cuda::AnchorOp (128 bytes).
type AnchorOp struct {
CommitRoot [32]byte
ParentRoot [32]byte
ValidatorSetRootAtCommit [32]byte
Height uint64
TimestampNS uint64
Epoch uint32
_pad0 uint32
_pad1 uint64
CommitRoot [32]byte
ParentRoot [32]byte
ValidatorSetRootAtCommit [32]byte
Height uint64
TimestampNS uint64
Epoch uint32
_pad0 uint32
_pad1 uint64
}
// AIVMTransitionResult mirrors aivm::cuda::AIVMTransitionResult (192 bytes).
@@ -377,12 +377,12 @@ type InferenceOp struct {
// InferenceResult mirrors aivm::cuda::InferenceResult (112 bytes).
type InferenceResult struct {
Status uint32
OutputLen uint32
InputCommitment [32]byte
OutputCommitment [32]byte
AttestationRoot [32]byte
_pad0 [8]uint8
Status uint32
OutputLen uint32
InputCommitment [32]byte
OutputCommitment [32]byte
AttestationRoot [32]byte
_pad0 [8]uint8
}
// ProofVerifyOp mirrors aivm::cuda::ProofVerifyOp (240 bytes).
@@ -451,16 +451,16 @@ func init() {
// stored at package level by backend.go's init(); call ActiveGPUBackend()
// to retrieve it.
type GPUBackend struct {
mu sync.Mutex
kind BackendKind
handle unsafe.Pointer // dlopen result
path string
fnAttest unsafe.Pointer
fnProv unsafe.Pointer
fnAnchor unsafe.Pointer
fnEpoch unsafe.Pointer
fnInfer unsafe.Pointer
fnProof unsafe.Pointer
mu sync.Mutex
kind BackendKind
handle unsafe.Pointer // dlopen result
path string
fnAttest unsafe.Pointer
fnProv unsafe.Pointer
fnAnchor unsafe.Pointer
fnEpoch unsafe.Pointer
fnInfer unsafe.Pointer
fnProof unsafe.Pointer
}
// Kind returns which backend satisfied the dlopen probe.
+31 -31
View File
@@ -91,27 +91,27 @@ type ModelRegistryEntry struct {
// AuditAnchor mirrors the cgo build's layout.
type AuditAnchor struct {
CommitRoot [32]byte
ParentRoot [32]byte
ValidatorSetRootAtCommit [32]byte
Height uint64
TimestampNS uint64
Occupied uint32
_pad0 uint32
_pad1 uint64
CommitRoot [32]byte
ParentRoot [32]byte
ValidatorSetRootAtCommit [32]byte
Height uint64
TimestampNS uint64
Occupied uint32
_pad0 uint32
_pad1 uint64
}
// AIVMEpochState mirrors the cgo build's layout.
type AIVMEpochState struct {
CurrentEpoch uint64
NextEpochHeight uint64
TotalActiveAttestations uint64
ActiveModelCount uint32
ExpiredAttestationCount uint32
AttestationRoot [32]byte
ModelRegistryRoot [32]byte
AuditRoot [32]byte
AIVMStateRoot [32]byte
CurrentEpoch uint64
NextEpochHeight uint64
TotalActiveAttestations uint64
ActiveModelCount uint32
ExpiredAttestationCount uint32
AttestationRoot [32]byte
ModelRegistryRoot [32]byte
AuditRoot [32]byte
AIVMStateRoot [32]byte
}
// AIVMRoundDescriptor mirrors the cgo build's layout.
@@ -162,14 +162,14 @@ type ModelOp struct {
// AnchorOp mirrors the cgo build's layout.
type AnchorOp struct {
CommitRoot [32]byte
ParentRoot [32]byte
ValidatorSetRootAtCommit [32]byte
Height uint64
TimestampNS uint64
Epoch uint32
_pad0 uint32
_pad1 uint64
CommitRoot [32]byte
ParentRoot [32]byte
ValidatorSetRootAtCommit [32]byte
Height uint64
TimestampNS uint64
Epoch uint32
_pad0 uint32
_pad1 uint64
}
// AIVMTransitionResult mirrors the cgo build's layout.
@@ -231,12 +231,12 @@ type InferenceOp struct {
// InferenceResult mirrors the cgo build's layout.
type InferenceResult struct {
Status uint32
OutputLen uint32
InputCommitment [32]byte
OutputCommitment [32]byte
AttestationRoot [32]byte
_pad0 [8]uint8
Status uint32
OutputLen uint32
InputCommitment [32]byte
OutputCommitment [32]byte
AttestationRoot [32]byte
_pad0 [8]uint8
}
// ProofVerifyOp mirrors the cgo build's layout.
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"fmt"
"os"
"github.com/luxfi/log"
"github.com/luxfi/chains/aivm"
"github.com/luxfi/log"
"github.com/luxfi/sys/ulimit"
"github.com/luxfi/vm/chain"
"github.com/luxfi/vm/rpc"
+4 -4
View File
@@ -47,12 +47,12 @@ import (
// operator (carried alongside the intent; the C-side Fee is the user-facing fee
// that funds it + the burn).
type CIntent struct {
IntentID common.Hash `json:"intentId"` // committed on C; must equal ComputeIntentID(...)
IntentID common.Hash `json:"intentId"` // committed on C; must equal ComputeIntentID(...)
CChainID common.Hash `json:"cChainId"`
AChainID common.Hash `json:"aChainId"`
CTxHash common.Hash `json:"cTxHash"` // C tx that emitted the intent
CallIndex uint32 `json:"callIndex"` // index within that tx
Caller common.Address `json:"caller"` // the C-side requester
CTxHash common.Hash `json:"cTxHash"` // C tx that emitted the intent
CallIndex uint32 `json:"callIndex"` // index within that tx
Caller common.Address `json:"caller"` // the C-side requester
ModelSpecHash common.Hash `json:"modelSpecHash"`
PromptHash common.Hash `json:"promptHash"`
N uint16 `json:"n"`
+5 -1
View File
@@ -184,7 +184,11 @@ func (e *Engine) WithdrawStake(st QuorumState, lg QuorumLedger, operator common.
return nil, ErrOperatorUnknown
}
if !rec.Unbonding {
return nil, ErrOperatorUnbonding // must Deregister first
// The operator is still bonded, so there is nothing to withdraw yet.
// Reporting ErrOperatorUnbonding here said the opposite of what had
// happened, and an operator reading it would conclude it had already
// deregistered.
return nil, ErrOperatorBonded
}
if block < rec.UnbondBlock+UnbondCooldownBlocks {
return nil, ErrCooldownActive
+23 -20
View File
@@ -15,7 +15,10 @@ var (
ErrOperatorExists = errors.New("aivm/quorum: operator already registered")
ErrOperatorUnknown = errors.New("aivm/quorum: operator not registered")
ErrOperatorUnbonding = errors.New("aivm/quorum: operator is unbonding")
ErrCooldownActive = errors.New("aivm/quorum: unbond cooldown not elapsed")
// ErrOperatorBonded is the converse: the operator has not deregistered,
// so no cooldown has started and there is no stake to withdraw.
ErrOperatorBonded = errors.New("aivm/quorum: operator is still bonded")
ErrCooldownActive = errors.New("aivm/quorum: unbond cooldown not elapsed")
// custody / conservation
ErrInsufficientFunds = errors.New("aivm/quorum: insufficient balance")
@@ -35,27 +38,27 @@ var (
ErrEligibleBelowMargin = errors.New("aivm/quorum: eligible operator set below N + required margin")
// task lifecycle
ErrTaskUnknown = errors.New("aivm/quorum: task not found")
ErrTaskNotCommitting = errors.New("aivm/quorum: task not in committing state")
ErrTaskUnknown = errors.New("aivm/quorum: task not found")
ErrTaskNotCommitting = errors.New("aivm/quorum: task not in committing state")
ErrTaskAlreadySettled = errors.New("aivm/quorum: task already settled")
ErrNotSelected = errors.New("aivm/quorum: operator not selected for task")
ErrCommitClosed = errors.New("aivm/quorum: commit window closed")
ErrAlreadyCommitted = errors.New("aivm/quorum: operator already committed")
ErrNotCommitted = errors.New("aivm/quorum: operator did not commit")
ErrRevealNotOpen = errors.New("aivm/quorum: reveal window not open")
ErrRevealClosed = errors.New("aivm/quorum: reveal window closed")
ErrAlreadyRevealed = errors.New("aivm/quorum: operator already revealed")
ErrCommitMismatch = errors.New("aivm/quorum: reveal does not match commit")
ErrEmptyCommit = errors.New("aivm/quorum: empty commit hash")
ErrEmptyOutputHash = errors.New("aivm/quorum: empty output hash")
ErrSettleTooEarly = errors.New("aivm/quorum: reveal window not closed")
ErrNotSelected = errors.New("aivm/quorum: operator not selected for task")
ErrCommitClosed = errors.New("aivm/quorum: commit window closed")
ErrAlreadyCommitted = errors.New("aivm/quorum: operator already committed")
ErrNotCommitted = errors.New("aivm/quorum: operator did not commit")
ErrRevealNotOpen = errors.New("aivm/quorum: reveal window not open")
ErrRevealClosed = errors.New("aivm/quorum: reveal window closed")
ErrAlreadyRevealed = errors.New("aivm/quorum: operator already revealed")
ErrCommitMismatch = errors.New("aivm/quorum: reveal does not match commit")
ErrEmptyCommit = errors.New("aivm/quorum: empty commit hash")
ErrEmptyOutputHash = errors.New("aivm/quorum: empty output hash")
ErrSettleTooEarly = errors.New("aivm/quorum: reveal window not closed")
// cross-chain seam
ErrIntentNilAmount = errors.New("aivm/quorum: C-chain intent has a nil fee or reward amount")
ErrIntentIDMismatch = errors.New("aivm/quorum: recomputed intent_id does not match delivered id (forged/tampered intent)")
ErrIntentNotCommitted = errors.New("aivm/quorum: C-chain intent is not committed (proof rejected)")
ErrIntentAlreadyUsed = errors.New("aivm/quorum: C-chain intent already consumed (replay)")
ErrReceiptNotFound = errors.New("aivm/quorum: no settled receipt for intent")
ErrProofOutOfRange = errors.New("aivm/quorum: receipt index out of range")
ErrIntentNilAmount = errors.New("aivm/quorum: C-chain intent has a nil fee or reward amount")
ErrIntentIDMismatch = errors.New("aivm/quorum: recomputed intent_id does not match delivered id (forged/tampered intent)")
ErrIntentNotCommitted = errors.New("aivm/quorum: C-chain intent is not committed (proof rejected)")
ErrIntentAlreadyUsed = errors.New("aivm/quorum: C-chain intent already consumed (replay)")
ErrReceiptNotFound = errors.New("aivm/quorum: no settled receipt for intent")
ErrProofOutOfRange = errors.New("aivm/quorum: receipt index out of range")
ErrReceiptRootMismatch = errors.New("aivm/quorum: block receipt_root does not match re-derived engine state")
)
+27 -27
View File
@@ -196,33 +196,33 @@ func (l *MemLedger) Restore(snap map[common.Address]*uint256.Int) {
// ---------------------------------------------------------------------------
var (
nsOperator = []byte("av/op") // operator registry meta
nsModelIndex = []byte("av/mspec.idx") // per-ModelSpec operator-array length
nsModelMember = []byte("av/mspec.mem") // per-ModelSpec operator-array element
nsModelSeen = []byte("av/mspec.seen") // per-(ModelSpec,operator) membership flag
nsCredit = []byte("av/cred") // operator withdrawable credit ledger
nsReqNonce = []byte("av/req.nonce") // requester monotonic nonce
nsTask = []byte("av/task") // task record (status + params) *** also job_id domain
nsTaskReward = []byte("av/task.reward") // task rewardPerOperator (uint256)
nsTaskEscrow = []byte("av/task.escrow") // task remaining escrow (uint256)
nsTaskIntent = []byte("av/task.intent") // task -> source C intent id
nsTaskFee = []byte("av/task.fee") // task fee paid (uint256, for the receipt)
nsSelected = []byte("av/sel") // per-(task,operator) selection flag
nsSelList = []byte("av/sel.list") // per-task selected-operator-array element
nsCommit = []byte("av/commit") // per-(task,operator) commit hash
nsReveal = []byte("av/reveal") // per-(task,operator) revealed output_hash
nsRevealFlag = []byte("av/reveal.f") // per-(task,operator) revealed flag
nsRevealList = []byte("av/reveal.list") // per-task revealer-array element
nsRevealCount = []byte("av/reveal.cnt") // per-task revealer count
nsSettled = []byte("av/settled") // per-task settled marker (replay guard)
nsCanonical = []byte("av/canon") // per-task canonical output_hash
nsReceiptRoot = []byte("av/receipt.root")// running receipt_root accumulator
nsReceiptIdx = []byte("av/receipt.idx") // count of settled receipts in the root
nsReceiptLeaf = []byte("av/receipt.leaf")// settled receipt_hash by index (for proofs)
nsIntentSeen = []byte("av/intent.seen") // per-C-intent consumed marker (anti-replay)
nsIntentTask = []byte("av/intent.task") // per-C-intent -> A-chain task id
nsIntentRcpt = []byte("av/intent.rcpt") // per-C-intent settled-receipt leaf index+1 (0 = unsettled)
nsTaskHeight = []byte("av/task.height") // per-task settled height (so receipt is reproducible)
nsOperator = []byte("av/op") // operator registry meta
nsModelIndex = []byte("av/mspec.idx") // per-ModelSpec operator-array length
nsModelMember = []byte("av/mspec.mem") // per-ModelSpec operator-array element
nsModelSeen = []byte("av/mspec.seen") // per-(ModelSpec,operator) membership flag
nsCredit = []byte("av/cred") // operator withdrawable credit ledger
nsReqNonce = []byte("av/req.nonce") // requester monotonic nonce
nsTask = []byte("av/task") // task record (status + params) *** also job_id domain
nsTaskReward = []byte("av/task.reward") // task rewardPerOperator (uint256)
nsTaskEscrow = []byte("av/task.escrow") // task remaining escrow (uint256)
nsTaskIntent = []byte("av/task.intent") // task -> source C intent id
nsTaskFee = []byte("av/task.fee") // task fee paid (uint256, for the receipt)
nsSelected = []byte("av/sel") // per-(task,operator) selection flag
nsSelList = []byte("av/sel.list") // per-task selected-operator-array element
nsCommit = []byte("av/commit") // per-(task,operator) commit hash
nsReveal = []byte("av/reveal") // per-(task,operator) revealed output_hash
nsRevealFlag = []byte("av/reveal.f") // per-(task,operator) revealed flag
nsRevealList = []byte("av/reveal.list") // per-task revealer-array element
nsRevealCount = []byte("av/reveal.cnt") // per-task revealer count
nsSettled = []byte("av/settled") // per-task settled marker (replay guard)
nsCanonical = []byte("av/canon") // per-task canonical output_hash
nsReceiptRoot = []byte("av/receipt.root") // running receipt_root accumulator
nsReceiptIdx = []byte("av/receipt.idx") // count of settled receipts in the root
nsReceiptLeaf = []byte("av/receipt.leaf") // settled receipt_hash by index (for proofs)
nsIntentSeen = []byte("av/intent.seen") // per-C-intent consumed marker (anti-replay)
nsIntentTask = []byte("av/intent.task") // per-C-intent -> A-chain task id
nsIntentRcpt = []byte("av/intent.rcpt") // per-C-intent settled-receipt leaf index+1 (0 = unsettled)
nsTaskHeight = []byte("av/task.height") // per-task settled height (so receipt is reproducible)
)
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -566,7 +566,7 @@ func TestUnstakeCooldown(t *testing.T) {
// cannot withdraw before deregister.
_, err := e.WithdrawStake(st, lg, op, 1000)
require.ErrorIs(err, ErrOperatorUnbonding)
require.ErrorIs(err, ErrOperatorBonded)
require.NoError(e.DeregisterOperator(st, op, 100))
require.False(e.IsEligible(st, op, modelSpec), "unbonding op is ineligible immediately")
+4 -4
View File
@@ -32,8 +32,8 @@ const ReceiptVersion uint16 = 1
// reordered.
type AInferenceReceipt struct {
Version uint16 `json:"version"`
IntentID common.Hash `json:"intentId"` // source C-chain intent id
TaskID common.Hash `json:"taskId"` // A-chain task id (beacon anchor)
IntentID common.Hash `json:"intentId"` // source C-chain intent id
TaskID common.Hash `json:"taskId"` // A-chain task id (beacon anchor)
CChainID common.Hash `json:"cChainId"`
AChainID common.Hash `json:"aChainId"`
Requester common.Address `json:"requester"`
@@ -43,8 +43,8 @@ type AInferenceReceipt struct {
Status uint8 `json:"status"` // StatusCompleted / StatusFailed / ...
N uint16 `json:"n"`
Threshold uint16 `json:"threshold"`
WinnersRoot common.Hash `json:"winnersRoot"` // merkle root over winner addresses
OperatorsRoot common.Hash `json:"operatorsRoot"` // merkle root over selected addresses
WinnersRoot common.Hash `json:"winnersRoot"` // merkle root over winner addresses
OperatorsRoot common.Hash `json:"operatorsRoot"` // merkle root over selected addresses
FeePaid *uint256.Int `json:"feePaid"`
SettledAtHeight uint64 `json:"settledAtHeight"`
}
-1
View File
@@ -150,4 +150,3 @@ func TestProbe_CommitRevealWindowsDisjoint(t *testing.T) {
require.Equal(uint64(160), info.RevealDeadline)
// commit window: [reqHeight, 130]; reveal window: (130, 160]. Disjoint at 130/131.
}
+7 -7
View File
@@ -39,14 +39,14 @@ import (
// SettleResult is the outcome of Settle, returned for callers + tests.
type SettleResult struct {
Status TaskState // TaskSettled or TaskFailed
CanonicalHash common.Hash // winning output_hash (zero if Failed)
WinnerCount uint32 // size of the winning group (0 if Failed)
Paid *uint256.Int // total wei paid out as rewards (0 if Failed)
Slashed *uint256.Int // total wei slashed from withholders
Status TaskState // TaskSettled or TaskFailed
CanonicalHash common.Hash // winning output_hash (zero if Failed)
WinnerCount uint32 // size of the winning group (0 if Failed)
Paid *uint256.Int // total wei paid out as rewards (0 if Failed)
Slashed *uint256.Int // total wei slashed from withholders
Receipt AInferenceReceipt // the emitted cross-chain receipt
ReceiptHash common.Hash // keccak(DomainReceipt || encoding)
ReceiptRoot common.Hash // receipt_root AFTER folding this receipt
ReceiptHash common.Hash // keccak(DomainReceipt || encoding)
ReceiptRoot common.Hash // receipt_root AFTER folding this receipt
}
// Settle finalizes a task. height must be > revealDeadline. Idempotent.
+10 -2
View File
@@ -297,13 +297,21 @@ type ProviderReg struct {
}
// Initialize initializes the VM with the unified Init struct
// errRuntimeRequired is returned by Initialize when the consensus runtime is
// absent. Every later method assumes it.
var errRuntimeRequired = errors.New("aivm: Initialize requires a runtime")
func (vm *VM) Initialize(ctx context.Context, init vmcore.Init) error {
vm.rt = init.Runtime
vm.db = init.DB
vm.toEngine = init.ToEngine
if vm.rt != nil {
vm.networkID = vm.rt.NetworkID
// A VM with no runtime has no logger, no network and no node identity.
// Guarding networkID and then dereferencing rt.Log on the next line meant
// the guard read as defensive while the panic happened anyway.
if vm.rt == nil {
return errRuntimeRequired
}
vm.networkID = vm.rt.NetworkID
if logger, ok := vm.rt.Log.(log.Logger); ok {
vm.log = logger
+188
View File
@@ -0,0 +1,188 @@
package aivm
import (
"context"
"sync"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/database"
"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"
)
// failDB fails every Put after the Nth.
type failDB struct {
database.Database
mu sync.Mutex
n int
fail bool
}
func (d *failDB) Put(k, v []byte) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.fail {
return errProbeDBDown
}
d.n++
return d.Database.Put(k, v)
}
var errProbeDBDown = &probeErr{"probe: db down"}
type probeErr struct{ s string }
func (e *probeErr) Error() string { return e.s }
// P5: Accept commits engine state, then fails to store the block. Torn?
func TestProbe_P5_AcceptTornWrite(t *testing.T) {
inner := memdb.New()
db := &failDB{Database: inner}
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), probeInit(db)))
vm.SetCommitVerifier(VerifierFunc(func(CIntent) error { return nil }))
ctx := context.Background()
reward := uint256.NewInt(1_000_000_000_000_000_000)
e, st, _ := vm.QuorumEngine()
seedOperators(t, vm, e, st, reward)
intent := buildIntent(e, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), reward)
vm.EnqueueCommittedIntent(intent)
blk, err := vm.BuildBlock(ctx)
require.NoError(t, err)
require.NoError(t, blk.Verify(ctx))
db.mu.Lock()
db.fail = true
db.mu.Unlock()
acceptErr := blk.Accept(ctx)
db.mu.Lock()
db.fail = false
db.mu.Unlock()
t.Logf("Accept with a failing DB = %v", acceptErr)
raw := NewDBState(inner)
engineCommitted := isSet(raw.GetState(slotHash(nsIntentSeen, intent.IntentID)))
head, _ := vm.LastAccepted(ctx)
blockStored := false
if _, err := func() ([]byte, error) { id := blk.ID(); return inner.Get(id[:]) }(); err == nil {
blockStored = true
}
t.Logf("engineCommitted=%v blockStored=%v head=%s blkID=%s", engineCommitted, blockStored, head, blk.ID())
if acceptErr != nil && engineCommitted && !blockStored {
t.Errorf("P5 CONFIRMED: Accept failed but the engine delta is already durable — the block is gone and its state is not")
}
}
// P14: verifyImported tolerates ErrIntentAlreadyUsed. Can a proposer record the
// same intent twice, or an intent an earlier block already consumed?
func TestProbe_P14_DuplicateIntentInBlock(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
reward := uint256.NewInt(1_000_000_000_000_000_000)
e, st, _ := vm.QuorumEngine()
seedOperators(t, vm, e, st, reward)
intent := buildIntent(e, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), reward)
// A hand-built block recording the same intent 3 times.
blk := &Block{ParentID_: vm.lastAcceptedID, Height_: 1,
ImportedIntents: []CIntent{intent, intent, intent}, vm: vm}
// The proposer stamps whatever root its own run produced; compute it honestly.
require.NoError(t, blk.Verify(ctx))
t.Logf("a block recording the same intent 3x verified (receiptRoot=%s)", blk.ReceiptRoot.Hex())
n := e.LiveTasks(st)
if n != 1 {
t.Errorf("P14: %d tasks created from one intent recorded 3x", n)
} else {
t.Logf("P14 benign: duplicate records collapse to 1 task (anti-replay held)")
}
}
// P15: concurrent reads of vm.running against Shutdown.
func TestProbe_P15_RunningRace(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
_ = vm.GetProviders()
_, _ = vm.HealthCheck(ctx)
_ = vm.GetMerkleRoot()
}
}()
}
wg.Add(1)
go func() { defer wg.Done(); _ = vm.Shutdown(ctx) }()
wg.Wait()
}
// P16: settleDue runs on both build and verify at the same height. Does the
// second pass over already-settled tasks change anything?
func TestProbe_P16_DoubleSettlePass(t *testing.T) {
e := NewEngine(hashOf(1), hashOf(2))
st := NewMemState()
reward := uint256.NewInt(1_000_000_000_000_000_000)
requester := addrOf(0xF0)
opening := map[common.Address]*uint256.Int{requester: new(uint256.Int).Mul(reward, uint256.NewInt(64))}
ops := make([]common.Address, 8)
for i := range ops {
ops[i] = addrOf(byte(0x10 + i))
opening[ops[i]] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
lg := NewMemLedger(opening)
for i, op := range ops {
require.NoError(t, e.RegisterOperator(st, lg, op, new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(2)), hashOf(0xAB), hashOf(byte(0x80+i))))
}
in := buildIntent(e, requester, hashOf(0xAB), 5, 3, uint256.NewInt(1), reward)
taskID, err := e.ImportCommittedIntent(st, lg, VerifierFunc(func(CIntent) error { return nil }), in, 1)
require.NoError(t, err)
sel, _ := e.SelectOperators(st, taskID, hashOf(0xAB), 5)
out := hashOf(0x42)
for i := 0; i < 3; i++ {
c := ComputeCommit(taskID, hashOf(0xAB), hashOf(0xCD), out, hashOf(7), sel[i], hashOf(9))
require.NoError(t, e.CommitResponse(st, taskID, sel[i], c, 2))
require.NoError(t, e.RevealResponse(st, taskID, sel[i], out, hashOf(7), hashOf(9), 32))
}
total := lg.Total()
n1 := e.SettleDue(st, lg, 62)
root1 := e.ReceiptRoot(st)
n2 := e.SettleDue(st, lg, 62)
root2 := e.ReceiptRoot(st)
t.Logf("settleDue pass1=%d pass2=%d root1=%s root2=%s", n1, n2, root1.Hex(), root2.Hex())
require.Equal(t, root1, root2, "P16: a second settleDue at the same height changed the receipt root")
require.Equal(t, total.String(), lg.Total().String())
}
// P17: WithdrawStake wrong-error probe + re-register after withdraw.
func TestProbe_P17_WithdrawErrors(t *testing.T) {
e := NewEngine(hashOf(1), hashOf(2))
st := NewMemState()
op := addrOf(1)
lg := NewMemLedger(map[common.Address]*uint256.Int{op: new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))})
require.NoError(t, e.RegisterOperator(st, lg, op, MinProviderBond, hashOf(0xAB), hashOf(1)))
_, err := e.WithdrawStake(st, lg, op, 100)
t.Logf("WithdrawStake before Deregister = %v", err)
if err == ErrOperatorUnbonding {
t.Errorf("P17 CONFIRMED: refusing a NOT-unbonding operator reports 'operator is unbonding'")
}
}
func probeInit(db database.Database) vmcore.Init {
return vmcore.Init{
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: log.NewNoOpLogger()},
DB: db,
ToEngine: make(chan vmcore.Message, 8),
Log: log.NewNoOpLogger(),
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
}
}
+314
View File
@@ -0,0 +1,314 @@
package aivm
import (
"context"
"testing"
"time"
"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/luxfi/zap"
"github.com/stretchr/testify/require"
)
func probeVM(t *testing.T, db *memdb.Database) *VM {
t.Helper()
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: log.NewNoOpLogger()},
DB: db,
ToEngine: make(chan vmcore.Message, 8),
Log: log.NewNoOpLogger(),
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
}))
vm.SetCommitVerifier(VerifierFunc(func(CIntent) error { return nil }))
return vm
}
// P1: does Accept persist the tip? Restart on the same DB and see what head we name.
func TestProbe_P1_RestartLosesTip(t *testing.T) {
db := memdb.New()
vm := probeVM(t, db)
ctx := context.Background()
blk, err := vm.BuildBlock(ctx)
require.NoError(t, err)
require.NoError(t, blk.Verify(ctx))
require.NoError(t, blk.Accept(ctx))
head, _ := vm.LastAccepted(ctx)
require.Equal(t, blk.ID(), head)
vm2 := probeVM(t, db)
head2, _ := vm2.LastAccepted(ctx)
t.Logf("tip before restart=%s after restart=%s", head, head2)
if head2 != head {
t.Errorf("P1 CONFIRMED: restart forgot the accepted tip (came back at %s)", head2)
}
}
// P2: does Verify check the parent is the tip / the height follows?
func TestProbe_P2_NoTipCheck(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
b1, _ := vm.BuildBlock(ctx)
require.NoError(t, b1.Verify(ctx))
require.NoError(t, b1.Accept(ctx))
// A block naming a parent that is not the tip, at an arbitrary height.
orphan := &Block{ParentID_: ids.GenerateTestID(), Height_: 99, Timestamp_: time.Now(), vm: vm}
orphan.ID_ = orphan.computeID()
err := orphan.Verify(ctx)
t.Logf("verify(orphan parent, height 99) = %v", err)
if err == nil {
t.Errorf("P2 CONFIRMED: a block with an unknown parent and a jumped height verifies")
}
// And one that rewinds height below the tip.
rewind := &Block{ParentID_: b1.Parent(), Height_: 1, Timestamp_: time.Now(), vm: vm}
rewind.ID_ = rewind.computeID()
if err := rewind.Verify(ctx); err == nil {
if err := rewind.Accept(ctx); err == nil {
head, _ := vm.LastAccepted(ctx)
t.Errorf("P2 CONFIRMED: an old sibling was accepted and became the head (%s)", head)
}
}
}
// P3/P4: one shared staging versiondb across every pending block.
func TestProbe_P3_CrossBlockStaging(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
reward := uint256.NewInt(1_000_000_000_000_000_000)
e, st, _ := vm.QuorumEngine()
seedOperators(t, vm, e, st, reward)
// Block A: no intent. Block B: one intent (writes engine state).
blkA, err := vm.BuildBlock(ctx)
require.NoError(t, err)
require.NoError(t, blkA.Verify(ctx))
intent := buildIntent(e, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), reward)
vm.EnqueueCommittedIntent(intent)
blkB, err := vm.BuildBlock(ctx)
require.NoError(t, err)
require.NoError(t, blkB.Verify(ctx))
// Accept only A. B's writes must NOT become durable.
require.NoError(t, blkA.Accept(ctx))
raw := NewDBState(vm.db)
if isSet(raw.GetState(slotHash(nsIntentSeen, intent.IntentID))) {
t.Errorf("P3 CONFIRMED: Accept(A) committed block B's staged intent to the durable DB")
}
}
func TestProbe_P4_RejectDropsSiblingWrites(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
reward := uint256.NewInt(1_000_000_000_000_000_000)
e, st, _ := vm.QuorumEngine()
seedOperators(t, vm, e, st, reward)
intent := buildIntent(e, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), reward)
vm.EnqueueCommittedIntent(intent)
blkA, err := vm.BuildBlock(ctx)
require.NoError(t, err)
require.NoError(t, blkA.Verify(ctx))
blkB, err := vm.BuildBlock(ctx)
require.NoError(t, err)
require.NoError(t, blkB.Verify(ctx))
require.NoError(t, blkB.Reject(ctx))
require.NoError(t, blkA.Accept(ctx))
raw := NewDBState(vm.db)
if !isSet(raw.GetState(slotHash(nsIntentSeen, intent.IntentID))) {
t.Errorf("P4 CONFIRMED: Reject(B) dropped block A's staged writes; Accept(A) committed nothing")
}
}
func seedOperators(t *testing.T, vm *VM, e *Engine, st QuorumState, reward *uint256.Int) {
t.Helper()
requester := addrOf(0xF0)
fund := new(uint256.Int).Mul(reward, uint256.NewInt(64))
opening := map[common.Address]*uint256.Int{requester: fund}
ops := make([]common.Address, consciousEligible)
for i := range ops {
ops[i] = addrOf(byte(0x10 + i))
opening[ops[i]] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
require.NoError(t, vm.FundLedger(opening))
_, _, lg := vm.QuorumEngine()
for i, op := range ops {
require.NoError(t, e.RegisterOperator(st, lg, op,
new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(2)), hashOf(0xAB), hashOf(byte(0x80+i))))
}
}
// P6: is the wire canonical? Feed padding the parser ignores and compare ids.
func TestProbe_P6_WireMalleable(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
blk := &Block{ParentID_: ids.GenerateTestID(), Height_: 7, Timestamp_: time.Unix(0, 1700000000)}
blk.Tasks = nil
canonical, err := blk.Marshal()
require.NoError(t, err)
parsed, err := vm.ParseBlock(ctx, canonical)
require.NoError(t, err)
require.Equal(t, canonical, parsed.Bytes())
// zap builder pads; probe whether a longer buffer with the same declared root
// still parses.
padded := append(append([]byte(nil), canonical...), 0x00)
if _, err := vm.ParseBlock(ctx, padded); err == nil {
t.Errorf("P6 CONFIRMED: a trailing byte past the content still parses")
} else {
t.Logf("padded parse rejected: %v", err)
}
}
// P6b: unpackObjs/unpackJSON ignore blob remainder; splitIDs ignores a short tail.
func TestProbe_P6b_BlobRemainderIgnored(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
// Build a block wire by hand with an intent blob longer than the declared lens.
in := CIntent{IntentID: hashOf(1), Fee: uint256.NewInt(1), RewardPerOperator: uint256.NewInt(1)}
one := marshalCIntent(in)
lens := []uint32{uint32(len(one))}
blob := append(append([]byte(nil), one...), 0xDE, 0xAD, 0xBE, 0xEF) // 4 stray bytes
b := zap.NewBuilder(zap.HeaderSize + blkSize + len(blob) + 256)
lensOff := writeU32List(b, lens)
ob := b.StartObject(blkSize)
ob.SetUint8(blkKind, uint8(kindBlock))
ob.SetUint64(blkHeight, 3)
ob.SetList(blkIntentLens, lensOff, len(lens))
ob.SetBytes(blkIntentBlob, blob)
ob.FinishAsRoot()
wire := b.Finish()
parsed, err := vm.ParseBlock(ctx, wire)
if err == nil {
t.Errorf("P6b CONFIRMED: %d stray bytes in the intent blob were ignored; id=%s", 4, parsed.ID())
} else {
t.Logf("stray blob bytes rejected: %v", err)
}
}
// P7: is there any size bound on an inbound block?
func TestProbe_P7_NoSizeBound(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
big := make([]ProviderReg, 0, 20000)
for i := 0; i < 20000; i++ {
big = append(big, ProviderReg{ProviderID: string(make([]byte, 400))})
}
blk := &Block{Height_: 1, ProviderRegs: big}
wire, err := blk.Marshal()
require.NoError(t, err)
t.Logf("block wire size = %d bytes", len(wire))
if _, err := vm.ParseBlock(ctx, wire); err == nil && len(wire) > 4<<20 {
t.Errorf("P7 CONFIRMED: a %d-byte block parsed with no size bound", len(wire))
}
}
// P8: is a block bound to the chain it was built on?
func TestProbe_P8_NoChainBinding(t *testing.T) {
ctx := context.Background()
vmA := probeVM(t, memdb.New())
vmB := probeVM(t, memdb.New())
blk, err := vmA.BuildBlock(ctx)
require.NoError(t, err)
wire := blk.Bytes()
foreign, err := vmB.ParseBlock(ctx, wire)
if err == nil && foreign.ID() == blk.ID() {
t.Errorf("P8 CONFIRMED: a block built on chain A parses byte-identically on chain B (id %s)", foreign.ID())
}
}
// P9: chain time. Does Verify bound the timestamp at all?
func TestProbe_P9_UnboundedTimestamp(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
b1, _ := vm.BuildBlock(ctx)
require.NoError(t, b1.Verify(ctx))
require.NoError(t, b1.Accept(ctx))
future := &Block{ParentID_: b1.ID(), Height_: 2, Timestamp_: time.Now().Add(1000 * time.Hour), vm: vm}
future.ID_ = future.computeID()
if err := future.Verify(ctx); err == nil {
t.Errorf("P9 CONFIRMED: a timestamp 1000h in the future verifies")
}
past := &Block{ParentID_: b1.ID(), Height_: 2, Timestamp_: b1.Timestamp().Add(-time.Hour), vm: vm}
past.ID_ = past.computeID()
if err := past.Verify(ctx); err == nil {
t.Errorf("P9 CONFIRMED: a timestamp BEFORE the parent's verifies")
}
}
// P10/P11: what does the vertex id actually cover?
func TestProbe_P10_VertexIDDoesNotCoverPayload(t *testing.T) {
vm := probeVM(t, memdb.New())
v := &AIVertex{height: 1, epoch: 0, parents: []ids.ID{ids.Empty}, jobIDs: []string{"job-1"}, vm: vm}
v.id = v.computeID()
v2 := &AIVertex{height: 1, epoch: 0, parents: []ids.ID{ids.Empty}, jobIDs: []string{"job-1"},
txIDs: []ids.ID{ids.GenerateTestID()}, vm: vm}
if v.computeID() == v2.computeID() {
t.Errorf("P10 CONFIRMED: vertex id ignores txIDs — payload is unauthenticated")
}
a := &AIVertex{height: 1, jobIDs: []string{"ab", "c"}}
b := &AIVertex{height: 1, jobIDs: []string{"a", "bc"}}
if a.computeID() == b.computeID() {
t.Errorf("P11 CONFIRMED: jobIDs are concatenated unseparated — [ab,c] and [a,bc] collide")
}
}
// P12: packJSON drops the marshal error.
func TestProbe_P12_UnmarshalableTaskSilentlyEmptied(t *testing.T) {
vm := probeVM(t, memdb.New())
ctx := context.Background()
blk := &Block{Height_: 1, ProviderRegs: []ProviderReg{{ProviderID: string([]byte{0xff, 0xfe})}}}
// json.Marshal fails on invalid UTF-8? No — it escapes. Use a RawMessage instead.
wire, err := blk.Marshal()
require.NoError(t, err)
if _, err := vm.ParseBlock(ctx, wire); err != nil {
t.Logf("P12: parse of an odd ProviderReg failed: %v", err)
}
}
// P13: is the merkle proof index authenticated?
func TestProbe_P13_ProofIndexUnbound(t *testing.T) {
leaves := []common.Hash{hashOf(1), hashOf(2), hashOf(3), hashOf(4)}
hashed := make([]common.Hash, len(leaves))
for i, l := range leaves {
hashed[i] = leafHash(l)
}
root := merkleRoot(hashed)
p := merkleProof(hashed, 0)
require.True(t, VerifyReceiptProof(leaves[0], p, root))
// Same siblings, a wildly out-of-range index whose low bits match.
p2 := MerkleProof{Index: p.Index + 1<<uint(len(p.Siblings)), Siblings: p.Siblings}
if VerifyReceiptProof(leaves[0], p2, root) {
t.Errorf("P13 CONFIRMED: proof verifies at index %d in a 4-leaf tree", p2.Index)
}
// Truncated proof against a deeper tree.
if VerifyReceiptProof(leaves[0], MerkleProof{Index: 0, Siblings: nil}, leafHash(leaves[0])) {
t.Logf("P13: a zero-sibling proof verifies against a single-leaf root (expected)")
}
}
// P18: Initialize with no Runtime.
func TestProbe_P18_NilRuntime(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("P18 CONFIRMED: Initialize panics with a nil Runtime: %v", r)
}
}()
vm := &VM{}
err := vm.Initialize(context.Background(), vmcore.Init{DB: memdb.New()})
t.Logf("Initialize(no runtime) = %v", err)
}