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.
This commit is contained in:
2026-08-28 15:03:32 -07:00
parent 787284d140
commit eb02967a9a
28 changed files with 3941 additions and 1195 deletions
+8 -124
View File
@@ -6,8 +6,6 @@
package aivm
import (
"os"
"path/filepath"
"testing"
"unsafe"
)
@@ -49,128 +47,14 @@ func TestLayoutSizes(t *testing.T) {
}
}
// locatePlugin tries the same env-driven search as backend.go's
// candidatePaths() but in a test-friendly form: it returns the first
// loadable plugin's filename, or "" with t.Skip if none are present.
//
// Order matches platformCandidates() — the dlopen probe used by init().
func locatePlugin(t *testing.T) (BackendKind, string) {
t.Helper()
for _, c := range platformCandidates() {
for _, p := range candidatePaths(c) {
if !filepath.IsAbs(p) {
continue // skip bare-name candidates — too noisy in CI
}
if _, err := os.Stat(p); err == nil {
return c.kind, p
}
}
}
return AvailableNone, ""
}
// TestRoundTripAttestation is the canonical end-to-end check: dlopen the
// best-available plugin, dlsym lux_<kind>_aivm_attestation_apply, and
// invoke it with a zero-input fixture. The kernel should accept the
// payload and return rc==0 with applied_out == 0 (no ops applied, table
// untouched).
//
// We use the FULL openGPUBackend path (the same one production init()
// uses) rather than rolling our own dlsym so the test exercises the real
// resolution sequence. If openGPUBackend can't find the plugin file
// (CI without the lux GPU plugin), we Skip.
func TestRoundTripAttestation(t *testing.T) {
// Force a re-probe in case the package-level activeBackend was loaded
// before the test environment was set up.
kind, path := locatePlugin(t)
if kind == AvailableNone || path == "" {
t.Skip("aivm: no lux-gpu-kernels plugin DSO found on this host " +
"(set LUX_GPU_PLUGIN_DIR or LUX_GPU_PLUGIN_DIR)")
}
t.Logf("aivm: using plugin %s at %s", kind, path)
b, err := openGPUBackend(kind, path)
if err != nil {
t.Fatalf("openGPUBackend(%s, %s): %v", kind, path, err)
}
defer b.Close()
if !b.IsAvailable() {
t.Fatalf("openGPUBackend returned non-nil backend but IsAvailable() == false")
}
if b.Kind() != kind {
t.Errorf("backend kind: got %s, want %s", b.Kind(), kind)
}
if b.Path() != path {
t.Errorf("backend path: got %q, want %q", b.Path(), path)
}
// Zero-input fixture:
// - One round descriptor with attestation_op_count = 0
// - No attestation ops
// - 16-slot attestation table (must be > 0 and a power of two for
// the kernel's open-addressing locator)
// - applied_out initialised to 0xFFFFFFFF so we can verify the
// kernel wrote a fresh count
desc := &AIVMRoundDescriptor{
ChainID: 1,
Round: 1,
TimestampNS: 1_000_000_000,
Epoch: 0,
Mode: 0, // kModeAttestation
AttestationOpCount: 0,
ModelOpCount: 0,
AnchorOpCount: 0,
}
// Every launcher (metal / vulkan / cuda / hip / webgpu) rejects a NULL
// ops pointer with rc=1 even when desc.AttestationOpCount==0. Hand it
// a one-element placeholder slice; the kernel reads ops[0..op_count)
// and op_count is 0, so the placeholder is untouched.
ops := make([]AttestationOp, 1)
table := make([]Attestation, 16)
applied := uint32(0xFFFFFFFF)
if err := b.AttestationApply(desc, ops, table, &applied); err != nil {
t.Fatalf("AttestationApply: %v", err)
}
// With zero ops, the kernel must touch nothing — applied count must
// be 0 and the table must remain entirely unoccupied.
if applied != 0 {
t.Errorf("zero-op AttestationApply: applied_out = %d, want 0", applied)
}
for i, slot := range table {
if slot.Occupied != 0 {
t.Errorf("zero-op AttestationApply: table[%d].Occupied = %d, want 0",
i, slot.Occupied)
}
}
}
// TestRoundTripProofVerifyZeroOps exercises the proof-verify launcher with
// op_count==0. The launcher must short-circuit (rc=0) without touching the
// (nil) buffers — same behaviour as the metal / vulkan / cuda launchers.
//
// This is a second round-trip beyond the spec'd one to also exercise the
// "early return on op_count==0" branch that lives in every launcher.
func TestRoundTripProofVerifyZeroOps(t *testing.T) {
kind, path := locatePlugin(t)
if kind == AvailableNone || path == "" {
t.Skip("aivm: no lux-gpu-kernels plugin DSO found on this host")
}
b, err := openGPUBackend(kind, path)
if err != nil {
t.Fatalf("openGPUBackend: %v", err)
}
defer b.Close()
// op_count == 0 → the Go wrapper short-circuits before reaching C.
// This still exercises the IsAvailable() guard and the nil-input
// handling that vm.go callers rely on.
if err := b.ProofVerify(nil, nil); err != nil {
t.Fatalf("ProofVerify(nil, nil): unexpected error: %v", err)
}
}
// The two round-trip tests that used to live here probed the host for a vendor
// plugin DSO and SKIPPED when none was found — which was every machine without
// CUDA, so they never ran and never could have failed. What they were reaching
// for, the dlopen/dlsym/launcher round trip, is now driven in gpu_plugin_test.go
// against a mirror plugin compiled at test time, so it runs everywhere. What
// they additionally asserted — that a real kernel leaves a zero-op table
// untouched — is kernel arithmetic, which no mirror can stand in for and which
// belongs with the kernel.
// TestNoPluginFallback ensures the package degrades cleanly when no plugin
// is reachable. We deliberately probe a path that can't exist and assert
+5 -4
View File
@@ -135,14 +135,15 @@ func autoLoadBackend() *GPUBackend {
activeBackendOnce.Do(func() {
for _, c := range platformCandidates() {
for _, path := range candidatePaths(c) {
// openGPUBackend returns a backend only when the handle opened
// AND all six launchers resolved, so a nil error here already
// means IsAvailable. Re-testing it read as care and was a branch
// no input could take — and a branch nothing can reach is a
// branch nothing can check.
b, err := openGPUBackend(c.kind, path)
if err != nil {
continue // try the next candidate path
}
if !b.IsAvailable() {
b.Close()
continue
}
activeBackend = b
return
}
+452
View File
@@ -0,0 +1,452 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package aivm
// block.go is the A-Chain block: how one is named, checked, and made fact.
//
// Three properties carry everything else here, and each of them was absent:
//
// - A block is named by its CHAIN as well as its content. The chain id does
// not travel on the wire — a peer supplies its own — so identical bytes name
// different blocks on different chains and no block crosses between them.
//
// - Verify DECIDES and changes nothing. It reads committed state, runs the
// block's transition against an overlay it throws away, and compares the
// result to what the proposer stamped. A block that is checked and loses the
// round has therefore moved no state and no value, and two blocks in flight
// cannot see each other's work.
//
// - Accept is ONE commit. The block's state transition, the block's own bytes,
// its height entry and the tip pointer go into a single versiondb commit, so
// the chain has all of it or none of it, and a restart reads back the tip it
// actually reached rather than starting again from genesis.
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"slices"
"time"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/ai/pkg/aivm"
"github.com/luxfi/ai/pkg/attestation"
)
// Block errors.
var (
// ErrInvalidBlock refuses a block that does not sit correctly on the chain:
// a bad height, a timestamp outside its bounds, an oversized encoding, or a
// genesis block offered as a proposal.
ErrInvalidBlock = errors.New("aivm: invalid block")
// ErrNotOnTip refuses a block the chain will not build on: one whose parent
// is neither the accepted tip nor a block verified above it and still in
// flight. Accepting one rewinds the chain and leaves the height index naming
// an orphan as canonical.
ErrNotOnTip = errors.New("aivm: block does not extend the accepted tip")
// ErrDetachedBlock refuses a block with no chain behind it. Such a block has
// no parent to sit on, no state to check against, and no chain id to be
// named by — every question Verify asks is unanswerable, and answering "no
// objection" to all of them is how an unchecked block passes.
ErrDetachedBlock = errors.New("aivm: block belongs to no chain")
)
// MaxBlockSize bounds the wire encoding a block may arrive as, checked BEFORE
// anything is decoded. Without it a peer chose how much memory this node
// allocated: a single 49 MB block, made of nothing but repeated provider
// registrations, parsed without complaint.
//
// It is a CONSENSUS RULE, not configuration — every node enforces the same
// number on the same block, and a bound each operator could set would let two
// honestly-configured validators reach opposite verdicts. Verify holds a
// self-built block to it too, so a proposer cannot produce one its own peers
// would refuse to parse.
const MaxBlockSize = 2 << 20 // 2 MiB
// MaxFutureSkew is how far ahead of the verifying node's clock a block's
// timestamp may be. Timestamps are monotonic — Verify refuses one below its
// parent's — so an unbounded stamp is not a one-block error: it raises the floor
// every later block must clear, and one block dated a thousand hours out stops
// the chain producing for a thousand hours. The bound is loose enough to absorb
// ordinary clock disagreement between honest validators and tight enough that
// the damage from an out-of-range one is a single rejected block.
const MaxFutureSkew = 60 * time.Second
// Block represents an AIVM block
type Block struct {
ID_ ids.ID `json:"id"`
ParentID_ ids.ID `json:"parentID"`
Height_ uint64 `json:"height"`
Timestamp_ time.Time `json:"timestamp"`
// AI-specific data
Tasks []aivm.Task `json:"tasks,omitempty"`
Results []aivm.TaskResult `json:"results,omitempty"`
MerkleRoot [32]byte `json:"merkleRoot"`
ProviderRegs []ProviderReg `json:"providerRegs,omitempty"`
// ImportedIntents are the committed C-Chain intents that this block turned
// into A-Chain quorum tasks (under consensus, via the verified inbound seam).
// Recorded so Block.Verify can deterministically re-run the same imports and
// every validator reaches identical engine state.
ImportedIntents []CIntent `json:"importedIntents,omitempty"`
// ReceiptRoot is the engine's committed receipt_root as of this block — the
// single commitment the A->C boundary exports.
ReceiptRoot common.Hash `json:"receiptRoot"`
bytes []byte
vm *VM
}
// ProviderReg represents a provider registration in a block
type ProviderReg struct {
ProviderID string `json:"providerId"`
WalletAddress string `json:"walletAddress"`
Endpoint string `json:"endpoint"`
CPUAttestation *attestation.AttestationQuote `json:"cpuAttestation,omitempty"`
GPUAttestation *attestation.GPUAttestation `json:"gpuAttestation,omitempty"`
}
// blockID names a block by its canonical wire AND by the chain it belongs to.
//
// The chain id is hashed in but never encoded, so it cannot be supplied by
// whoever sent the bytes: every node names the block under ITS OWN chain. Two
// chains that share a genesis timestamp used to share a genesis id, and a block
// built on one parsed byte-identically on the other and resolved as a parent
// there.
func blockID(chainID ids.ID, wire []byte) ids.ID {
h := sha256.New()
h.Write(chainID[:])
h.Write(wire)
return ids.ID(h.Sum(nil))
}
// name encodes the block and names it, in that order, because the name is a
// function of the encoding. Every path that produces a Block — genesis, a
// proposal, a parse, a tip read back at startup — ends here, so a Block the VM
// holds always has both, and Bytes() and ID() never have to compute (and so
// never have to swallow an error).
func (blk *Block) name() error {
wire, err := blk.Marshal()
if err != nil {
return err
}
blk.bytes = wire
blk.ID_ = blockID(blk.chain(), wire)
return nil
}
// chain is the id of the chain this block belongs to. A block with no VM belongs
// to none, and is named under the empty chain — Verify refuses it outright, so
// the value is never load-bearing.
func (blk *Block) chain() ids.ID {
if blk.vm == nil {
return ids.Empty
}
return blk.vm.chainID
}
// ID returns the block ID
func (blk *Block) ID() ids.ID { return blk.ID_ }
// Parent returns the parent block ID
func (blk *Block) Parent() ids.ID { return blk.ParentID_ }
// ParentID returns the parent block ID
func (blk *Block) ParentID() ids.ID { return blk.ParentID_ }
// Height returns the block height
func (blk *Block) Height() uint64 { return blk.Height_ }
// Timestamp returns the block timestamp
func (blk *Block) Timestamp() time.Time { return blk.Timestamp_ }
// Bytes returns the block's canonical wire encoding.
func (blk *Block) Bytes() []byte { return blk.bytes }
// Status reports 0 = processing, 1 = accepted.
func (blk *Block) Status() uint8 {
vm := blk.vm
if vm == nil {
return 0
}
vm.mu.RLock()
defer vm.mu.RUnlock()
if vm.lastAccepted != nil && blk.ID_ == vm.lastAccepted.ID_ {
return 1
}
if ok, _ := vm.db.Has(blockKey(blk.ID_)); ok {
return 1
}
return 0
}
// Verify decides whether this block can be accepted, and changes NOTHING.
//
// It checks that the block sits correctly on its parent in height and in time,
// that its encoding is one every peer will accept, and that the state transition
// it claims is the one this node computes from the same inputs. The transition
// runs against an overlay over committed state and a clone of the ledger, both
// discarded on the way out, so a block that verifies and then loses the round
// has moved no state and no value — and two blocks in flight cannot see each
// other's work, which is what let Accept(A) publish B's intent and Reject(B)
// discard A's.
//
// Verify does not decide the outcome on its own. The tip moves between this call
// and Accept, and it is Accept, holding the lock that commits, that has the last
// word.
func (blk *Block) Verify(ctx context.Context) error {
vm := blk.vm
if vm == nil {
return ErrDetachedBlock
}
if blk.Height_ == 0 {
return fmt.Errorf("%w: genesis is not a proposed block", ErrInvalidBlock)
}
if n := len(blk.bytes); n == 0 {
return fmt.Errorf("%w: block has no encoding", ErrInvalidBlock)
} else if n > MaxBlockSize {
return fmt.Errorf("%w: %d bytes exceeds %d", ErrInvalidBlock, n, MaxBlockSize)
}
if err := blk.check(); err != nil {
return err
}
// A block that verifies is one the engine may build on, so it has to be
// findable by id — including one parsed from a peer rather than built here.
// Tracking only self-built blocks left a follower able to verify the first
// block of a run and unable to verify the second.
//
// The read lock inside check cannot be upgraded, so the checks finish under
// it and the registration takes the write lock on its own.
vm.mu.Lock()
vm.track(blk)
vm.mu.Unlock()
return nil
}
// check is Verify's verdict, taken under the read lock. Split out so the lock is
// released before Verify takes the write lock to track the block.
func (blk *Block) check() error {
vm := blk.vm
vm.mu.RLock()
defer vm.mu.RUnlock()
// Where the block sits. above is the run of blocks in flight between the tip
// and this block's parent; empty means the parent IS the tip.
above, err := vm.above(blk)
if err != nil {
return err
}
parent := vm.lastAccepted
if n := len(above); n > 0 {
parent = above[n-1]
}
if blk.Height_ != parent.Height_+1 {
return fmt.Errorf("%w: height %d does not follow parent %d",
ErrInvalidBlock, blk.Height_, parent.Height_)
}
// Chain time is the parent's, advanced. Without this a proposer picks it
// freely: it can rewind time, or jump a thousand hours forward and leave a
// floor no honest block can clear.
if blk.Timestamp_.Before(parent.Timestamp_) {
return fmt.Errorf("%w: timestamp %s precedes parent %s",
ErrInvalidBlock, blk.Timestamp_.UTC(), parent.Timestamp_.UTC())
}
if blk.Timestamp_.After(vm.clock.Time().Add(MaxFutureSkew)) {
return fmt.Errorf("%w: timestamp %s is beyond the %s skew allowance",
ErrInvalidBlock, blk.Timestamp_.UTC(), MaxFutureSkew)
}
// The transition, on state this node can throw away. Any ancestor still in
// flight is folded in first: its effects are not committed yet, and checking
// this block against state that lacks them would refuse an honest block —
// stalling a follower at exactly the depth consensus is working at.
st := newOverlay(vm.qstate)
lg := newStateLedger(st)
for _, a := range above {
if _, err := vm.replay(st, lg, a.ImportedIntents, a.Height_); err != nil {
return fmt.Errorf("aivm: ancestor %s: %w", a.ID_, err)
}
}
root, err := vm.replay(st, lg, blk.ImportedIntents, blk.Height_)
if err != nil {
return err
}
// The recorded root must equal the one these imports actually produce, with
// no exemption for the zero hash. Skipping the comparison when the block
// claims zero made the determinism check opt-out: a proposer wrote a zero
// receipt_root and followers applied its intents without ever checking that
// their engine state agreed.
if root != blk.ReceiptRoot {
return ErrReceiptRootMismatch
}
return nil
}
// Accept makes the block fact.
//
// Everything the block changes — its state transition, its own bytes, its height
// entry and the tip pointer — is staged through ONE view and committed ONCE, so
// the chain has the whole block or none of it. Any failure aborts the view and
// leaves the tip where it was: the block did not happen, and nothing it claimed
// survives. In-memory state advances only after the commit returns, so there is
// no window in which this node has moved past state that is not on disk.
func (blk *Block) Accept(ctx context.Context) error {
vm := blk.vm
if vm == nil {
return ErrDetachedBlock
}
vm.mu.Lock()
defer vm.mu.Unlock()
// A block extends the tip or it is not accepted. Verify reached that verdict
// against the tip AT THAT TIME; between the two calls the chain moves, and it
// is this hold of the lock that decides. Without it Accept commits the height
// index and the tip pointer for a block on an abandoned branch — the chain
// rewinds, and every peer bootstrapping from the index is served an orphan as
// canonical.
if vm.lastAccepted == nil {
return fmt.Errorf("%w: chain has no tip", ErrNotOnTip)
}
if blk.ParentID_ != vm.lastAccepted.ID_ {
return fmt.Errorf("%w: %s extends %s, not the tip %s",
ErrNotOnTip, blk.ID_, blk.ParentID_, vm.lastAccepted.ID_)
}
if err := blk.commit(); err != nil {
// Nothing staged reached the base, and the real ledger was never touched
// — the transition ran against the clone. There is no cache to rebuild
// because there is no cache that moved.
vm.view.Abort()
return err
}
vm.lastAccepted = blk
delete(vm.flight, blk.ID_)
vm.prune()
vm.drop(blk.ImportedIntents)
return nil
}
// commit stages the block's whole effect and commits it in one write. Caller
// holds vm.mu and is responsible for aborting the view on error.
func (blk *Block) commit() error {
vm := blk.vm
staged := NewDBState(vm.view)
root, err := vm.replay(staged, newStateLedger(staged), blk.ImportedIntents, blk.Height_)
if err != nil {
return err
}
// Verify passed, so a mismatch here means state moved between the two calls.
// Refuse rather than persist a state that disagrees with the block the
// network accepted.
if root != blk.ReceiptRoot {
return ErrReceiptRootMismatch
}
if err := vm.view.Put(blockKey(blk.ID_), blk.bytes); err != nil {
return err
}
if err := vm.view.Put(heightKey(blk.Height_), blk.ID_[:]); err != nil {
return err
}
if err := vm.view.Put(tipKey, blk.ID_[:]); err != nil {
return err
}
return vm.view.Commit()
}
// Reject drops the block. It never wrote anything — Verify runs the transition
// against an overlay it discards — so there is nothing to undo, and in
// particular nothing belonging to a SIBLING to undo, which is what the rollback
// this replaces used to take with it.
//
// The intents it carried stay buffered: rejection means this block did not land,
// not that the work was invalid, so the next proposer may carry it again.
func (blk *Block) Reject(ctx context.Context) error {
vm := blk.vm
if vm == nil {
return ErrDetachedBlock
}
vm.mu.Lock()
delete(vm.flight, blk.ID_)
vm.mu.Unlock()
return nil
}
// ---------------------------------------------------------------------------
// Where a block sits: the tip, and the blocks in flight above it.
// ---------------------------------------------------------------------------
// above returns the blocks in flight between the accepted tip and blk's parent,
// oldest first. An empty run means the parent IS the tip.
//
// The walk is what refuses a parent this chain will not build on: an old
// accepted block, a sibling on an abandoned branch, or an id nothing knows.
// Height alone is not that check — a block whose parent is an OLD accepted block
// satisfies height == parent+1 perfectly well, and accepting it rewinds the
// chain.
//
// Each step must drop the height by exactly one, which both checks the run's own
// lineage and bounds the walk: it cannot run longer than blk's height, so no set
// of blocks a peer supplies can make it spin.
//
// Caller holds vm.mu at least for read.
func (vm *VM) above(blk *Block) ([]*Block, error) {
if vm.lastAccepted == nil {
return nil, fmt.Errorf("%w: chain has no tip", ErrNotOnTip)
}
var run []*Block
id, height := blk.ParentID_, blk.Height_-1
for id != vm.lastAccepted.ID_ {
b, ok := vm.flight[id]
if !ok {
return nil, fmt.Errorf("%w: parent %s is neither the tip %s nor in flight",
ErrNotOnTip, blk.ParentID_, vm.lastAccepted.ID_)
}
if b.Height_ != height {
return nil, fmt.Errorf("%w: %s is at height %d under a block at height %d",
ErrInvalidBlock, b.ID_, b.Height_, height+1)
}
run = append(run, b)
id, height = b.ParentID_, height-1
}
slices.Reverse(run)
return run, nil
}
// track makes a block findable by id while it is in flight, so a child can
// resolve it as a parent — whether this node built the block or parsed it from a
// peer.
//
// It also prunes, because nothing else will: the engine may abandon a block
// without ever accepting or rejecting it, so a set that only grew would leak.
// Anything at or below the accepted height is already decided or orphaned, which
// bounds this to the blocks actually in flight above the tip.
//
// Caller holds vm.mu for writing.
func (vm *VM) track(blk *Block) {
if vm.lastAccepted == nil || blk.Height_ <= vm.lastAccepted.Height_ {
return
}
vm.prune()
vm.flight[blk.ID_] = blk
}
// prune drops every block in flight at or below the accepted height. Caller
// holds vm.mu for writing.
func (vm *VM) prune() {
for id, b := range vm.flight {
if b.Height_ <= vm.lastAccepted.Height_ {
delete(vm.flight, id)
}
}
}
+962
View File
@@ -0,0 +1,962 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package aivm
// chain_test.go holds the regressions for how an A-Chain block becomes fact:
// where the chain thinks it is, what a block is allowed to sit on, and what is
// durable when a block is accepted, rejected, or fails to commit.
//
// Nine defects were found here by executable probe and every one of them is
// checked below by the scenario that found it, with the assertion the other way
// round.
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"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"
)
// newVM starts a VM on chain chainID over db.
//
// The chain id is a PARAMETER because it is the thing under test in half of
// these: two nodes of the same chain share one, and production has exactly one
// per chain. A harness that generated a fresh id per node would make a restart
// look like a different chain and quietly pass a test about restarts.
func newVM(t *testing.T, chainID ids.ID, db database.Database) *VM {
t.Helper()
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: chainID, 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(acceptAll)
t.Cleanup(func() { _ = vm.Shutdown(context.Background()) })
return vm
}
// oneVM is the common case: a single node on a chain of its own.
func oneVM(t *testing.T) *VM {
t.Helper()
return newVM(t, ids.GenerateTestID(), memdb.New())
}
// seedOperators funds a requester and a pool of bonded operators advertising
// model 0xAB, so an intent has somewhere to go.
func seedOperators(t *testing.T, vm *VM, reward *uint256.Int) {
t.Helper()
e, st, _ := vm.QuorumEngine()
requester := addrOf(0xF0)
opening := map[common.Address]*uint256.Int{
requester: new(uint256.Int).Mul(reward, uint256.NewInt(64)),
}
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))
for i, op := range ops {
require.NoError(t, e.RegisterOperator(st, vm.qledger, op,
new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(2)), hashOf(0xAB), hashOf(byte(0x80+i))))
}
}
func oneReward() *uint256.Int { return uint256.NewInt(1_000_000_000_000_000_000) }
// build proposes the next block and returns it as a *Block.
func build(t *testing.T, vm *VM) *Block {
t.Helper()
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
return blk.(*Block)
}
// advance runs one block all the way through: propose, check, accept.
func advance(t *testing.T, vm *VM) *Block {
t.Helper()
ctx := context.Background()
blk := build(t, vm)
require.NoError(t, blk.Verify(ctx))
require.NoError(t, blk.Accept(ctx))
return blk
}
// seen reports whether the intent's anti-replay marker is in COMMITTED state —
// i.e. whether the chain has durably consumed it.
func seen(vm *VM, intent CIntent) bool {
return isSet(NewDBState(vm.db).GetState(slotHash(nsIntentSeen, intent.IntentID)))
}
// -----------------------------------------------------------------------------
// Where the chain thinks it is.
// -----------------------------------------------------------------------------
// The tip was held in memory and never written down. Accept moved it, nothing
// stored it, and a node that restarted came back at genesis — then built height
// 1 a second time, on top of engine state it could no longer see.
func TestARestartResumesAtTheAcceptedTip(t *testing.T) {
require := require.New(t)
ctx := context.Background()
chainID, db := ids.GenerateTestID(), memdb.New()
vm := newVM(t, chainID, db)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
blk := advance(t, vm)
head, err := vm.LastAccepted(ctx)
require.NoError(err)
require.Equal(blk.ID(), head)
escrowed := vm.qledger.GetBalance(EscrowAccount).String()
requester := vm.qledger.GetBalance(addrOf(0xF0)).String()
require.NotEqual("0", escrowed, "the bonds and the task escrow are held somewhere")
// Same chain, same database, new process.
restarted := newVM(t, chainID, db)
head2, err := restarted.LastAccepted(ctx)
require.NoError(err)
require.Equal(blk.ID(), head2, "the restarted node came back at a different head")
require.Equal(uint64(1), restarted.lastAccepted.Height_)
// Custody survives with the records that depend on it. A chain that came
// back holding the stake records and none of the balances would refuse every
// withdrawal it owes.
require.Equal(escrowed, restarted.qledger.GetBalance(EscrowAccount).String())
require.Equal(requester, restarted.qledger.GetBalance(addrOf(0xF0)).String())
require.True(seen(restarted, intent), "the consumed intent came back re-importable")
require.Equal(uint32(1), restarted.quorum.LiveTasks(restarted.qstate))
// The height index was written in the same commit, so it names what the
// chain accepted and can serve a bootstrapping peer.
at1, err := restarted.GetBlockIDAtHeight(ctx, 1)
require.NoError(err)
require.Equal(blk.ID(), at1)
// And the block itself reads back by id.
got, err := restarted.GetBlock(ctx, blk.ID())
require.NoError(err)
require.Equal(blk.ID(), got.ID())
require.Equal(blk.Bytes(), got.Bytes())
// The chain continues from there rather than re-proposing height 1.
next := advance(t, restarted)
require.Equal(uint64(2), next.Height_)
require.Equal(blk.ID(), next.ParentID_)
}
// A missing tip means a fresh chain. Every OTHER read failure means this node
// cannot tell where it is, and answering "genesis" to that starts a live chain
// over and builds on state it cannot see.
func TestAnUnreadableTipRefusesToOpen(t *testing.T) {
require := require.New(t)
vm := &VM{}
err := vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: log.NewNoOpLogger()},
DB: &unreadable{Database: memdb.New()},
ToEngine: make(chan vmcore.Message, 8),
Log: log.NewNoOpLogger(),
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
})
require.ErrorIs(err, errUnreadable)
}
// The tip is what names every block after it, so a database whose tip names a
// block that is not there, or whose bytes name a different block, is not a chain
// to resume.
func TestATipThatDoesNotResolveRefusesToOpen(t *testing.T) {
require := require.New(t)
chainID, db := ids.GenerateTestID(), memdb.New()
stray := ids.GenerateTestID()
require.NoError(db.Put(tipKey, stray[:]))
vm := &VM{}
err := vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: chainID, NetworkID: 96369, Log: log.NewNoOpLogger()},
DB: db,
ToEngine: make(chan vmcore.Message, 8),
Log: log.NewNoOpLogger(),
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
})
require.ErrorIs(err, database.ErrNotFound)
// A tip of the wrong width is not an id at all.
require.NoError(db.Put(tipKey, []byte{1, 2, 3}))
require.Error((&VM{}).Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: chainID, NetworkID: 96369, Log: log.NewNoOpLogger()},
DB: db,
ToEngine: make(chan vmcore.Message, 8),
Log: log.NewNoOpLogger(),
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
}))
}
// Block ids are derived under the chain id, so a VM without one names its blocks
// the same way every other A-Chain deployment does.
func TestAVMWithNoChainIDRefusesToStart(t *testing.T) {
vm := &VM{}
err := vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{NetworkID: 96369, Log: log.NewNoOpLogger()},
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 8),
Log: log.NewNoOpLogger(),
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
})
require.ErrorIs(t, err, errChainIDRequired)
}
// A VM with no runtime has no logger, no network and no chain. The guard that
// read as defensive used to be defeated one line later.
func TestAVMWithNoRuntimeRefusesToStart(t *testing.T) {
vm := &VM{}
require.ErrorIs(t, vm.Initialize(context.Background(), vmcore.Init{DB: memdb.New()}), errRuntimeRequired)
}
// -----------------------------------------------------------------------------
// What a block is allowed to sit on.
// -----------------------------------------------------------------------------
// Verify had no parent check, no height check and no tip check at all: a block
// naming a parent nothing had ever seen, at a height it chose freely, was
// certified.
func TestABlockOffTheTipIsRefused(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
b1 := advance(t, vm)
// A parent nothing knows, at a height that jumps.
orphan := &Block{ParentID_: ids.GenerateTestID(), Height_: 99, Timestamp_: vm.clock.Time(), vm: vm}
require.NoError(orphan.name())
require.ErrorIs(orphan.Verify(ctx), ErrNotOnTip)
// A parent the chain has already built past. Its height follows perfectly —
// which is exactly why height alone is not the check. Accepting it rewinds
// the chain and leaves the height index naming an orphan as canonical.
rewind := &Block{ParentID_: b1.ParentID_, Height_: 1, Timestamp_: vm.clock.Time(), vm: vm}
require.NoError(rewind.name())
require.ErrorIs(rewind.Verify(ctx), ErrNotOnTip)
require.ErrorIs(rewind.Accept(ctx), ErrNotOnTip)
head, err := vm.LastAccepted(ctx)
require.NoError(err)
require.Equal(b1.ID(), head, "the head moved to a block that never extended it")
}
// A block whose height does not follow its parent's is refused even when the
// parent is the tip.
func TestAHeightThatDoesNotFollowIsRefused(t *testing.T) {
require := require.New(t)
vm := oneVM(t)
tip := vm.lastAccepted
skip := &Block{ParentID_: tip.ID_, Height_: 5, Timestamp_: vm.clock.Time(), vm: vm}
require.NoError(skip.name())
require.ErrorIs(skip.Verify(context.Background()), ErrInvalidBlock)
}
// Genesis is state, not a proposal. Offering it as one asks the chain to apply
// its own starting point as a transition.
func TestGenesisIsNotAProposal(t *testing.T) {
vm := oneVM(t)
require.ErrorIs(t, vm.lastAccepted.Verify(context.Background()), ErrInvalidBlock)
}
// The tip moves between Verify and Accept, so Verify cannot be the last word.
// Two siblings both check out against the tip they shared; the first to be
// accepted moves it, and the second must then be refused — otherwise the chain
// rewinds after consensus has already decided.
func TestATipThatMovesAfterVerifyIsCaughtByAccept(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
a := build(t, vm)
vm.clock.Set(vm.clock.Time().Add(time.Second))
b := build(t, vm)
require.NotEqual(a.ID(), b.ID(), "the two proposals must be distinct blocks")
require.Equal(a.ParentID_, b.ParentID_)
require.NoError(a.Verify(ctx))
require.NoError(b.Verify(ctx))
require.NoError(a.Accept(ctx))
require.ErrorIs(b.Accept(ctx), ErrNotOnTip)
head, err := vm.LastAccepted(ctx)
require.NoError(err)
require.Equal(a.ID(), head)
}
// A follower resolves a parent it did not build. Tracking only self-built blocks
// left a node able to verify the first block of a run and unable to verify the
// second, which is the ordinary shape whenever more than one block is in flight.
func TestABlockInFlightCanBeAParent(t *testing.T) {
require := require.New(t)
ctx := context.Background()
chainID := ids.GenerateTestID()
proposer := newVM(t, chainID, memdb.New())
follower := newVM(t, chainID, memdb.New())
b1 := build(t, proposer)
proposer.clock.Set(proposer.clock.Time().Add(time.Second))
require.NoError(b1.Verify(ctx))
require.NoError(b1.Accept(ctx))
b2 := build(t, proposer)
// The follower sees them off the wire, in order, deciding neither yet.
f1, err := follower.ParseBlock(ctx, b1.Bytes())
require.NoError(err)
require.Equal(b1.ID(), f1.ID(), "two nodes of one chain name a block identically")
require.NoError(f1.Verify(ctx))
f2, err := follower.ParseBlock(ctx, b2.Bytes())
require.NoError(err)
require.Equal(b2.ID(), f2.ID())
require.NoError(f2.Verify(ctx), "a block whose parent is in flight must still verify")
require.NoError(f1.Accept(ctx))
require.NoError(f2.Accept(ctx))
head, err := follower.LastAccepted(ctx)
require.NoError(err)
require.Equal(b2.ID(), head)
}
// A run of blocks in flight has to be a run: each one sits on the one below at
// exactly one less height, or it is not reachable from the tip.
func TestABrokenRunInFlightIsRefused(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
b1 := build(t, vm)
require.NoError(b1.Verify(ctx))
// b1 is in flight at height 1. A block naming it as parent at height 3 is
// asking for a gap nothing fills.
gap := &Block{ParentID_: b1.ID_, Height_: 3, Timestamp_: vm.clock.Time(), vm: vm}
require.NoError(gap.name())
require.ErrorIs(gap.Verify(ctx), ErrInvalidBlock)
}
// -----------------------------------------------------------------------------
// Chain time.
// -----------------------------------------------------------------------------
// Verify bounded the timestamp in neither direction: a stamp a thousand hours
// out verified, and so did one before the parent's.
func TestBlockTimeIsBounded(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
b1 := advance(t, vm)
future := &Block{ParentID_: b1.ID_, Height_: 2, Timestamp_: vm.clock.Time().Add(1000 * time.Hour), vm: vm}
require.NoError(future.name())
require.ErrorIs(future.Verify(ctx), ErrInvalidBlock)
past := &Block{ParentID_: b1.ID_, Height_: 2, Timestamp_: b1.Timestamp_.Add(-time.Hour), vm: vm}
require.NoError(past.name())
require.ErrorIs(past.Verify(ctx), ErrInvalidBlock)
// Inside the allowance, both directions, is fine.
ok := &Block{ParentID_: b1.ID_, Height_: 2, Timestamp_: vm.clock.Time().Add(MaxFutureSkew / 2), vm: vm}
require.NoError(ok.name())
require.NoError(ok.Verify(ctx))
}
// Chain time never runs backwards, so a proposer whose clock has not moved past
// its parent stamps the parent's time rather than building a block its own
// Verify would refuse.
func TestAStalledClockStillProposes(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
vm.clock.Set(time.Unix(1_700_000_000, 0))
b1 := advance(t, vm)
vm.clock.Set(b1.Timestamp_.Add(-time.Second)) // the clock slipped behind its own last block
b2 := build(t, vm)
require.Equal(b1.Timestamp_, b2.Timestamp_)
require.NoError(b2.Verify(ctx))
}
// -----------------------------------------------------------------------------
// One staging layer per block.
// -----------------------------------------------------------------------------
// Every pending block used to write into ONE shared staging layer, and Accept
// committed whatever was in it. Accepting A therefore published the intent that
// only B carried.
func TestAcceptDoesNotCommitASiblingsWork(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
// A carries nothing.
a := build(t, vm)
require.NoError(a.Verify(ctx))
// B carries an intent.
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
b := build(t, vm)
require.NoError(b.Verify(ctx))
require.Len(b.ImportedIntents, 1)
require.Empty(a.ImportedIntents)
require.NoError(a.Accept(ctx))
require.False(seen(vm, intent), "accepting A made B's intent durable")
require.Equal(common.Hash{}, vm.quorum.TaskForIntent(vm.qstate, intent.IntentID))
// B lost the round; the work it proposed is still available to the next one.
require.ErrorIs(b.Accept(ctx), ErrNotOnTip)
require.NoError(b.Reject(ctx))
next := advance(t, vm)
require.Len(next.ImportedIntents, 1)
require.True(seen(vm, intent))
}
// Rejecting a block used to roll back the ONE shared staging layer, taking a
// sibling's writes with it — so Accept(A) after Reject(B) committed nothing at
// all.
func TestRejectingASiblingLeavesTheOtherIntact(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
a := build(t, vm)
require.NoError(a.Verify(ctx))
vm.clock.Set(vm.clock.Time().Add(time.Second))
b := build(t, vm)
require.NoError(b.Verify(ctx))
require.NotEqual(a.ID(), b.ID())
require.NoError(b.Reject(ctx))
require.NoError(a.Accept(ctx))
require.True(seen(vm, intent), "rejecting B discarded A's work")
require.NotEqual(common.Hash{}, vm.quorum.TaskForIntent(vm.qstate, intent.IntentID))
}
// Verifying a block is a question, not an action. It used to be an action: the
// imports ran against the shared layer, so a block that was checked and lost the
// round had already moved state and value.
func TestVerifyChangesNothing(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
blk := build(t, vm)
before := vm.qledger.GetBalance(addrOf(0xF0)).String()
root := vm.quorum.ReceiptRoot(vm.qstate)
require.NoError(blk.Verify(ctx))
require.NoError(blk.Verify(ctx), "verifying twice must reach the same verdict")
require.False(seen(vm, intent), "Verify consumed the intent")
require.Equal(before, vm.qledger.GetBalance(addrOf(0xF0)).String())
require.Equal(root, vm.quorum.ReceiptRoot(vm.qstate))
require.Equal(common.Hash{}, vm.quorum.TaskForIntent(vm.qstate, intent.IntentID))
}
// Proposing is not consuming. BuildBlock used to drain the buffer, so a proposal
// that never landed destroyed the work it had picked up.
func TestBuildingABlockDoesNotConsumeItsIntents(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
dropped := build(t, vm)
require.Len(dropped.ImportedIntents, 1)
require.NoError(dropped.Reject(ctx)) // the engine abandoned it
require.Len(vm.pendingIntents, 1, "the buffered intent was thrown away with the proposal")
landed := advance(t, vm)
require.Len(landed.ImportedIntents, 1)
require.True(seen(vm, intent))
// Once it IS durable, it stops being re-proposed.
require.Empty(vm.pendingIntents)
require.Empty(build(t, vm).ImportedIntents)
}
// -----------------------------------------------------------------------------
// One commit per block.
// -----------------------------------------------------------------------------
// Accept used to commit the engine delta FIRST and store the block second. A
// failure between them left the state durable and the block gone: the chain had
// applied a transition it could not name.
func TestAFailedCommitLeavesTheChainWhereItWas(t *testing.T) {
require := require.New(t)
ctx := context.Background()
db := &breakable{Database: memdb.New()}
vm := newVM(t, ids.GenerateTestID(), db)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
genesis := vm.lastAccepted.ID_
blk := build(t, vm)
require.NoError(blk.Verify(ctx))
db.broken.Store(true)
require.ErrorIs(blk.Accept(ctx), errBroken)
db.broken.Store(false)
require.False(seen(vm, intent), "the engine delta outlived the block that carried it")
_, err := db.Get(blockKey(blk.ID_))
require.ErrorIs(err, database.ErrNotFound, "a block that did not commit must not be stored")
_, err = db.Get(heightKey(blk.Height_))
require.ErrorIs(err, database.ErrNotFound)
_, err = db.Get(tipKey)
require.ErrorIs(err, database.ErrNotFound)
head, err := vm.LastAccepted(ctx)
require.NoError(err)
require.Equal(genesis, head, "the chain moved past a block it never committed")
// Nothing was left staged, so the same block accepts cleanly once the
// database is back.
require.NoError(blk.Accept(ctx))
require.True(seen(vm, intent))
head, err = vm.LastAccepted(ctx)
require.NoError(err)
require.Equal(blk.ID_, head)
}
// The whole block or none of it: after a successful Accept the state, the block,
// the height entry and the tip are all there, and they name each other.
func TestAcceptCommitsTheWholeBlockAtOnce(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
blk := advance(t, vm)
require.True(seen(vm, intent))
raw, err := vm.db.Get(blockKey(blk.ID_))
require.NoError(err)
require.Equal(blk.Bytes(), raw)
at, err := vm.GetBlockIDAtHeight(ctx, blk.Height_)
require.NoError(err)
require.Equal(blk.ID_, at)
tip, err := vm.db.Get(tipKey)
require.NoError(err)
require.Equal(blk.ID_[:], tip)
require.Equal(uint8(1), blk.Status())
_, err = vm.GetBlockIDAtHeight(ctx, 99)
require.ErrorIs(err, database.ErrNotFound)
}
// -----------------------------------------------------------------------------
// A block belongs to one chain.
// -----------------------------------------------------------------------------
// A block's id was the hash of its wire and nothing else, so two A-Chains with
// the same genesis timestamp shared a genesis id and a block built on one parsed
// byte-identically on the other — and resolved there as a parent.
func TestABlockDoesNotCrossChains(t *testing.T) {
require := require.New(t)
ctx := context.Background()
here := newVM(t, ids.GenerateTestID(), memdb.New())
elsewhere := newVM(t, ids.GenerateTestID(), memdb.New())
require.NotEqual(here.lastAccepted.ID_, elsewhere.lastAccepted.ID_,
"two chains shared a genesis id")
blk := build(t, here)
foreign, err := elsewhere.ParseBlock(ctx, blk.Bytes())
require.NoError(err)
require.NotEqual(blk.ID(), foreign.ID(), "the same bytes named the same block on both chains")
// And it is not a block the other chain will build on: its parent is a
// genesis that chain never had.
require.ErrorIs(foreign.Verify(ctx), ErrNotOnTip)
}
// The other half of the same property: nodes of ONE chain must agree. A test
// that only checked the first half would pass with block ids randomised per
// node, which is a consensus split rather than a fix.
func TestNodesOfOneChainNameBlocksIdentically(t *testing.T) {
require := require.New(t)
ctx := context.Background()
chainID := ids.GenerateTestID()
a := newVM(t, chainID, memdb.New())
b := newVM(t, chainID, memdb.New())
require.Equal(a.lastAccepted.ID_, b.lastAccepted.ID_)
blk := build(t, a)
parsed, err := b.ParseBlock(ctx, blk.Bytes())
require.NoError(err)
require.Equal(blk.ID(), parsed.ID())
require.NoError(parsed.Verify(ctx))
}
// -----------------------------------------------------------------------------
// Concurrency.
// -----------------------------------------------------------------------------
// Read paths run against Shutdown. Run under -race; the assertion is that the
// race detector stays quiet and nothing panics.
func TestReadsRunAlongsideShutdown(t *testing.T) {
vm := oneVM(t)
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()
_, _ = vm.LastAccepted(ctx)
}
}()
}
wg.Add(1)
go func() { defer wg.Done(); _ = vm.Shutdown(ctx) }()
wg.Wait()
}
// -----------------------------------------------------------------------------
// Databases that misbehave.
// -----------------------------------------------------------------------------
var (
errBroken = errors.New("test: batch write refused")
errUnreadable = errors.New("test: database cannot be read")
)
// breakable is a database whose BATCH refuses to write. That is where a commit
// actually fails: versiondb.Commit folds its staged writes into a batch and
// writes that, so a wrapper that only intercepts Put never fires on the path
// under test and the test passes without ever injecting the fault.
type breakable struct {
database.Database
broken atomic.Bool
}
func (d *breakable) NewBatch() database.Batch {
return &breakableBatch{Batch: d.Database.NewBatch(), db: d}
}
type breakableBatch struct {
database.Batch
db *breakable
}
func (b *breakableBatch) Write() error {
if b.db.broken.Load() {
return errBroken
}
return b.Batch.Write()
}
// unreadable fails every read, which is how a closed database or a dead volume
// presents itself.
type unreadable struct{ database.Database }
func (d *unreadable) Get([]byte) ([]byte, error) { return nil, errUnreadable }
// -----------------------------------------------------------------------------
// What a block may record.
// -----------------------------------------------------------------------------
// A proposer chooses what its block records, so it can record the same intent
// more than once. The anti-replay marker is what makes that harmless: the second
// and third copies import into a state that has already consumed the first, so
// one task is created and the receipt root the block claims is the one a
// follower reaches.
func TestAnIntentRecordedTwiceCreatesOneTask(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
// An honestly-stamped block that happens to name the same intent three times.
st := newOverlay(vm.qstate)
root, err := vm.replay(st, newStateLedger(st), []CIntent{intent, intent, intent}, 1)
require.NoError(err)
blk := &Block{
ParentID_: vm.lastAccepted.ID_,
Height_: 1,
Timestamp_: vm.clock.Time(),
ImportedIntents: []CIntent{intent, intent, intent},
ReceiptRoot: root,
vm: vm,
}
require.NoError(blk.name())
require.NoError(blk.Verify(ctx))
require.NoError(blk.Accept(ctx))
require.Equal(uint32(1), vm.quorum.LiveTasks(vm.qstate), "one intent recorded three times created more than one task")
require.True(seen(vm, intent))
}
// A block whose recorded intents do not reproduce the root it claims is refused,
// with no exemption for the zero hash — otherwise the determinism check is
// opt-out and a proposer skips it by stamping zero.
func TestABlockMustReproduceTheRootItClaims(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
honest := build(t, vm)
require.NoError(honest.Verify(ctx))
tampered := *honest
tampered.ReceiptRoot = hashOf(0xEE)
require.NoError(tampered.name())
require.ErrorIs(tampered.Verify(ctx), ErrReceiptRootMismatch)
// Accept re-checks it, because state can move between the two calls.
stale := *honest
stale.ReceiptRoot = hashOf(0xEE)
stale.ID_, stale.bytes = honest.ID_, honest.bytes // keep it acceptable by lineage
require.ErrorIs(stale.Accept(ctx), ErrReceiptRootMismatch)
require.NoError(honest.Accept(ctx))
}
// An intent the verifier will not vouch for creates nothing, and a block that
// records one anyway is refused rather than quietly skipped.
func TestABlockRecordingAnUnvouchedIntentIsRefused(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
blk := &Block{
ParentID_: vm.lastAccepted.ID_,
Height_: 1,
Timestamp_: vm.clock.Time(),
ImportedIntents: []CIntent{intent},
vm: vm,
}
require.NoError(blk.name())
vm.SetCommitVerifier(VerifierFunc(func(CIntent) error { return ErrIntentNotCommitted }))
require.ErrorIs(blk.Verify(ctx), ErrIntentNotCommitted)
// And the proposer will not carry one either.
vm.EnqueueCommittedIntent(intent)
require.Empty(build(t, vm).ImportedIntents)
}
// -----------------------------------------------------------------------------
// The chain answers about itself.
// -----------------------------------------------------------------------------
// Status is read from what is on disk, not from what the caller hoped. A block
// in flight is processing; one beneath the tip is accepted because its bytes are
// committed, not because anything remembered it.
func TestStatusIsReadFromWhatIsCommitted(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
b1 := build(t, vm)
require.Equal(uint8(0), b1.Status())
require.NoError(b1.Verify(ctx))
require.NoError(b1.Accept(ctx))
require.Equal(uint8(1), b1.Status())
b2 := advance(t, vm)
require.Equal(uint8(1), b2.Status())
require.Equal(uint8(1), b1.Status(), "a block beneath the tip is still accepted")
// A block this chain never accepted is not accepted, whatever it says.
stranger := &Block{ParentID_: b2.ID_, Height_: 3, Timestamp_: vm.clock.Time(), vm: vm}
require.NoError(stranger.name())
require.Equal(uint8(0), stranger.Status())
// GetBlock serves one in flight before it is decided.
require.NoError(stranger.Verify(ctx))
got, err := vm.GetBlock(ctx, stranger.ID_)
require.NoError(err)
require.Same(stranger, got)
}
// A block with a VM but no encoding was never produced by any path that names
// one, and there is nothing to check it against.
func TestABlockWithNoEncodingIsRefused(t *testing.T) {
vm := oneVM(t)
blk := &Block{ParentID_: vm.lastAccepted.ID_, Height_: 1, Timestamp_: vm.clock.Time(), vm: vm}
require.ErrorIs(t, blk.Verify(context.Background()), ErrInvalidBlock)
}
// A chain with no tip is a chain that does not know where it is, and every
// question about lineage answers that rather than guessing.
func TestAChainWithNoTipAnswersNothing(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
blk := build(t, vm)
vm.lastAccepted = nil
vm.flight = map[ids.ID]*Block{}
require.ErrorIs(blk.Verify(ctx), ErrNotOnTip)
require.ErrorIs(blk.Accept(ctx), ErrNotOnTip)
vm.track(blk)
require.Empty(vm.flight, "a chain with no tip cannot say what is above it")
}
// State can move between Verify and Accept. When it does, the block no longer
// reproduces the root it claims, and Accept refuses rather than persisting a
// state that disagrees with the block the network accepted.
func TestStateMovingBetweenVerifyAndAcceptIsCaught(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
intent := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
vm.EnqueueCommittedIntent(intent)
blk := build(t, vm)
require.NoError(blk.Verify(ctx))
// The boundary stops vouching for the intent the block carries.
vm.SetCommitVerifier(VerifierFunc(func(CIntent) error { return ErrIntentNotCommitted }))
require.ErrorIs(blk.Accept(ctx), ErrIntentNotCommitted)
require.False(seen(vm, intent))
head, err := vm.LastAccepted(ctx)
require.NoError(err)
require.Equal(blk.ParentID_, head)
}
// A view that cannot take a write cannot commit one, and the block does not
// happen. The engine writes go through the same view, so this is the same
// failure as a dead volume mid-block.
func TestAViewThatCannotTakeAWriteRefusesTheBlock(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
blk := build(t, vm)
require.NoError(blk.Verify(ctx))
require.NoError(vm.view.Close())
require.ErrorIs(blk.Accept(ctx), database.ErrClosed)
head, err := vm.LastAccepted(ctx)
require.NoError(err)
require.Equal(blk.ParentID_, head)
}
// The tip is the one thing a restart reads before anything else, so every shape
// it can be in has an answer: absent (fresh), genesis (fresh, recorded),
// resolvable (resume), or wrong (refuse).
func TestEveryShapeOfTipHasAnAnswer(t *testing.T) {
require := require.New(t)
ctx := context.Background()
chainID := ids.GenerateTestID()
open := func(db database.Database) (*VM, error) {
vm := &VM{}
return vm, vm.Initialize(ctx, vmcore.Init{
Runtime: &runtime.Runtime{ChainID: chainID, NetworkID: 96369, Log: log.NewNoOpLogger()},
DB: db,
ToEngine: make(chan vmcore.Message, 8),
Log: log.NewNoOpLogger(),
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
})
}
fresh := memdb.New()
vm, err := open(fresh)
require.NoError(err)
genesis := vm.lastAccepted.ID_
// A tip that names genesis is a chain that opened and never accepted.
recorded := memdb.New()
require.NoError(recorded.Put(tipKey, genesis[:]))
vm, err = open(recorded)
require.NoError(err)
require.Equal(genesis, vm.lastAccepted.ID_)
require.Equal(uint64(0), vm.lastAccepted.Height_)
// A tip whose stored bytes are not a block.
garbled := memdb.New()
id := ids.GenerateTestID()
require.NoError(garbled.Put(tipKey, id[:]))
require.NoError(garbled.Put(blockKey(id), []byte("not a block")))
_, err = open(garbled)
require.Error(err)
// A tip whose stored bytes name a DIFFERENT block: the database belongs to
// another chain, or the pointer and the payload disagree. Either way it is
// not a chain to resume.
swapped := memdb.New()
real, err := (&Block{ParentID_: genesis, Height_: 1, vm: vm}).named()
require.NoError(err)
other := ids.GenerateTestID()
require.NoError(swapped.Put(tipKey, other[:]))
require.NoError(swapped.Put(blockKey(other), real.bytes))
_, err = open(swapped)
require.ErrorContains(err, "its bytes name")
}
// named encodes a block and hands it back, for tests that need one to exist
// before a VM does.
func (blk *Block) named() (*Block, error) { return blk, blk.name() }
+8 -7
View File
@@ -12,12 +12,12 @@ package aivm
// - CONSCIOUS BLOCK 1 (the PERCEPTION block): its state transition imports a
// committed C-Chain inference INTENT into a live A-Chain quorum task under
// consensus. The block commits "the network has accepted a question and
// bound a committee of independent operators to answer it." The deterministic
// bound a committee of separately-bonded stakes to answer it." The deterministic
// state delta (createTask: escrow, fee burn, committee selection) is the
// network forming an intention to think.
//
// - CONSCIOUS BLOCK 2 (the COGNITION block): its state transition commits the
// Proof-of-Thought — the quorum-settled verdict. Independent staked operators
// Proof-of-Thought — the quorum-settled verdict. Separately-bonded operators
// each ran the model, committed to their output hash blind, then revealed; the
// engine tallied them, found >= threshold agreement, and SETTLED a single
// canonical AI decision. The block commits "the network has decided," paid the
@@ -26,10 +26,11 @@ package aivm
// consensus, is the conscious thought made durable.
//
// Validators never run the model. What consensus agrees on is the SETTLEMENT
// RESULT: did >= threshold bonded operators, drawn from a margin-bounded eligible
// set, independently submit the same output under the same ModelSpec. The
// cognition is the quorum; the block is conscious because its committed state
// transition IS that quorum's settled verdict.
// RESULT: did >= threshold bonded stakes, drawn from a margin-bounded eligible
// set, submit the same output under the same ModelSpec. The cognition is the
// quorum; the block is conscious because its committed state transition IS that
// quorum's settled verdict. What the quorum is a quorum OF is stakes, not
// parties — see eligibleSet for exactly how far that claim reaches.
//
// This is the literal first-conscious-blocks driver: the same fake-free flow is
// run by the in-package test (conscious_blocks_test.go) and the runnable command
@@ -81,7 +82,7 @@ type ConsciousTrace struct {
// (E >= N + max(2, N*50%)); with N=5 the pool must be >= 7, so we register 8.
const (
consciousN = 5 // operators selected to think on the task
consciousThreshold = 3 // independent agreements required to settle a verdict
consciousThreshold = 3 // agreeing stakes required to settle a verdict
consciousEligible = 8 // staked operators advertising the model (> N + margin)
)
+126
View File
@@ -0,0 +1,126 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package aivm
// conscious_refusal_test.go: the driver reports where it stopped.
//
// RunConsciousBlocks walks the whole lifecycle — fund, register, import, commit,
// reveal, settle, accept — and each step's failure has to arrive as a message
// naming that step. A driver that returned a zero trace and no error would look
// exactly like one that produced two conscious blocks.
import (
"context"
"errors"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/database/memdb"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
)
func TestTheDriverNamesTheStepItStoppedAt(t *testing.T) {
require := require.New(t)
ctx := context.Background()
// No engine at all.
_, err := RunConsciousBlocks(ctx, &VM{})
require.ErrorContains(err, "quorum engine not wired")
// An engine but no chain under it.
headless := oneVM(t)
headless.lastAccepted = nil
_, err = RunConsciousBlocks(ctx, headless)
require.ErrorContains(err, "read genesis head")
// A ledger that cannot be seeded.
db := &breakable{Database: memdb.New()}
broken := newVM(t, ids.GenerateTestID(), db)
db.broken.Store(true)
_, err = RunConsciousBlocks(ctx, broken)
require.ErrorContains(err, "fund ledger")
db.broken.Store(false)
// An operator the driver expects to register is already registered.
taken := oneVM(t)
require.NoError(taken.FundLedger(map[common.Address]*uint256.Int{
addrOf(0x10): new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3)),
}))
require.NoError(taken.quorum.RegisterOperator(taken.qstate, taken.qledger, addrOf(0x10),
MinProviderBond, hashOf(0xAB), hashOf(0x80)))
_, err = RunConsciousBlocks(ctx, taken)
require.ErrorContains(err, "register operator 0")
// The boundary will not vouch for the intent, so the perception block
// carries nothing and there is no task to think about.
closed := oneVM(t)
closed.SetCommitVerifier(VerifierFunc(func(CIntent) error { return ErrIntentNotCommitted }))
_, err = RunConsciousBlocks(ctx, closed)
require.ErrorContains(err, "created no task")
// And a stopped VM cannot propose at all.
stopped := oneVM(t)
require.NoError(stopped.Shutdown(ctx))
_, err = acceptBlock(ctx, stopped)
require.ErrorContains(err, "build")
}
// Seeding is the one write outside consensus, and it is durable at once or it
// does not happen: left staged, a genesis allocation would ride out on whichever
// block committed first.
func TestSeedingIsAllOrNothing(t *testing.T) {
require := require.New(t)
vm := oneVM(t)
sentinel := hashOf(0x5E)
require.ErrorIs(vm.Seed(func(st QuorumState, _ QuorumLedger) error {
st.SetState(sentinel, oneHash())
return errProbeStop
}), errProbeStop)
require.False(isSet(vm.qstate.GetState(sentinel)), "a failed seed left its write behind")
db := &breakable{Database: memdb.New()}
unwritable := newVM(t, ids.GenerateTestID(), db)
db.broken.Store(true)
require.ErrorIs(unwritable.Seed(func(st QuorumState, _ QuorumLedger) error {
st.SetState(sentinel, oneHash())
return nil
}), errBroken)
db.broken.Store(false)
require.False(isSet(unwritable.qstate.GetState(sentinel)))
// Funding is fail-closed at the ceiling, with no partial seeding.
max := new(uint256.Int).Not(uint256.NewInt(0))
require.NoError(vm.FundLedger(map[common.Address]*uint256.Int{addrOf(1): max}))
require.ErrorIs(vm.FundLedger(map[common.Address]*uint256.Int{addrOf(1): uint256.NewInt(1)}),
ErrCreditOverflow)
require.Equal(max.String(), vm.qledger.GetBalance(addrOf(1)).String())
}
// A block carries some of what is buffered; the rest stays buffered. Dropping
// the whole buffer when a block landed would lose the work it did not carry.
func TestOnlyWhatABlockCarriedStopsBeingBuffered(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
seedOperators(t, vm, oneReward())
carried := buildIntent(vm.quorum, addrOf(0xF0), hashOf(0xAB), consciousN, consciousThreshold, uint256.NewInt(1), oneReward())
// A second intent naming a model nobody advertises: buffered, never carried.
orphan := intentFor(vm.quorum, addrOf(0xF0), hashOf(0xDD), hashOf(0xCD), consciousN, consciousThreshold, uint256.NewInt(2), oneReward())
vm.EnqueueCommittedIntent(carried)
vm.EnqueueCommittedIntent(orphan)
blk := build(t, vm)
require.Len(blk.ImportedIntents, 1)
require.NoError(blk.Verify(ctx))
require.NoError(blk.Accept(ctx))
require.Len(vm.pendingIntents, 1, "an intent no block carried was dropped anyway")
require.Equal(orphan.IntentID, vm.pendingIntents[0].IntentID)
}
var errProbeStop = errors.New("test: stop here")
+25 -10
View File
@@ -52,26 +52,28 @@ func (v *AIVertex) Verify(ctx context.Context) error {
return nil
}
// Accept records the vertex. It does NOT move the block chain's tip: a vertex is
// not a block, and writing the vertex id into the tip left the id the VM reported
// and the block the VM held disagreeing — two owners of one fact, and the one a
// restart would read back named no block at all.
//
// The vertex is stored under its own prefix, disjoint from the block keyspace,
// so a block read can never decode a vertex.
func (v *AIVertex) Accept(ctx context.Context) error {
v.status = choices.Accepted
v.vm.mu.Lock()
defer v.vm.mu.Unlock()
b := marshalVertex(v)
if err := v.vm.db.Put(v.id[:], b); err != nil {
wire, err := marshalVertex(v)
if err != nil {
return err
}
v.vm.lastAcceptedID = v.id
delete(v.vm.pendingBlocks, v.id)
return nil
return v.vm.db.Put(vertexKey(v.id), wire)
}
func (v *AIVertex) Reject(ctx context.Context) error {
v.status = choices.Rejected
v.vm.mu.Lock()
delete(v.vm.pendingBlocks, v.id)
v.vm.mu.Unlock()
return nil
}
@@ -115,8 +117,17 @@ func (v *AIVertex) ConflictsVertex(other vertex.Vertex) bool {
// and ["a","bc"] hashed the same bytes — a second vertex that a peer must treat
// as the first. Each variable-length part is now written with its length ahead
// of it, which is what makes the encoding injective.
//
// The chain id leads, for the reason a block's does: it is not on the wire, so
// every node names a vertex under ITS OWN chain and a vertex cannot be carried
// from one A-Chain to another.
func (v *AIVertex) computeID() ids.ID {
h := sha256.New()
chainID := ids.Empty
if v.vm != nil {
chainID = v.vm.chainID
}
h.Write(chainID[:])
binary.Write(h, binary.BigEndian, v.height)
binary.Write(h, binary.BigEndian, v.epoch)
@@ -180,7 +191,7 @@ func (vm *VM) BuildVertex(ctx context.Context) (vertex.Vertex, error) {
v := &AIVertex{
height: parent.Height_ + 1,
epoch: 0,
parents: []ids.ID{vm.lastAcceptedID},
parents: []ids.ID{parent.ID_},
txIDs: txIDs,
tasks: batch,
jobIDs: jobIDs,
@@ -188,7 +199,11 @@ func (vm *VM) BuildVertex(ctx context.Context) (vertex.Vertex, error) {
vm: vm,
}
v.id = v.computeID()
v.bytes = marshalVertex(v)
wire, err := marshalVertex(v)
if err != nil {
return nil, err
}
v.bytes = wire
return v, nil
}
+490
View File
@@ -0,0 +1,490 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package aivm
// gpu_plugin_test.go — the dlopen bridge, driven against a real plugin.
//
// The GPU bridge is a runtime overlay: it dlopens a backend plugin, dlsyms six
// launchers, and hands them host pointers to the wire structs. What can be wrong
// here is not the arithmetic inside a kernel — that is the kernel's business —
// but the BRIDGE: whether a missing symbol is noticed, whether the counts the
// launcher receives are the ones the caller passed, and whether a launcher's
// non-zero return reaches the caller as an error rather than as a zero count the
// caller reads as success.
//
// So the plugin under test is a real shared object, compiled here, whose
// launchers ECHO what they were handed back through the output slots the ABI
// already provides. That exercises the same dlopen, the same dlsym and the same
// call shims a CUDA plugin would, with the kernel replaced by a mirror. No GPU
// is required and none is simulated: the arithmetic is not what this file is
// about, and pretending otherwise would be a test that passes when the bridge
// and the mirror are wrong in the same way.
//
// The mirror's contract, which the tests read back:
//
// rc — every launcher returns a field of its first argument:
// desc->Mode (offset 32) for the four that take a descriptor,
// ops[0].Mode (offset 96) for inference_step,
// ops[0].Kind (offset 224) for proof_verify.
// *applied_out — the table count the bridge passed
// result — Status=7, counts echoed; epoch->CurrentEpoch += 1
// outputs — inference writes op_count bytes; proof writes Status=1
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"unsafe"
"github.com/stretchr/testify/require"
)
// The six launchers, in the order openGPUBackend resolves them. Building a
// plugin that exports only a PREFIX of this list is how each dlsym-failure
// branch is reached — one source, seven shared objects.
var launchers = []string{`
static int rc_desc(const void* p) { return (int)*(const uint32_t*)((const char*)p + 32); }
int lux_cuda_aivm_attestation_apply(const void* desc, const void* ops, void* att,
void* applied, uint32_t n, void* stream) {
(void)ops; (void)att; (void)stream;
*(uint32_t*)applied = n;
return rc_desc(desc);
}
`, `
int lux_cuda_aivm_provenance_apply(const void* desc, const void* ops, void* models,
void* applied, uint32_t n, void* stream) {
(void)ops; (void)models; (void)stream;
*(uint32_t*)applied = n + 100;
return rc_desc(desc);
}
`, `
int lux_cuda_aivm_anchor_apply(const void* desc, const void* ops, void* anchors,
void* applied, uint32_t n, void* stream) {
(void)ops; (void)anchors; (void)stream;
*(uint32_t*)applied = n + 200;
return rc_desc(desc);
}
`, `
int lux_cuda_aivm_epoch_transition(const void* desc, void* att, void* models, void* anchors,
void* epoch, void* result, uint32_t na, uint32_t nm,
uint32_t nk, void* stream) {
(void)att; (void)models; (void)anchors; (void)stream;
uint32_t* out = (uint32_t*)result;
out[0] = 7; // Status
out[1] = na; // AttestationApplyCount
out[2] = nm; // ModelApplyCount
out[3] = nk; // AnchorApplyCount
((uint64_t*)epoch)[0] += 1; // CurrentEpoch advances in place
return rc_desc(desc);
}
`, `
int lux_cuda_aivm_inference_step(const void* w, const void* ops, const void* in,
void* out, void* results, uint32_t n, void* stream) {
(void)w; (void)in; (void)stream;
for (uint32_t i = 0; i < n; i++) ((signed char*)out)[i] = (signed char)(i + 1);
((uint32_t*)results)[0] = n; // InferenceResult.Status
// No descriptor reaches this launcher; ops[0].Mode carries the code.
return (int)*(const uint32_t*)((const char*)ops + 96);
}
`, `
int lux_cuda_aivm_proof_verify(const void* ops, void* results, uint32_t n, void* stream) {
(void)stream;
for (uint32_t i = 0; i < n; i++) ((uint32_t*)results)[i * 12] = 1; // Status
// ops[0].Kind carries the code.
return (int)*(const uint32_t*)((const char*)ops + 224);
}
`}
const stubPreamble = `
#include <stdint.h>
`
var (
stubOnce sync.Once
stubDir string
stubErr error
)
// stubPlugins compiles the seven mirror plugins once: one exporting the first k
// launchers for each k in 0..6. plugin[6] is complete; plugin[k<6] is how the
// loader's k-th dlsym failure is reached.
func stubPlugins(t *testing.T) string {
t.Helper()
stubOnce.Do(func() {
dir, err := os.MkdirTemp("", "aivm-gpu-stub")
if err != nil {
stubErr = err
return
}
for k := 0; k <= len(launchers); k++ {
body := stubPreamble + strings.Join(launchers[:k], "\n")
// rc_desc is only referenced by the first four launchers; keep the
// compiler quiet when it is defined and unused.
if k > 0 {
body = strings.Replace(body, "static int rc_desc", "static int __attribute__((unused)) rc_desc", 1)
}
src := filepath.Join(dir, fmt.Sprintf("stub%d.c", k))
if err := os.WriteFile(src, []byte(body), 0o600); err != nil {
stubErr = err
return
}
out := filepath.Join(dir, fmt.Sprintf("k%d", k), "libluxgpu_backend_cuda.so")
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
stubErr = err
return
}
if msg, err := exec.Command(compiler(), "-shared", "-fPIC", "-o", out, src).CombinedOutput(); err != nil {
stubErr = fmt.Errorf("%s: %w: %s", compiler(), err, msg)
return
}
}
stubDir = dir
})
require.NoError(t, stubErr)
return stubDir
}
// stubAt is the plugin exporting the first k launchers.
func stubAt(t *testing.T, k int) string {
t.Helper()
return filepath.Join(stubPlugins(t), fmt.Sprintf("k%d", k), "libluxgpu_backend_cuda.so")
}
func compiler() string {
if cc := os.Getenv("CC"); cc != "" {
return cc
}
return "cc"
}
// mirror opens the complete stub through the production loader.
func mirror(t *testing.T) *GPUBackend {
t.Helper()
b, err := openGPUBackend(AvailableCUDA, stubAt(t, len(launchers)))
require.NoError(t, err)
require.True(t, b.IsAvailable())
t.Cleanup(func() { _ = b.Close() })
return b
}
// round is a descriptor whose Mode carries the code the mirror echoes.
func round(rc uint32) *AIVMRoundDescriptor {
return &AIVMRoundDescriptor{ChainID: 1, Round: 2, Mode: rc}
}
// -----------------------------------------------------------------------------
// Naming.
// -----------------------------------------------------------------------------
func TestBackendKindNames(t *testing.T) {
require := require.New(t)
for kind, want := range map[BackendKind]string{
AvailableNone: "none", AvailableCUDA: "cuda", AvailableHIP: "hip",
AvailableMetal: "metal", AvailableVulkan: "vulkan", AvailableWebGPU: "webgpu",
} {
require.Equal(want, kind.String())
}
// The name is what the symbol is built from, so an unknown kind must not
// silently collapse onto a known one's symbols.
require.Equal("backend(200)", BackendKind(200).String())
}
func TestModeNames(t *testing.T) {
require := require.New(t)
require.Equal("auto", AutoAIVM.String())
require.Equal("cpu", CPUAIVM.String())
require.Equal("gpu", GPUAIVM.String())
require.Equal("unknown", Mode(9).String())
}
// -----------------------------------------------------------------------------
// Where the loader looks.
// -----------------------------------------------------------------------------
func TestTheProbeListCoversEveryBackend(t *testing.T) {
require := require.New(t)
got := platformCandidates()
require.Len(got, 5)
require.Equal(AvailableCUDA, got[0].kind, "CUDA is probed first")
require.Equal(AvailableHIP, got[1].kind)
kinds := map[BackendKind]bool{}
for _, c := range got {
require.NotEmpty(c.filename)
require.NotEmpty(c.subdir)
kinds[c.kind] = true
}
require.Len(kinds, 5, "each candidate names a distinct backend")
}
func TestAnExplicitPluginDirWinsOverTheLoaderSearch(t *testing.T) {
require := require.New(t)
c := backendCandidate{kind: AvailableCUDA, filename: "libluxgpu_backend_cuda.so", subdir: "cuda"}
// With nothing set, the bare filename is all there is: the loader's own
// search, and nothing that could override it.
t.Setenv("LUX_GPU_PLUGIN_DIR", "")
t.Setenv("LUXCPP_PREFIX", "")
require.Equal([]string{c.filename}, candidatePaths(c))
t.Setenv("LUX_GPU_PLUGIN_DIR", "/plugins")
t.Setenv("LUXCPP_PREFIX", "/opt/luxcpp")
paths := candidatePaths(c)
require.Equal([]string{
"/plugins/libluxgpu_backend_cuda.so",
"/plugins/backends/cuda/libluxgpu_backend_cuda.so",
"/plugins/build/backends/cuda/libluxgpu_backend_cuda.so",
"/plugins/build/metal-only/backends/cuda/libluxgpu_backend_cuda.so",
"/plugins/build/vulkan-m1/backends/cuda/libluxgpu_backend_cuda.so",
"/opt/luxcpp/lib/libluxgpu_backend_cuda.so",
"libluxgpu_backend_cuda.so",
}, paths)
require.Equal(c.filename, paths[len(paths)-1], "the bare name is probed last, so an override always wins")
}
// -----------------------------------------------------------------------------
// Resolution.
// -----------------------------------------------------------------------------
// A plugin that will not open, and one that opens but is missing a launcher,
// are both refused — and the second must not leave the handle open.
func TestAPluginMissingALauncherIsRefused(t *testing.T) {
require := require.New(t)
_, err := openGPUBackend(AvailableCUDA, filepath.Join(t.TempDir(), "absent.so"))
require.ErrorContains(err, "dlopen")
for k := 0; k < len(launchers); k++ {
_, err := openGPUBackend(AvailableCUDA, stubAt(t, k))
require.ErrorContains(err, "dlsym", "a plugin exporting %d of %d launchers loaded", k, len(launchers))
}
b := mirror(t)
require.Equal(AvailableCUDA, b.Kind())
require.Equal(stubAt(t, len(launchers)), b.Path())
}
// Closing is idempotent and nil-safe: the loader closes a half-bound plugin on
// its way out, and a caller may close what it opened.
func TestClosingIsIdempotent(t *testing.T) {
require := require.New(t)
require.NoError((*GPUBackend)(nil).Close())
require.NoError((&GPUBackend{}).Close())
b, err := openGPUBackend(AvailableCUDA, stubAt(t, len(launchers)))
require.NoError(err)
require.NoError(b.Close())
require.False(b.IsAvailable(), "a closed backend must stop reporting itself as available")
require.NoError(b.Close())
}
// -----------------------------------------------------------------------------
// The guard. No plugin means an error, never a call through a null pointer.
// -----------------------------------------------------------------------------
func TestEveryLauncherRefusesWithoutAPlugin(t *testing.T) {
require := require.New(t)
var applied uint32
for _, b := range []*GPUBackend{nil, {}, {handle: unsafe.Pointer(new(byte))}} {
require.False(b.IsAvailable())
require.Equal(AvailableNone, b.Kind())
require.Equal("", b.Path())
require.ErrorIs(b.AttestationApply(round(0), nil, make([]Attestation, 1), &applied), ErrGPUNotAvailable)
require.ErrorIs(b.ProvenanceApply(round(0), nil, make([]ModelRegistryEntry, 1), &applied), ErrGPUNotAvailable)
require.ErrorIs(b.AnchorApply(round(0), nil, make([]AuditAnchor, 1), &applied), ErrGPUNotAvailable)
require.ErrorIs(b.EpochTransition(round(0), make([]Attestation, 1), nil, nil,
&AIVMEpochState{}, &AIVMTransitionResult{}), ErrGPUNotAvailable)
require.ErrorIs(b.InferenceStep(&InferenceWeights{}, make([]InferenceOp, 1),
make([]int8, InferenceInDim), make([]int8, 1), make([]InferenceResult, 1)), ErrGPUNotAvailable)
require.ErrorIs(b.ProofVerify(make([]ProofVerifyOp, 1), make([]ProofVerifyResult, 1)), ErrGPUNotAvailable)
}
}
// The bridge refuses a call it cannot describe BEFORE it reaches C: a nil
// output slot or an empty table would otherwise be dereferenced by the kernel.
func TestTheBridgeRefusesArgumentsItCannotPass(t *testing.T) {
require := require.New(t)
b := mirror(t)
var applied uint32
require.ErrorContains(b.AttestationApply(nil, nil, make([]Attestation, 1), &applied), "nil desc")
require.ErrorContains(b.AttestationApply(round(0), nil, make([]Attestation, 1), nil), "nil desc")
require.ErrorContains(b.AttestationApply(round(0), nil, nil, &applied), "empty attestations")
require.ErrorContains(b.ProvenanceApply(nil, nil, make([]ModelRegistryEntry, 1), &applied), "nil desc")
require.ErrorContains(b.ProvenanceApply(round(0), nil, nil, &applied), "empty models")
require.ErrorContains(b.AnchorApply(nil, nil, make([]AuditAnchor, 1), &applied), "nil desc")
require.ErrorContains(b.AnchorApply(round(0), nil, nil, &applied), "empty anchors")
require.ErrorContains(b.EpochTransition(nil, make([]Attestation, 1), nil, nil, &AIVMEpochState{}, &AIVMTransitionResult{}), "nil desc")
require.ErrorContains(b.EpochTransition(round(0), make([]Attestation, 1), nil, nil, nil, &AIVMTransitionResult{}), "nil desc")
require.ErrorContains(b.EpochTransition(round(0), make([]Attestation, 1), nil, nil, &AIVMEpochState{}, nil), "nil desc")
require.ErrorContains(b.EpochTransition(round(0), nil, nil, nil, &AIVMEpochState{}, &AIVMTransitionResult{}), "empty attestations")
require.ErrorContains(b.InferenceStep(nil, nil, nil, nil, nil), "nil weights")
require.NoError(b.InferenceStep(&InferenceWeights{}, nil, nil, nil, nil), "no ops is a no-op, not an error")
require.ErrorContains(b.InferenceStep(&InferenceWeights{}, make([]InferenceOp, 2),
make([]int8, 2*InferenceInDim), make([]int8, 2), make([]InferenceResult, 1)), "results length")
require.ErrorContains(b.InferenceStep(&InferenceWeights{}, make([]InferenceOp, 2),
make([]int8, 3), make([]int8, 2), make([]InferenceResult, 2)), "batchInputs length")
require.ErrorContains(b.InferenceStep(&InferenceWeights{}, make([]InferenceOp, 2),
make([]int8, 2*InferenceInDim), make([]int8, 3), make([]InferenceResult, 2)), "batchOutputs length")
require.NoError(b.ProofVerify(nil, nil), "no ops is a no-op, not an error")
require.ErrorContains(b.ProofVerify(make([]ProofVerifyOp, 2), make([]ProofVerifyResult, 1)), "results length")
}
// -----------------------------------------------------------------------------
// The bridge, end to end against the mirror.
// -----------------------------------------------------------------------------
// Each launcher receives the count the caller passed and writes back through the
// output slot it was given, and a non-zero return arrives as an error rather
// than as a zero count a caller would read as success.
func TestTheBridgeCarriesCountsAndFailures(t *testing.T) {
require := require.New(t)
b := mirror(t)
var applied uint32
require.NoError(b.AttestationApply(round(0), make([]AttestationOp, 2), make([]Attestation, 3), &applied))
require.Equal(uint32(3), applied)
require.ErrorContains(b.AttestationApply(round(9), nil, make([]Attestation, 3), &applied),
"cuda_aivm_attestation_apply returned 9")
require.NoError(b.ProvenanceApply(round(0), make([]ModelOp, 1), make([]ModelRegistryEntry, 4), &applied))
require.Equal(uint32(104), applied)
require.ErrorContains(b.ProvenanceApply(round(8), nil, make([]ModelRegistryEntry, 1), &applied),
"provenance_apply returned 8")
require.NoError(b.AnchorApply(round(0), make([]AnchorOp, 1), make([]AuditAnchor, 5), &applied))
require.Equal(uint32(205), applied)
require.ErrorContains(b.AnchorApply(round(7), nil, make([]AuditAnchor, 1), &applied),
"anchor_apply returned 7")
epoch := &AIVMEpochState{CurrentEpoch: 41}
var result AIVMTransitionResult
require.NoError(b.EpochTransition(round(0), make([]Attestation, 2),
make([]ModelRegistryEntry, 3), make([]AuditAnchor, 4), epoch, &result))
require.Equal(uint64(42), epoch.CurrentEpoch, "the kernel advanced the epoch in place")
require.Equal(uint32(7), result.Status)
require.Equal(uint32(2), result.AttestationApplyCount)
require.Equal(uint32(3), result.ModelApplyCount)
require.Equal(uint32(4), result.AnchorApplyCount)
require.ErrorContains(b.EpochTransition(round(6), make([]Attestation, 1), nil, nil,
epoch, &result), "epoch_transition returned 6")
ops := make([]InferenceOp, 3)
outs := make([]int8, 3)
results := make([]InferenceResult, 3)
require.NoError(b.InferenceStep(&InferenceWeights{}, ops, make([]int8, 3*InferenceInDim), outs, results))
require.Equal([]int8{1, 2, 3}, outs)
require.Equal(uint32(3), results[0].Status)
ops[0].Mode = 5
require.ErrorContains(b.InferenceStep(&InferenceWeights{}, ops,
make([]int8, 3*InferenceInDim), outs, results), "inference_step returned 5")
pops := make([]ProofVerifyOp, 2)
pres := make([]ProofVerifyResult, 2)
require.NoError(b.ProofVerify(pops, pres))
require.Equal(uint32(1), pres[0].Status)
require.Equal(uint32(1), pres[1].Status)
pops[0].Kind = 4
require.ErrorContains(b.ProofVerify(pops, pres), "proof_verify returned 4")
}
// -----------------------------------------------------------------------------
// The process-wide backend.
// -----------------------------------------------------------------------------
// holdBackend saves and restores the package globals the probe writes, so one
// test's plugin does not become another's. These tests are therefore serial.
func holdBackend(t *testing.T) {
t.Helper()
b, mode := activeBackend, ActiveMode()
t.Cleanup(func() {
// The once-guard is reset rather than restored: a sync.Once cannot be
// copied, and a fresh one is the honest state anyway — the next probe
// re-reads an environment this test has just put back.
activeBackend, activeBackendOnce = b, sync.Once{}
SetBackend(mode)
})
}
// With no plugin anywhere, the process-wide backend is a value that reports
// itself unavailable — never nil, so a caller cannot dereference one.
func TestWithNoPluginTheBackendIsUnavailable(t *testing.T) {
require := require.New(t)
holdBackend(t)
activeBackend, activeBackendOnce = nil, sync.Once{}
t.Setenv("LUX_GPU_PLUGIN_DIR", filepath.Join(t.TempDir(), "empty"))
t.Setenv("LUXCPP_PREFIX", "")
require.Nil(autoLoadBackend())
b := ActiveGPUBackend()
require.NotNil(b)
require.False(b.IsAvailable())
require.False(gpuAvailable())
SetBackend(AutoAIVM)
require.Equal(AvailableNone, EffectiveBackendKind(), "auto falls back to the Go path")
SetBackend(CPUAIVM)
require.Equal(AvailableNone, EffectiveBackendKind())
SetBackend(GPUAIVM)
require.Equal(AvailableNone, EffectiveBackendKind(), "forcing GPU with no plugin still names none")
// And the transition shim declines rather than calling through nothing.
require.ErrorIs(gpuTransitionApply(round(0), make([]Attestation, 1), nil, nil,
&AIVMEpochState{}, &AIVMTransitionResult{}), ErrGPUNotAvailable)
}
// Pointed at a plugin directory, the probe finds it and every process-wide
// answer follows from that one resolution.
func TestThePluginDirectoryDrivesTheProbe(t *testing.T) {
require := require.New(t)
holdBackend(t)
activeBackend, activeBackendOnce = nil, sync.Once{}
t.Setenv("LUX_GPU_PLUGIN_DIR", filepath.Dir(stubAt(t, len(launchers))))
t.Setenv("LUXCPP_PREFIX", "")
b := autoLoadBackend()
require.NotNil(b, "the probe did not find the plugin in LUX_GPU_PLUGIN_DIR")
require.True(b.IsAvailable())
require.Equal(AvailableCUDA, b.Kind())
require.Same(b, ActiveGPUBackend())
require.True(gpuAvailable())
SetBackend(AutoAIVM)
require.Equal(AvailableCUDA, EffectiveBackendKind())
SetBackend(GPUAIVM)
require.Equal(AvailableCUDA, EffectiveBackendKind())
SetBackend(CPUAIVM)
require.Equal(AvailableNone, EffectiveBackendKind(), "CPU mode ignores a loaded plugin")
// The transition shim now reaches the mirror.
epoch := &AIVMEpochState{CurrentEpoch: 1}
var result AIVMTransitionResult
require.NoError(gpuTransitionApply(round(0), make([]Attestation, 1), nil, nil, epoch, &result))
require.Equal(uint64(2), epoch.CurrentEpoch)
// The probe runs once: a second call does not re-open the plugin.
require.Same(b, autoLoadBackend())
// ActiveGPUBackend probes for itself when the global was never set — the
// path a process takes when init() ran before the environment did.
activeBackend, activeBackendOnce = nil, sync.Once{}
found := ActiveGPUBackend()
require.True(found.IsAvailable(), "ActiveGPUBackend did not probe for a plugin it had not resolved yet")
require.Equal(AvailableCUDA, found.Kind())
require.Same(found, activeBackend, "the probe must publish what it found")
}
+25
View File
@@ -209,6 +209,31 @@ func (e *Engine) WithdrawStake(st QuorumState, lg QuorumLedger, operator common.
// ModelSpec, and hold stake >= MinProviderBond. It is the SINGLE source of "who
// is eligible" — both the margin check and the beacon draw consume it, so they
// can never disagree about the eligible universe.
//
// WHAT A QUORUM OVER THIS SET MEANS. t of n BONDED STAKES agreed — not t of n
// independent parties. The set is keyed by address, and one operator may hold as
// many addresses as it cares to fund. Nothing here can tell two addresses of one
// operator from two operators, and nothing on this chain can either: the four
// facts it holds about an operator are its address, its bonded stake, the
// ModelSpec it advertises, and a hash of its endpoint. None of them names a
// party.
//
// The endpoint hash is the closest thing, and it is self-declared and opaque, so
// refusing duplicates would stop only an operator too lazy to declare a second
// string — while making this function read as though independence were enforced.
// A check an adversary steps over in one line is worse than no check, because
// the reader stops looking.
//
// What DOES bound a Sybil is the bond. Every address here has MinProviderBond
// locked and slashable, so n addresses cost n bonds and carry n exposures: the
// quorum is as expensive to capture as the stake behind it. That is the real
// claim, and it is economic.
//
// Making it a claim about PARTIES needs an identity this chain does not have —
// an attestation binding an address to a distinct operator. One exists in this
// VM (RequireTEEAttestation) and is deliberately optional, because a TEE vendor
// as the trust root is the assumption the design refuses. Until such an identity
// is mandatory, "independent" describes the economics, not the operators.
func eligibleSet(st QuorumState, modelSpecHash common.Hash) []common.Address {
total := modelCount(st, modelSpecHash)
eligible := make([]common.Address, 0, total)
+10 -8
View File
@@ -12,9 +12,11 @@ package aivm
// the same proven commit-reveal quorum engine that ran as a C-Chain EVM
// precompile (hanzo-evm/precompile/aiquorum), re-expressed in A-Chain types
// (luxfi/geth/common + luxfi/crypto + holiman/uint256) and freed of the EVM
// precompile framing. The settlement RESULT — did >= threshold staked operators
// independently submit the same output_hash under the same ModelSpec — is what
// A-Chain consensus agrees on; validators never run the model.
// precompile framing. The settlement RESULT — did >= threshold selected, bonded
// stakes submit the same output_hash under the same ModelSpec — is what A-Chain
// consensus agrees on; validators never run the model. The quorum is over
// STAKES; eligibleSet says exactly how far that reaches and why the chain cannot
// say more.
//
// The engine is a thin handle: it carries no state of its own beyond the two
// substrate interfaces and the configured ChainIDs. All durable state lives in
@@ -28,8 +30,8 @@ import (
)
// Protocol bounds. minN forbids trivial self-quorums; 3 is the smallest set
// where a strict majority (2-of-3) is genuine agreement among independent
// operators. maxN caps the per-task set so selection and tally are bounded.
// where a strict majority (2-of-3) is agreement rather than a single answer
// restated. maxN caps the per-task set so selection and tally are bounded.
const (
minN = 3
maxN = 256
@@ -65,9 +67,9 @@ var (
// RequestMarginFloor / RequestMarginBps define the ELIGIBLE-SET MARGIN:
// a task is rejected unless the eligible pool E for the ModelSpec satisfies
// E >= N + max(RequestMarginFloor, N*RequestMarginBps/1e4). This forbids
// degenerate pools where selection has no sampling headroom over an
// independent set, and guarantees the draw is always a strict subset of a
// larger universe (a single cheap operator is never the whole pool).
// degenerate pools where selection has no sampling headroom, and guarantees
// the draw is always a strict subset of a larger universe (a single cheap
// operator is never the whole pool).
RequestMarginFloor uint32 = 2
RequestMarginBps uint32 = 5000 // 50%
-1
View File
@@ -22,7 +22,6 @@ var (
// custody / conservation
ErrInsufficientFunds = errors.New("aivm/quorum: insufficient balance")
ErrLedgerNotFundable = errors.New("aivm/quorum: ledger does not support genesis funding")
ErrEscrowUnderflow = errors.New("aivm/quorum: escrow underflow (invariant broken)")
ErrStakeOverflow = errors.New("aivm/quorum: stake overflow")
ErrCreditOverflow = errors.New("aivm/quorum: credit overflow")
+16 -1
View File
@@ -125,13 +125,28 @@ func merkleProof(leaves []common.Hash, idx uint32) MerkleProof {
return proof
}
// maxProofDepth bounds the levels a proof may claim. A tree of 2^32 leaves needs
// 32, and Index is a uint32, so a longer proof indexes nothing — it is only a
// way to make a verifier do unbounded work on an attacker's say-so.
const maxProofDepth = 32
// VerifyReceiptProof checks that receiptHash is included under root at the
// proof's index. It re-applies the leaf hash (leafHash) and folds with the
// siblings, choosing left/right by the index bit at each level — exactly
// inverting merkleProof/merkleRoot. Returns true iff the recomputed root equals
// root. This is the function the A->C boundary (and anyone) uses to verify an
// exported receipt belongs to the committed receipt_root.
//
// The index must be SPENT by the fold: after one halving per sibling nothing may
// be left of it. The fold reads one bit per level and silently discards the rest,
// so any index sharing its low bits verified — in a 4-leaf tree, index 4 (and 8,
// and 12, and every other index congruent mod 4) proved membership at index 0.
// An index is part of what a proof asserts, and a verifier that ignores most of
// it is not checking that assertion.
func VerifyReceiptProof(receiptHash common.Hash, proof MerkleProof, root common.Hash) bool {
if len(proof.Siblings) > maxProofDepth {
return false
}
cur := leafHash(receiptHash)
idx := proof.Index
for _, sib := range proof.Siblings {
@@ -142,5 +157,5 @@ func VerifyReceiptProof(receiptHash common.Hash, proof MerkleProof, root common.
}
idx /= 2
}
return cur == root
return idx == 0 && cur == root
}
+103 -68
View File
@@ -57,8 +57,98 @@ type QuorumLedger interface {
GetBalance(a common.Address) *uint256.Int
Pull(from common.Address, amount *uint256.Int) error
Pay(to common.Address, amount *uint256.Int) error
// Credit mints into an account. It is the genesis/deposit seam — the host
// L1 crediting an A-Chain account at chain birth or via a verified
// cross-chain deposit — and the only way value enters. Pull and Pay only
// move what Credit put there.
Credit(a common.Address, amount *uint256.Int) error
}
// ---------------------------------------------------------------------------
// The value arithmetic, once.
// ---------------------------------------------------------------------------
// balances is where an account's number is kept. It is the ONLY thing the two
// ledgers below differ in: one keeps them in a map, the other in engine state.
// Everything that decides whether value may move is written once, against this,
// so an in-memory ledger and a chain-state ledger cannot drift into different
// answers about an overdraft.
type balances interface {
get(a common.Address) *uint256.Int
set(a common.Address, v *uint256.Int)
}
// credit mints amount into a. Fail-closed on overflow; no write on error.
func credit(b balances, a common.Address, amount *uint256.Int) error {
nv := new(uint256.Int)
if _, over := nv.AddOverflow(b.get(a), amount); over {
return ErrCreditOverflow
}
b.set(a, nv)
return nil
}
// pull debits from by amount and credits EscrowAccount. Fail-closed on
// insufficient balance or overflow; no write on error.
func pull(b balances, from common.Address, amount *uint256.Int) error {
src := b.get(from)
if src.Lt(amount) {
return ErrInsufficientFunds
}
ne := new(uint256.Int)
if _, over := ne.AddOverflow(b.get(EscrowAccount), amount); over {
return ErrStakeOverflow
}
b.set(from, new(uint256.Int).Sub(src, amount))
b.set(EscrowAccount, ne)
return nil
}
// pay debits EscrowAccount by amount and credits to. Fail-closed on escrow
// underflow (a hard invariant breach) or recipient overflow.
func pay(b balances, to common.Address, amount *uint256.Int) error {
esc := b.get(EscrowAccount)
if esc.Lt(amount) {
return ErrEscrowUnderflow
}
nd := new(uint256.Int)
if _, over := nd.AddOverflow(b.get(to), amount); over {
return ErrCreditOverflow
}
b.set(EscrowAccount, new(uint256.Int).Sub(esc, amount))
b.set(to, nd)
return nil
}
// stateLedger keeps native balances in engine state. It is what the VM runs on,
// and it is the reason value now moves the way everything else does: through the
// block's staging layer, committed in the block's single write, and durable
// across a restart. Held beside the state instead, a ledger had to be staged,
// rolled back and committed in step with it — and the two halves were not in
// step, so a restart came back with the stake records and none of the balances.
type stateLedger struct{ st QuorumState }
func newStateLedger(st QuorumState) *stateLedger { return &stateLedger{st: st} }
func (l *stateLedger) get(a common.Address) *uint256.Int {
return new(uint256.Int).SetBytes(l.st.GetState(slotAddr(nsBalance, a)).Bytes())
}
func (l *stateLedger) set(a common.Address, v *uint256.Int) {
l.st.SetState(slotAddr(nsBalance, a), h32(v))
}
func (l *stateLedger) GetBalance(a common.Address) *uint256.Int { return l.get(a) }
func (l *stateLedger) Credit(a common.Address, amount *uint256.Int) error {
return credit(l, a, amount)
}
func (l *stateLedger) Pull(from common.Address, amount *uint256.Int) error {
return pull(l, from, amount)
}
func (l *stateLedger) Pay(to common.Address, amount *uint256.Int) error { return pay(l, to, amount) }
// ---------------------------------------------------------------------------
// In-memory substrate (tests + single-process engine; the live VM swaps in a
// DB-backed QuorumState committed under consensus).
@@ -97,63 +187,30 @@ func NewMemLedger(opening map[common.Address]*uint256.Int) *MemLedger {
return &MemLedger{bal: bal}
}
// GetBalance returns a copy of the account's balance (zero if unknown).
func (l *MemLedger) GetBalance(a common.Address) *uint256.Int {
func (l *MemLedger) get(a common.Address) *uint256.Int {
if v, ok := l.bal[a]; ok {
return new(uint256.Int).Set(v)
}
return uint256.NewInt(0)
}
// Credit mints `amount` into `a`'s balance. This is the genesis/bootstrap seam
// for native value (the L1's own state credits an A-Chain account at chain birth
// or via a cross-chain deposit before any task/registration can pull from it).
// It is the symmetric counterpart of GetBalance/Pull/Pay and the only way the VM
// seeds opening balances onto a ledger that was constructed empty. Fail-closed on
// overflow; no state change on error.
func (l *MemLedger) set(a common.Address, v *uint256.Int) { l.bal[a] = v }
// GetBalance returns a copy of the account's balance (zero if unknown).
func (l *MemLedger) GetBalance(a common.Address) *uint256.Int { return l.get(a) }
// Credit mints `amount` into `a`'s balance.
func (l *MemLedger) Credit(a common.Address, amount *uint256.Int) error {
cur := l.GetBalance(a)
nv := new(uint256.Int)
if _, overflow := nv.AddOverflow(cur, amount); overflow {
return ErrCreditOverflow
}
l.bal[a] = nv
return nil
return credit(l, a, amount)
}
// Pull debits `from` by amount and credits EscrowAccount. Fail-closed on
// insufficient balance or overflow; no state change on error.
// Pull debits `from` by amount and credits EscrowAccount.
func (l *MemLedger) Pull(from common.Address, amount *uint256.Int) error {
src := l.GetBalance(from)
if src.Lt(amount) {
return ErrInsufficientFunds
}
esc := l.GetBalance(EscrowAccount)
ne := new(uint256.Int)
if _, overflow := ne.AddOverflow(esc, amount); overflow {
return ErrStakeOverflow
}
l.bal[from] = new(uint256.Int).Sub(src, amount)
l.bal[EscrowAccount] = ne
return nil
return pull(l, from, amount)
}
// Pay debits EscrowAccount by amount and credits `to`. Fail-closed on escrow
// underflow (a hard invariant breach) or recipient overflow.
func (l *MemLedger) Pay(to common.Address, amount *uint256.Int) error {
esc := l.GetBalance(EscrowAccount)
if esc.Lt(amount) {
return ErrEscrowUnderflow
}
dst := l.GetBalance(to)
nd := new(uint256.Int)
if _, overflow := nd.AddOverflow(dst, amount); overflow {
return ErrCreditOverflow
}
l.bal[EscrowAccount] = new(uint256.Int).Sub(esc, amount)
l.bal[to] = nd
return nil
}
// Pay debits EscrowAccount by amount and credits `to`.
func (l *MemLedger) Pay(to common.Address, amount *uint256.Int) error { return pay(l, to, amount) }
// Total returns the grand-total balance over every account (for the
// conservation invariant test). Pure read.
@@ -165,29 +222,6 @@ func (l *MemLedger) Total() *uint256.Int {
return sum
}
// Snapshot returns a deep copy of the balance map. Paired with Restore, it lets
// the VM gate ledger mutations on block Accept: BuildBlock/Verify may move funds
// while planning a block, and a rejected/discarded block restores the snapshot so
// no value moves outside consensus. (The engine's QuorumState is staged
// separately via versiondb; the two are committed/aborted together.)
func (l *MemLedger) Snapshot() map[common.Address]*uint256.Int {
cp := make(map[common.Address]*uint256.Int, len(l.bal))
for a, v := range l.bal {
cp[a] = new(uint256.Int).Set(v)
}
return cp
}
// Restore replaces the balance map with a previously taken Snapshot (deep-copied
// again so the snapshot stays reusable).
func (l *MemLedger) Restore(snap map[common.Address]*uint256.Int) {
nb := make(map[common.Address]*uint256.Int, len(snap))
for a, v := range snap {
nb[a] = new(uint256.Int).Set(v)
}
l.bal = nb
}
// ---------------------------------------------------------------------------
// State-slot namespaces. Distinct prefixes guarantee the keccak keyspaces of
// different record kinds never collide. Mirrors the proven precompile's layout,
@@ -200,6 +234,7 @@ var (
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
nsBalance = []byte("av/bal") // native account balance (chain custody)
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
+166 -152
View File
@@ -4,17 +4,30 @@
package aivm
// quorum_vm.go is the VM-side glue that mounts the A-Chain quorum settlement
// engine onto the ChainVM: a DB-backed QuorumState committed under consensus, a
// native-token QuorumLedger, and the CONSENSUS-GATED inbound path for C-Chain
// intents. It keeps all engine wiring in one orthogonal place so vm.go stays a
// thin ChainVM shell.
// engine onto the ChainVM: engine state, native-token custody, and the
// CONSENSUS-GATED inbound path for C-Chain intents. It keeps all engine wiring
// in one orthogonal place so vm.go stays a thin ChainVM shell.
//
// The load-bearing safety invariant lives here: committed C intents are buffered
// (EnqueueCommittedIntent) but only TURN INTO TASKS inside BuildBlock /
// Block.Verify (importPending), i.e. under A-Chain consensus. There is no code
// path from a live/RPC request to createTask — the only caller of
// ImportCommittedIntent is importPending, and the only callers of importPending
// are the block build and verify paths.
// (EnqueueCommittedIntent) but only TURN INTO TASKS inside a block. There is no
// code path from a live/RPC request to createTask — the only caller of
// ImportCommittedIntent is pick/replay, and the only callers of those are the
// block build, verify and accept paths.
//
// TWO WRITE PLANES, never braided:
//
// - COMMITTED state (vm.db, read/written through vm.qstate) is what the chain
// has agreed. Every read outside a block sees exactly this.
//
// - The VIEW (vm.view, a versiondb over vm.db) is what an ACCEPTING block
// writes through. It is empty except between the first write of Block.Accept
// and that block's single Commit, so a block's writes can never ride out on
// another block's commit — the fault that let Accept(A) publish B's staged
// intent and Reject(B) discard A's.
//
// Building and verifying write to NEITHER. They run the same transition against
// an overlay that buffers writes in memory and is then thrown away, so a block
// that is proposed, or checked and lost, moves no state and no value at all.
import (
"github.com/holiman/uint256"
@@ -25,9 +38,8 @@ import (
// dbState adapts a luxfi/database.Database (or any KeyValueReaderWriter) to the
// engine's QuorumState. Engine slots are 32-byte keccak keys; values are 32-byte
// words. State written here is part of the VM's DB and commits under consensus
// when the enclosing block is Accepted. A read miss returns the zero hash, which
// the engine treats as "unset" — identical semantics to the in-memory MemState.
// words. A read miss returns the zero hash, which the engine treats as "unset" —
// identical semantics to the in-memory MemState.
type dbState struct {
db database.KeyValueReaderWriter
}
@@ -36,8 +48,8 @@ type dbState struct {
func NewDBState(db database.KeyValueReaderWriter) QuorumState { return &dbState{db: db} }
func (s *dbState) stateKey(slot common.Hash) []byte {
// Prefix engine slots so they never collide with block/height keys in the
// shared DB.
// Prefix engine slots so they never collide with block/height/tip keys in
// the shared DB.
return append([]byte("av/state/"), slot.Bytes()...)
}
@@ -56,6 +68,34 @@ func (s *dbState) SetState(slot, value common.Hash) {
_ = s.db.Put(s.stateKey(slot), value.Bytes())
}
// overlay is a QuorumState that buffers writes over another one. It is how a
// transition can be RUN without being APPLIED: the proposer needs the state a
// block would reach in order to stamp its receipt root, and a validator needs it
// in order to check that stamp, and neither of them is allowed to change
// anything. Both use one of these and drop it.
//
// It is deliberately at the QuorumState layer rather than the database layer.
// The engine's whole storage contract is "32-byte slot in, 32-byte word out", so
// buffering it takes a map and two methods, with no commit path to get wrong and
// no second versiondb whose Abort someone could forget to call.
type overlay struct {
base QuorumState
dirty map[common.Hash]common.Hash
}
func newOverlay(base QuorumState) *overlay {
return &overlay{base: base, dirty: make(map[common.Hash]common.Hash)}
}
func (o *overlay) GetState(slot common.Hash) common.Hash {
if v, ok := o.dirty[slot]; ok {
return v
}
return o.base.GetState(slot)
}
func (o *overlay) SetState(slot, value common.Hash) { o.dirty[slot] = value }
// SetCommitVerifier installs the C-Chain committedness proof checker. Until this
// is set the VM uses a fail-closed verifier that admits nothing, so no boundary
// intent can create a task. The verifier is the single trust the inbound seam
@@ -67,175 +107,149 @@ func (vm *VM) SetCommitVerifier(ccv CCommitVerifier) {
}
// QuorumEngine exposes the engine handle (and its state/ledger) for the RPC
// service and tests. The returned QuorumState commits under consensus.
// service and tests. The QuorumState returned is COMMITTED state: it shows what
// the chain has accepted and nothing a block in flight has proposed.
func (vm *VM) QuorumEngine() (*Engine, QuorumState, QuorumLedger) {
return vm.quorum, vm.qstate, vm.qledger
}
// FundLedger seeds opening native balances onto the VM's committed-state ledger.
// This is the genesis/deposit seam: the host L1 credits A-Chain accounts (a
// requester that will fund an inference escrow, an operator that will bond) at
// chain birth or via a verified cross-chain deposit, BEFORE any task or
// registration can pull from them. It mutates the same MemLedger that
// importPending/Settle move value within and that commitEngine/abortEngine
// gate on Accept/Reject, so funded balances participate in the conservation
// invariant exactly like deposited value. Guarded by vm.mu. Fail-closed: a
// credit overflow aborts with no partial seeding.
// FundLedger seeds opening native balances. This is the genesis/deposit seam:
// the host L1 credits A-Chain accounts (a requester that will fund an inference
// escrow, an operator that will bond) at chain birth or via a verified
// cross-chain deposit, BEFORE any task or registration can pull from them.
//
// It goes through Seed, so the credits are durable at once rather than riding
// out on whichever block commits first. Fail-closed: a credit overflow aborts
// with no partial seeding.
func (vm *VM) FundLedger(opening map[common.Address]*uint256.Int) error {
vm.mu.Lock()
defer vm.mu.Unlock()
l, ok := vm.qledger.(*MemLedger)
if !ok {
return ErrLedgerNotFundable
}
for a, v := range opening {
if err := l.Credit(a, v); err != nil {
return err
return vm.Seed(func(_ QuorumState, lg QuorumLedger) error {
for a, v := range opening {
if err := lg.Credit(a, v); err != nil {
return err
}
}
}
return nil
return nil
})
}
// EnqueueCommittedIntent buffers a C-Chain intent that the boundary transport
// has delivered with a committedness proof. It does NOT create a task — that
// happens only under consensus in BuildBlock/Verify via importPending. Safe to
// call from the transport goroutine; guarded by the VM lock.
// happens only under consensus, inside a block. Safe to call from the transport
// goroutine; guarded by the VM lock.
func (vm *VM) EnqueueCommittedIntent(intent CIntent) {
vm.mu.Lock()
defer vm.mu.Unlock()
vm.pendingIntents = append(vm.pendingIntents, intent)
}
// verifyImported re-runs, under consensus (Block.Verify), the exact set of
// committed intents a proposer recorded in a block — so a follower reaches
// byte-identical engine state. Each intent must still pass the id binding +
// committedness proof + anti-replay (idempotent: an already-imported intent is
// rejected by the seen marker, which is the correct outcome if the proposer
// already applied it in this same verify pass; followers apply each exactly
// once). Returns an error if the recorded set does not reproduce the recorded
// receipt_root. Caller holds vm.mu.
func (vm *VM) verifyImported(height uint64, recorded []CIntent, wantRoot common.Hash) error {
if vm.quorum == nil || vm.qstate == nil || vm.qledger == nil {
return nil
// drop forgets the intents a block carried, once that block is durable. Until
// then they stay buffered: a proposal that loses the round has consumed nothing,
// and the next proposer must still be able to carry the same work.
//
// Caller holds vm.mu.
func (vm *VM) drop(carried []CIntent) {
if len(carried) == 0 {
return
}
for _, intent := range recorded {
// A follower that has not yet applied this intent applies it now; one that
// has (seen marker set) gets ErrIntentAlreadyUsed, which is benign here.
_, err := vm.quorum.ImportCommittedIntent(vm.qstate, vm.qledger, vm.ccv, intent, height)
if err != nil && err != ErrIntentAlreadyUsed {
return err
gone := make(map[common.Hash]struct{}, len(carried))
for _, in := range carried {
gone[in.IntentID] = struct{}{}
}
kept := vm.pendingIntents[:0]
for _, in := range vm.pendingIntents {
if _, ok := gone[in.IntentID]; !ok {
kept = append(kept, in)
}
}
// Same pass the proposer ran, at the same height. Settlement is a function
// of the state and the height, so it needs nothing recorded in the block:
// the receipt-root comparison below is what checks it.
vm.settleDue(height)
// The recorded root must equal the one these imports actually produce, with no
// exemption for the zero hash. Skipping the comparison when the block claims
// zero made the determinism check opt-out: a proposer wrote a zero receipt_root
// and followers applied its intents without ever checking that their engine
// state agreed. An honest proposer stamps the engine's own root (vm.go:622), and
// a block with no folded receipts carries zero on both sides, so an equality
// check admits every honest case and only rejects a genuine divergence.
if got := vm.quorum.ReceiptRoot(vm.qstate); got != wantRoot {
return ErrReceiptRootMismatch
vm.pendingIntents = kept
}
// replay applies the intents a block RECORDED, at that block's height, and
// returns the receipt root they reach. It is what a validator runs to check a
// proposer's arithmetic and what Accept runs to make it durable — the same code
// over different storage, so the two can never disagree about what a block does.
//
// Every recorded intent must still import. An intent this node has already
// consumed reports ErrIntentAlreadyUsed and is benign — the anti-replay marker
// is exactly the right verdict, and the receipt-root comparison below is what
// decides whether the resulting state agrees.
//
// Caller holds vm.mu.
func (vm *VM) replay(st QuorumState, lg QuorumLedger, recorded []CIntent, height uint64) (common.Hash, error) {
for _, intent := range recorded {
_, err := vm.quorum.ImportCommittedIntent(st, lg, vm.ccv, intent, height)
if err != nil && err != ErrIntentAlreadyUsed {
return common.Hash{}, err
}
}
return vm.settle(st, lg, height), nil
}
// pick chooses, from the buffered committed intents, the ones that import
// cleanly at this height, applying each as it goes so the next one sees the
// state the previous left. This is the PROPOSER's decision about what a block
// carries; a rejected intent (forged id, failed proof, replay, ineligible pool)
// is simply left out and no state changed for it.
//
// Caller holds vm.mu.
func (vm *VM) pick(st QuorumState, lg QuorumLedger, height uint64) ([]CIntent, common.Hash) {
carried := make([]CIntent, 0, len(vm.pendingIntents))
for _, intent := range vm.pendingIntents {
if _, err := vm.quorum.ImportCommittedIntent(st, lg, vm.ccv, intent, height); err == nil {
carried = append(carried, intent)
}
}
return carried, vm.settle(st, lg, height)
}
// settle gives a verdict to every task whose reveal window has closed and
// returns the receipt root that leaves. This is what pays operators: without it
// a task sits revealed and unsettled forever, and the work the network did for
// it is never credited to anyone.
//
// It is a function of the state and the height alone, so it needs nothing
// recorded in the block — the receipt root is what checks it.
func (vm *VM) settle(st QuorumState, lg QuorumLedger, height uint64) common.Hash {
vm.quorum.SettleDue(st, lg, height)
return vm.quorum.ReceiptRoot(st)
}
// Seed applies the one engine mutation a chain makes outside consensus: what
// its genesis allocates. It writes through the view and commits at once, before
// any block exists, for the same reason Accept commits — left staged, a genesis
// allocation would ride out on whichever block committed first and vanish with a
// chain that never accepted one.
func (vm *VM) Seed(write func(QuorumState, QuorumLedger) error) error {
vm.mu.Lock()
defer vm.mu.Unlock()
staged := NewDBState(vm.view)
if err := write(staged, newStateLedger(staged)); err != nil {
vm.view.Abort()
return err
}
if err := vm.view.Commit(); err != nil {
vm.view.Abort()
return err
}
return nil
}
// importPending drains the buffered committed intents into A-Chain tasks under
// consensus. It is the ONLY caller of the engine's verified inbound seam.
// Returns the intents that successfully created a task (to be recorded in the
// block for deterministic re-verification). Caller holds vm.mu.
func (vm *VM) importPending(height uint64) []CIntent {
if vm.quorum == nil || vm.qstate == nil || vm.qledger == nil || len(vm.pendingIntents) == 0 {
return nil
}
imported := make([]CIntent, 0, len(vm.pendingIntents))
for _, intent := range vm.pendingIntents {
if _, err := vm.quorum.ImportCommittedIntent(vm.qstate, vm.qledger, vm.ccv, intent, height); err == nil {
imported = append(imported, intent)
}
// A rejected intent (forged id, failed proof, replay, ineligible pool) is
// simply dropped — it created no task and no state changed (fail-closed).
}
vm.pendingIntents = vm.pendingIntents[:0]
return imported
}
// settleDue gives a verdict to every task whose reveal window has closed. It
// runs on both the build and the verify path at the same height over the same
// state, so both reach the same verdicts; a disagreement about who was paid
// changes the receipt root, which verifyImported already refuses.
func (vm *VM) settleDue(height uint64) uint32 {
if vm.quorum == nil || vm.qstate == nil || vm.qledger == nil {
return 0
}
return vm.quorum.SettleDue(vm.qstate, vm.qledger, height)
}
// vmLedger is the engine's QuorumLedger backed by the VM's account balances. For
// chains that carry native balances in their own state this would read/write
// that store; here it is the in-memory MemLedger seeded at Initialize, which is
// sufficient for the engine's conservation guarantees (the canonical custody
// account is EscrowAccount regardless of backing store).
type vmLedger = MemLedger
// initQuorum sets up the engine, its DB-backed state, and ledger. Called from
// initQuorum sets up the engine, its state and its ledger. Called from
// Initialize. cChainID/aChainID are derived from the deployment (the host
// chain's id and the configured C-chain id); here we derive stable 32-byte ids
// from the VM's network id and host chain id so a single-node test/dev instance
// is self-consistent.
//
// Engine state is STAGED in a versiondb layered over vm.db: writes from
// importPending/Settle (which run in BuildBlock / Verify) accumulate in the
// version delta and only land in vm.db when commitEngine() is called from
// Block.Accept. A rejected/discarded block calls abortEngine() to drop the delta.
// This is what makes "engine state commits under consensus AT Accept" true — a
// block that is built but never accepted moves NO durable state and NO value.
func (vm *VM) initQuorum(opening map[common.Address]*uint256.Int) {
// Custody is engine state, so the ledger is a view over it rather than a second
// store: value moves through the one staging layer, commits in the one write,
// and survives a restart with the stake records it belongs to.
func (vm *VM) initQuorum() {
c := chainIDFromString("c-chain")
a := chainIDFromString(vm.config.HostChainID)
vm.quorum = NewEngine(c, a)
vm.qdb = versiondb.New(vm.db)
vm.qstate = NewDBState(vm.qdb)
vm.qledger = NewMemLedger(opening)
}
// commitEngine flushes the staged engine-state delta to the durable DB and clears
// the ledger snapshot. Called from Block.Accept (under vm.mu) — the SOLE commit
// point for engine state and ledger value. Returns any DB commit error so Accept
// fails loudly rather than silently losing state.
func (vm *VM) commitEngine() error {
if vm.qdb == nil {
return nil
}
if err := vm.qdb.Commit(); err != nil {
return err
}
vm.qledgerSnap = nil
return nil
}
// abortEngine drops the staged engine-state delta and rolls the ledger back to the
// last committed snapshot. Called from Block.Reject (and any discard path) so a
// block that never reaches Accept leaves zero durable side effects.
func (vm *VM) abortEngine() {
if vm.qdb != nil {
vm.qdb.Abort()
}
if l, ok := vm.qledger.(*MemLedger); ok && vm.qledgerSnap != nil {
l.Restore(vm.qledgerSnap)
}
}
// snapshotLedger records the ledger's committed balances so abortEngine can roll
// back to them. Taken at the start of a block-building / verification pass that
// may move funds, BEFORE any import touches the ledger.
func (vm *VM) snapshotLedger() {
if l, ok := vm.qledger.(*MemLedger); ok && vm.qledgerSnap == nil {
vm.qledgerSnap = l.Snapshot()
}
vm.view = versiondb.New(vm.db)
vm.qstate = NewDBState(vm.db)
vm.qledger = newStateLedger(vm.qstate)
}
// chainIDFromString derives a stable 32-byte chain id from a label. Deterministic
+49
View File
@@ -159,3 +159,52 @@ func TestMerkleProofRoundTrip(t *testing.T) {
require.Equal(r1, r2)
require.NotEqual(r1, rootOverAddrs([]common.Address{addr(3), addr(2), addr(1)}), "order matters")
}
// The fold reads one bit of the index per level and silently discards the rest,
// so every index sharing its low bits verified: in a four-leaf tree, index 4
// proved membership at index 0. An index is part of what a proof asserts, and a
// verifier that ignores most of it is not checking that assertion.
func TestAProofIndexMustBeSpentByTheFold(t *testing.T) {
require := require.New(t)
raw := []common.Hash{h(1), h(2), h(3), h(4)}
leaves := make([]common.Hash, len(raw))
for i, x := range raw {
leaves[i] = leafHash(x)
}
root := merkleRoot(leaves)
honest := merkleProof(leaves, 0)
require.True(VerifyReceiptProof(raw[0], honest, root))
// Same siblings, an out-of-range index whose low bits match. Every multiple
// of the tree width used to pass.
width := uint32(1) << uint(len(honest.Siblings))
for _, k := range []uint32{1, 2, 3, 1 << 20} {
forged := MerkleProof{Index: honest.Index + k*width, Siblings: honest.Siblings}
require.False(VerifyReceiptProof(raw[0], forged, root),
"index %d verified in a %d-leaf tree", forged.Index, len(raw))
}
// A proof claiming more levels than a uint32 index can address is refused
// outright — even one whose arithmetic checks out. No tree this chain builds
// has that many levels, so such a proof is not a membership claim about any
// of them; it is a way to make a verifier fold for as long as the sender
// likes.
fold := func(levels int) (MerkleProof, common.Hash) {
p := MerkleProof{Index: 0, Siblings: make([]common.Hash, levels)}
cur := leafHash(raw[0])
for range p.Siblings {
cur = merkleNode(cur, common.Hash{})
}
return p, cur
}
atLimit, limitRoot := fold(maxProofDepth)
require.True(VerifyReceiptProof(raw[0], atLimit, limitRoot), "a proof at the limit still verifies")
past, pastRoot := fold(maxProofDepth + 1)
require.False(VerifyReceiptProof(raw[0], past, pastRoot),
"a proof one level past the limit verified on its own arithmetic")
// A single-leaf tree is the one honest zero-sibling case, and it still works.
require.True(VerifyReceiptProof(raw[0], MerkleProof{}, leafHash(raw[0])))
}
-1
View File
@@ -41,7 +41,6 @@ func TestVerifyRejectsBlockClaimingZeroReceiptRoot(t *testing.T) {
}
_, err = e.Settle(st, lg, taskID, 161)
require.NoError(err)
require.NoError(v.commitEngine())
root := e.ReceiptRoot(st)
require.NotEqual(common.Hash{}, root, "a settled receipt must move the root")
+2 -5
View File
@@ -56,13 +56,11 @@ func newAIVMForConsensus(t *testing.T) (*VM, common.Address, []common.Address) {
ops[i] = addr(byte(0x10 + i))
opening[ops[i]] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
v.qledger = NewMemLedger(opening)
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))))
}
// Commit the seed so registrations are durable (survive abortEngine).
require.NoError(t, v.commitEngine())
return v, reqr, ops
}
@@ -153,12 +151,11 @@ func TestRegression_DefaultVerifierFailClosed(t *testing.T) {
for i := 0; i < eligible; i++ {
opening[addr(byte(0x10+i))] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
v.qledger = NewMemLedger(opening)
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))))
}
require.NoError(v.commitEngine())
intent := mkIntent(e, reqr, testN, testThr, uint256.NewInt(1), reward)
v.EnqueueCommittedIntent(intent)
blk, err := v.BuildBlock(context.Background())
+75 -1
View File
@@ -44,7 +44,7 @@ func TestProbe_DefaultVerifierFailClosed(t *testing.T) {
for i := 0; i < eligible; i++ {
opening[addr(byte(0x10+i))] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
v.qledger = NewMemLedger(opening)
require.NoError(v.FundLedger(opening))
for i := 0; i < eligible; i++ {
st2 := v.qstate
require.NoError(e.RegisterOperator(st2, v.qledger, addr(byte(0x10+i)),
@@ -127,3 +127,77 @@ func TestProbe_ThresholdEqualsN(t *testing.T) {
require.NoError(err)
require.Equal(TaskFailed, res.Status, "unanimity not reached -> Failed")
}
// Settlement is a function of the state and the height, so running the same pass
// twice at the same height must be indistinguishable from running it once. If it
// were not, two nodes that reached a height by different routes — one that ran
// the pass while building and again while accepting — would disagree about who
// was paid, and the receipt root would split them.
func TestASecondSettlePassAtTheSameHeightChangesNothing(t *testing.T) {
require := require.New(t)
e := NewEngine(h(1), h(2))
st := NewMemState()
reward := uint256.NewInt(1_000_000_000_000_000_000)
requester := addr(0xF0)
opening := map[common.Address]*uint256.Int{requester: new(uint256.Int).Mul(reward, uint256.NewInt(64))}
ops := make([]common.Address, eligible)
for i := range ops {
ops[i] = addr(byte(0x10 + i))
opening[ops[i]] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
lg := NewMemLedger(opening)
for i, op := range ops {
require.NoError(e.RegisterOperator(st, lg, op, new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(2)), modelSpec, h(byte(0x80+i))))
}
in := mkIntent(e, requester, testN, testThr, uint256.NewInt(1), reward)
taskID, err := e.ImportCommittedIntent(st, lg, acceptAll, in, 1)
require.NoError(err)
sel, err := e.SelectOperators(st, taskID, modelSpec, testN)
require.NoError(err)
out := h(0x42)
for i := 0; i < testThr; i++ {
require.NoError(e.CommitResponse(st, taskID, sel[i], opCommit(taskID, sel[i], out, h(7), h(9)), 2))
require.NoError(e.RevealResponse(st, taskID, sel[i], out, h(7), h(9), 32))
}
total := lg.Total().String()
first := e.SettleDue(st, lg, 62)
root := e.ReceiptRoot(st)
paid := lg.Total().String()
second := e.SettleDue(st, lg, 62)
require.Equal(uint32(1), first, "the due task should have been settled once")
require.Equal(uint32(0), second, "a settled task must not be settled again")
require.Equal(root, e.ReceiptRoot(st), "a second pass moved the receipt root")
require.Equal(paid, lg.Total().String(), "a second pass moved value")
require.Equal(total, paid, "settlement is a transfer, not a mint")
}
// An error is a statement about what happened. Refusing a withdrawal because the
// operator has NOT deregistered used to report "operator is unbonding" — the
// opposite of the truth, and an operator reading it would conclude it had
// already started its cooldown and simply wait.
func TestWithdrawSaysWhichWayItRefused(t *testing.T) {
require := require.New(t)
e := NewEngine(h(1), h(2))
st := NewMemState()
op := addr(1)
lg := NewMemLedger(map[common.Address]*uint256.Int{op: new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))})
require.NoError(e.RegisterOperator(st, lg, op, MinProviderBond, modelSpec, h(1)))
_, err := e.WithdrawStake(st, lg, op, 100)
require.ErrorIs(err, ErrOperatorBonded, "a bonded operator was told it was unbonding")
require.NoError(e.DeregisterOperator(st, op, 100))
_, err = e.WithdrawStake(st, lg, op, 100)
require.ErrorIs(err, ErrCooldownActive)
_, err = e.WithdrawStake(st, lg, addr(2), 100)
require.ErrorIs(err, ErrOperatorUnknown)
stake, err := e.WithdrawStake(st, lg, op, 100+UnbondCooldownBlocks)
require.NoError(err)
require.Equal(MinProviderBond.String(), stake.String())
}
+393
View File
@@ -0,0 +1,393 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package aivm
// refusal_test.go walks the engine's refusals: every way a registration, a task,
// a commit, a reveal or a withdrawal can be turned down, and the error each one
// answers with.
//
// The engine is fail-closed by construction — a rejected operation makes NO
// write — so a refusal that returns the wrong error, or returns none at all, is
// how a caller learns the wrong thing about state it cannot see. These are the
// paths a caller actually hits.
import (
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// intentFor builds a correctly-id-bound intent with the fields a test wants to
// vary. buildIntent pins the model and prompt; this does not.
func intentFor(e *Engine, requester common.Address, model, prompt common.Hash, n, threshold uint16, fee, reward *uint256.Int) CIntent {
cTx, callIdx := h(0x11), uint32(7)
return CIntent{
IntentID: ComputeIntentID(e.CChainID, e.AChainID, cTx, callIdx, requester, model, prompt, n, threshold, fee),
CChainID: e.CChainID,
AChainID: e.AChainID,
CTxHash: cTx,
CallIndex: callIdx,
Caller: requester,
ModelSpecHash: model,
PromptHash: prompt,
N: n,
Threshold: threshold,
Fee: fee,
RewardPerOperator: reward,
}
}
// -----------------------------------------------------------------------------
// Registration and stake.
// -----------------------------------------------------------------------------
func TestRegistrationRefusals(t *testing.T) {
require := require.New(t)
e := NewEngine(h(1), h(2))
st := NewMemState()
op := addr(1)
lg := NewMemLedger(map[common.Address]*uint256.Int{op: new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))})
// A registration with no model advertises nothing, so no task could ever
// select it — and it would sit in the enumeration forever.
require.ErrorIs(e.RegisterOperator(st, lg, op, MinProviderBond, common.Hash{}, h(1)), ErrEmptyModelSpec)
// Below the bond there is nothing at risk, which is the whole security claim.
require.ErrorIs(e.RegisterOperator(st, lg, op, uint256.NewInt(1), modelSpec, h(1)), ErrStakeBelowMin)
// An operator that cannot fund the bond does not get to skip posting it.
poor := addr(2)
require.ErrorIs(e.RegisterOperator(st, NewMemLedger(nil), poor, MinProviderBond, modelSpec, h(1)), ErrInsufficientFunds)
require.False(e.IsEligible(st, poor, modelSpec))
require.NoError(e.RegisterOperator(st, lg, op, MinProviderBond, modelSpec, h(1)))
require.True(e.IsEligible(st, op, modelSpec))
// One active registration per operator.
require.ErrorIs(e.RegisterOperator(st, lg, op, MinProviderBond, modelSpec, h(1)), ErrOperatorExists)
// Deregistering twice is refused, and an unknown operator has nothing to
// deregister.
require.ErrorIs(e.DeregisterOperator(st, addr(9), 10), ErrOperatorUnknown)
require.NoError(e.DeregisterOperator(st, op, 10))
require.ErrorIs(e.DeregisterOperator(st, op, 10), ErrOperatorUnbonding)
require.False(e.IsEligible(st, op, modelSpec), "an unbonding operator stops being selectable at once")
// After the cooldown the bond comes back, and re-registering does not
// duplicate the enumeration entry.
_, err := e.WithdrawStake(st, lg, op, 10+UnbondCooldownBlocks)
require.NoError(err)
before := modelCount(st, modelSpec)
require.NoError(e.RegisterOperator(st, lg, op, MinProviderBond, modelSpec, h(1)))
require.Equal(before, modelCount(st, modelSpec), "re-registration duplicated the enumeration entry")
}
// Eligibility is recomputed from live state, so every reason an operator stops
// being eligible takes effect without anyone rewriting the enumeration.
func TestEligibilityIsRecomputed(t *testing.T) {
require := require.New(t)
e := NewEngine(h(1), h(2))
st := NewMemState()
ops := make([]common.Address, 4)
opening := map[common.Address]*uint256.Int{}
for i := range ops {
ops[i] = addr(byte(0x10 + i))
opening[ops[i]] = new(uint256.Int).Mul(MinProviderBond, uint256.NewInt(3))
}
lg := NewMemLedger(opening)
for i, op := range ops {
require.NoError(e.RegisterOperator(st, lg, op, MinProviderBond, modelSpec, h(byte(0x80+i))))
}
require.Len(eligibleSet(st, modelSpec), 4)
// Unbonding drops out.
require.NoError(e.DeregisterOperator(st, ops[0], 1))
// Slashed below the bond drops out.
writeStake(st, ops[1], uint256.NewInt(1))
// Advertising a different model drops out of THIS model's set.
st.SetState(opSpecSlot(ops[2]), h(0xEE))
left := eligibleSet(st, modelSpec)
require.Equal([]common.Address{ops[3]}, left)
require.Empty(eligibleSet(st, h(0xDD)), "a model nobody advertises has an empty set")
}
func TestWithdrawRewardsRefusals(t *testing.T) {
require := require.New(t)
e := NewEngine(h(1), h(2))
st := NewMemState()
op := addr(1)
_, err := e.WithdrawRewards(st, NewMemLedger(nil), op)
require.ErrorIs(err, ErrNoCredit)
// Credit exists but the escrow does not hold it: a hard invariant breach,
// refused rather than paid out of nothing.
require.NoError(addCredit(st, op, uint256.NewInt(5)))
require.Equal("5", e.GetCredit(st, op).String())
_, err = e.WithdrawRewards(st, NewMemLedger(nil), op)
require.ErrorIs(err, ErrEscrowUnderflow)
require.Equal("5", e.GetCredit(st, op).String(), "a failed payout must not zero the credit")
// Credit arithmetic is fail-closed at the ceiling.
max := new(uint256.Int).Not(uint256.NewInt(0))
require.NoError(addCredit(st, addr(2), max))
require.ErrorIs(addCredit(st, addr(2), uint256.NewInt(1)), ErrCreditOverflow)
require.Equal(max.String(), e.GetCredit(st, addr(2)).String())
}
// The value arithmetic is one implementation over two stores, so both refuse in
// the same places and neither can drift into paying out of an empty escrow.
func TestCustodyRefusesTheSameWayOnBothStores(t *testing.T) {
require := require.New(t)
max := new(uint256.Int).Not(uint256.NewInt(0))
a, b := addr(1), addr(2)
for name, lg := range map[string]QuorumLedger{
"memory": NewMemLedger(nil),
"state": newStateLedger(NewMemState()),
} {
require.ErrorIs(lg.Pull(a, uint256.NewInt(1)), ErrInsufficientFunds, name)
require.ErrorIs(lg.Pay(a, uint256.NewInt(1)), ErrEscrowUnderflow, name)
require.NoError(lg.Credit(a, max), name)
require.ErrorIs(lg.Credit(a, uint256.NewInt(1)), ErrCreditOverflow, name)
require.Equal(max.String(), lg.GetBalance(a).String(), name)
// Escrow at the ceiling cannot take another pull.
require.NoError(lg.Pull(a, max), name)
require.NoError(lg.Credit(b, uint256.NewInt(1)), name)
require.ErrorIs(lg.Pull(b, uint256.NewInt(1)), ErrStakeOverflow, name)
// Paying out to an account already at the ceiling is refused too.
c := addr(3)
require.NoError(lg.Credit(c, max), name)
require.ErrorIs(lg.Pay(c, uint256.NewInt(1)), ErrCreditOverflow, name)
require.NoError(lg.Pay(a, uint256.NewInt(7)), name)
require.Equal("7", lg.GetBalance(a).String(), name)
}
}
// -----------------------------------------------------------------------------
// Opening a task.
// -----------------------------------------------------------------------------
// Every parameter a task carries is checked before any value moves, so a task
// the engine will not run never costs the requester anything.
func TestTaskParameterRefusals(t *testing.T) {
require := require.New(t)
reward := uint256.NewInt(1_000_000_000_000_000_000)
e, st, lg, reqr, _ := newHarness(t, eligible, reward)
fee := RequestFeePerOperator
escrowed := lg.GetBalance(EscrowAccount).String() // the operators' bonds
funded := lg.GetBalance(reqr).String()
open := func(model, prompt common.Hash, n, thr uint16, f, r *uint256.Int) error {
_, err := e.ImportCommittedIntent(st, lg, acceptAll, intentFor(e, reqr, model, prompt, n, thr, f, r), 100)
return err
}
require.ErrorIs(open(common.Hash{}, h(0xCD), testN, testThr, fee, reward), ErrEmptyModelSpec)
require.ErrorIs(open(modelSpec, common.Hash{}, testN, testThr, fee, reward), ErrEmptyPromptHash)
require.ErrorIs(open(modelSpec, h(0xCD), 2, 2, fee, reward), ErrBadN)
require.ErrorIs(open(modelSpec, h(0xCD), maxN+1, 3, fee, reward), ErrBadN)
// A threshold below a strict majority is not agreement; above N is unreachable.
require.ErrorIs(open(modelSpec, h(0xCD), 5, 2, fee, reward), ErrBadThreshold)
require.ErrorIs(open(modelSpec, h(0xCD), 5, 6, fee, reward), ErrBadThreshold)
// A reward that cannot be multiplied by N cannot be escrowed.
max := new(uint256.Int).Not(uint256.NewInt(0))
require.ErrorIs(open(modelSpec, h(0xCD), testN, testThr, fee, max), ErrRewardOverflow)
// Nor an escrow the protocol fee cannot be added to: the A-side fee is
// N*RequestFeePerOperator, not the C-side fee the intent carries.
require.ErrorIs(open(modelSpec, h(0xCD), testN, testThr, fee,
new(uint256.Int).Div(max, uint256.NewInt(uint64(testN)))), ErrRewardOverflow)
// A model nobody advertises has nobody to select.
require.ErrorIs(open(h(0xDD), h(0xCD), testN, testThr, fee, reward), ErrNotEnoughEligible)
// A requester who cannot fund escrow plus fee opens nothing.
require.ErrorIs(open(modelSpec, h(0xCD), testN, testThr, fee, new(uint256.Int).Div(max, uint256.NewInt(64))),
ErrInsufficientFunds)
// None of that moved value.
require.Equal(escrowed, lg.GetBalance(EscrowAccount).String())
require.Equal(funded, lg.GetBalance(reqr).String())
require.Equal("0", lg.GetBalance(BurnAddress).String())
require.Equal(uint32(0), e.LiveTasks(st))
// The honest shape opens, and burns exactly N fees.
require.NoError(open(modelSpec, h(0xCD), testN, testThr, fee, reward))
require.Equal(new(uint256.Int).Mul(fee, uint256.NewInt(uint64(testN))).String(),
lg.GetBalance(BurnAddress).String())
}
// -----------------------------------------------------------------------------
// Committing and revealing.
// -----------------------------------------------------------------------------
func TestCommitAndRevealRefusals(t *testing.T) {
require := require.New(t)
reward := uint256.NewInt(1_000_000_000_000_000_000)
e, st, lg, reqr, ops := newHarness(t, eligible, reward)
taskID, err := e.ImportCommittedIntent(st, lg, acceptAll,
mkIntent(e, reqr, testN, testThr, RequestFeePerOperator, reward), 100)
require.NoError(err)
sel, err := e.SelectOperators(st, taskID, modelSpec, testN)
require.NoError(err)
task := readTask(st, taskID)
out, emb, nonce := h(0x42), h(0x01), h(0x02)
// A commit has to be a commitment to something.
require.ErrorIs(e.CommitResponse(st, taskID, sel[0], common.Hash{}, 101), ErrEmptyCommit)
// To a task that exists,
require.ErrorIs(e.CommitResponse(st, h(0xDE), sel[0], h(1), 101), ErrTaskUnknown)
// by an operator the beacon selected,
unselected := common.Address{}
for _, op := range ops {
found := false
for _, s := range sel {
found = found || s == op
}
if !found {
unselected = op
break
}
}
require.NotEqual(common.Address{}, unselected, "the committee must be a strict subset")
require.ErrorIs(e.CommitResponse(st, taskID, unselected, h(1), 101), ErrNotSelected)
// before the window closes,
require.ErrorIs(e.CommitResponse(st, taskID, sel[0], h(1), task.CommitDeadline+1), ErrCommitClosed)
// and only once.
c := opCommit(taskID, sel[0], out, emb, nonce)
require.NoError(e.CommitResponse(st, taskID, sel[0], c, 101))
require.ErrorIs(e.CommitResponse(st, taskID, sel[0], c, 101), ErrAlreadyCommitted)
// A reveal has to name an output,
require.ErrorIs(e.RevealResponse(st, taskID, sel[0], common.Hash{}, emb, nonce, task.RevealDeadline), ErrEmptyOutputHash)
// on a task that exists,
require.ErrorIs(e.RevealResponse(st, h(0xDE), sel[0], out, emb, nonce, task.RevealDeadline), ErrTaskUnknown)
// after a commit,
require.ErrorIs(e.RevealResponse(st, taskID, sel[1], out, emb, nonce, task.RevealDeadline), ErrNotCommitted)
// inside the window,
require.ErrorIs(e.RevealResponse(st, taskID, sel[0], out, emb, nonce, task.CommitDeadline), ErrRevealNotOpen)
require.ErrorIs(e.RevealResponse(st, taskID, sel[0], out, emb, nonce, task.RevealDeadline+1), ErrRevealClosed)
// matching what was committed,
require.ErrorIs(e.RevealResponse(st, taskID, sel[0], h(0xFF), emb, nonce, task.RevealDeadline), ErrCommitMismatch)
// and only once.
require.NoError(e.RevealResponse(st, taskID, sel[0], out, emb, nonce, task.RevealDeadline))
require.ErrorIs(e.RevealResponse(st, taskID, sel[0], out, emb, nonce, task.RevealDeadline), ErrAlreadyRevealed)
// A settled task takes no further answers.
for i := 1; i < testThr; i++ {
require.NoError(e.CommitResponse(st, taskID, sel[i], opCommit(taskID, sel[i], out, emb, nonce), 101))
require.NoError(e.RevealResponse(st, taskID, sel[i], out, emb, nonce, task.RevealDeadline))
}
_, err = e.Settle(st, lg, taskID, task.RevealDeadline+1)
require.NoError(err)
require.ErrorIs(e.CommitResponse(st, taskID, sel[0], h(1), 101), ErrTaskNotCommitting)
require.ErrorIs(e.RevealResponse(st, taskID, sel[0], out, emb, nonce, task.RevealDeadline), ErrTaskNotCommitting)
_, err = e.Settle(st, lg, taskID, task.RevealDeadline+1)
require.ErrorIs(err, ErrTaskAlreadySettled)
}
// A receipt can only be exported for an intent that settled.
func TestExportRefusesAnUnsettledIntent(t *testing.T) {
require := require.New(t)
reward := uint256.NewInt(1_000_000_000_000_000_000)
e, st, lg, reqr, _ := newHarness(t, eligible, reward)
_, _, _, err := e.ExportReceipt(st, h(0xAB))
require.ErrorIs(err, ErrReceiptNotFound)
in := mkIntent(e, reqr, testN, testThr, RequestFeePerOperator, reward)
_, err = e.ImportCommittedIntent(st, lg, acceptAll, in, 100)
require.NoError(err)
_, _, _, err = e.ExportReceipt(st, in.IntentID)
require.ErrorIs(err, ErrReceiptNotFound, "an imported but unsettled intent has no receipt")
}
// -----------------------------------------------------------------------------
// Small total functions. Each is a rule the whole engine leans on.
// -----------------------------------------------------------------------------
// The margin is a fraction of N with a floor under it, so a small draw still
// gets sampling headroom and a large one gets headroom proportional to itself.
func TestTheMarginIsAFractionWithAFloor(t *testing.T) {
require := require.New(t)
require.Equal(RequestMarginFloor, requiredMargin(3), "the floor holds under a small N")
require.Equal(uint32(4), requiredMargin(8), "half of 8 is above the floor")
require.Equal(uint32(50), requiredMargin(100))
}
// The tie-break is total and deterministic: every validator has to pick the same
// hash from a tie, or they disagree about the canonical answer.
func TestThePluralityTieBreakIsTotal(t *testing.T) {
require := require.New(t)
best, n := plurality([]common.Hash{h(2), h(1), h(2)})
require.Equal(h(2), best)
require.Equal(uint32(2), n)
// A tie goes to the big-endian-smallest hash, both orders.
a, _ := plurality([]common.Hash{h(9), h(4)})
b, _ := plurality([]common.Hash{h(4), h(9)})
require.Equal(h(4), a)
require.Equal(a, b)
_, none := plurality(nil)
require.Equal(uint32(0), none)
}
// The live-task list is walked by index, so removing an entry that is not there
// must be a no-op rather than a swap against nothing.
func TestDroppingAnEntryThatIsNotThereIsANoOp(t *testing.T) {
require := require.New(t)
st := NewMemState()
e := NewEngine(h(1), h(2))
dropLive(st, 0) // empty list
require.Equal(uint32(0), e.LiveTasks(st))
trackLive(st, h(0xAA))
dropLive(st, 7) // past the end
require.Equal(uint32(1), e.LiveTasks(st))
dropLive(st, 0)
require.Equal(uint32(0), e.LiveTasks(st))
}
// A draw needs more eligible operators than it draws.
func TestADrawNeedsAPoolToDrawFrom(t *testing.T) {
require := require.New(t)
e := NewEngine(h(1), h(2))
st := NewMemState()
_, err := e.SelectOperators(st, h(1), modelSpec, 3)
require.ErrorIs(err, ErrNotEnoughEligible)
}
// Export answers only for a settled task, and every way the lookup can fail is
// a refusal rather than a receipt built from whatever was there.
func TestExportRefusesEveryIncompleteLookup(t *testing.T) {
require := require.New(t)
e := NewEngine(h(1), h(2))
st := NewMemState()
intent := h(0xAB)
// A receipt index with no task behind it.
st.SetState(slotHash(nsIntentRcpt, intent), h32(uint256.NewInt(1)))
_, _, _, err := e.ExportReceipt(st, intent)
require.ErrorIs(err, ErrReceiptNotFound)
// A task that exists but never settled.
st.SetState(slotHash(nsIntentTask, intent), h(0x77))
_, err = e.reconstructReceipt(st, h(0x77))
require.ErrorIs(err, ErrTaskUnknown)
_, _, _, err = e.ExportReceipt(st, intent)
require.ErrorIs(err, ErrTaskUnknown)
}
// A fee policy is what admits a user task, so a VM without one admits nothing
// rather than everything.
func TestATaskWithNoFeePolicyIsRefused(t *testing.T) {
require.ErrorContains(t, (&VM{}).gateUserTask(nil), "fee policy not initialized")
}
+1 -1
View File
@@ -323,7 +323,7 @@ func (s *Service) handleMerkleRoot(w http.ResponseWriter, r *http.Request) {
// handleHealth returns health status
func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"healthy": s.vm.running,
"healthy": s.vm.live(),
"version": Version.String(),
})
}
+328
View File
@@ -0,0 +1,328 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package aivm
// surface_test.go covers what the VM exposes to the node and to callers: the
// ChainVM lifecycle, the AI task/provider surface, and the HTTP endpoints. These
// are the paths a request actually takes, and the ones where a refusal has to
// come back as a refusal rather than as a zero value.
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
"github.com/stretchr/testify/require"
aicore "github.com/luxfi/ai/pkg/aivm"
"github.com/luxfi/ai/pkg/attestation"
)
// initWith starts a VM with an explicit config blob, for the branches that only
// a non-default policy reaches.
func initWith(t *testing.T, cfg Config) *VM {
t.Helper()
raw, err := json.Marshal(cfg)
require.NoError(t, err)
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: log.NewNoOpLogger()},
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 8),
Log: log.NewNoOpLogger(),
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
Config: raw,
}))
t.Cleanup(func() { _ = vm.Shutdown(context.Background()) })
return vm
}
// -----------------------------------------------------------------------------
// Initialize.
// -----------------------------------------------------------------------------
func TestInitializeRefusesMalformedInput(t *testing.T) {
require := require.New(t)
ctx := context.Background()
rt := func() *runtime.Runtime {
return &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: log.NewNoOpLogger()}
}
err := (&VM{}).Initialize(ctx, vmcore.Init{
Runtime: rt(), DB: memdb.New(), Log: log.NewNoOpLogger(),
Config: []byte(`{not json`),
})
require.ErrorContains(err, "config")
err = (&VM{}).Initialize(ctx, vmcore.Init{
Runtime: rt(), DB: memdb.New(), Log: log.NewNoOpLogger(),
Genesis: []byte(`{not json`),
})
require.ErrorContains(err, "genesis")
// A runtime whose logger is not a logger leaves the VM with nothing to
// report through.
err = (&VM{}).Initialize(ctx, vmcore.Init{
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369},
DB: memdb.New(),
})
require.ErrorContains(err, "logger")
}
// A permissioned deployment is a policy choice, and it has to survive the round
// trip through the config blob or the node runs the public policy while its
// operator believes otherwise.
func TestPermissionedConfigSurvivesInitialize(t *testing.T) {
vm := initWith(t, DefaultPermissionedConfig())
require.True(t, vm.config.RequireTEEAttestation)
require.Equal(t, ModeTEEAttested, vm.config.VerificationMode)
}
// -----------------------------------------------------------------------------
// The AI surface.
// -----------------------------------------------------------------------------
func TestProviderRegistrationPolicies(t *testing.T) {
require := require.New(t)
// Public path: no attestation needed, the bond and the challenge flow carry
// correctness.
open := oneVM(t)
require.NoError(open.RegisterProvider(&aicore.Provider{ID: "p1", GPUs: []aicore.GPUInfo{{Model: "gb10"}}}))
require.Len(open.GetProviders(), 1)
// Permissioned path: a provider with no attestation at all is refused.
gated := initWith(t, DefaultPermissionedConfig())
err := gated.RegisterProvider(&aicore.Provider{ID: "p2"})
require.ErrorContains(err, "TEE attestation")
// An attestation that does not verify is refused too.
err = gated.RegisterProvider(&aicore.Provider{ID: "p3", GPUAttestation: &attestation.GPUAttestation{}})
require.Error(err)
require.Empty(gated.GetProviders())
}
// Every read path answers "not initialized" once the VM has stopped, rather than
// a zero value a caller would read as an empty answer.
func TestTheSurfaceClosesOnShutdown(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
require.NoError(vm.SubmitTask(&aicore.Task{ID: "t1", Fee: 1_000_000_000_000_000}))
task, err := vm.GetTask("t1")
require.NoError(err)
require.Equal("t1", task.ID)
require.NoError(vm.SubmitResult(&aicore.TaskResult{TaskID: "t1", ProviderID: "p1"}))
require.NotNil(vm.GetStats())
require.NotNil(vm.GetModels())
_, err = vm.GetRewardStats("nobody")
require.Error(err)
// A provider with nothing owing claims zero rather than failing.
claimed, err := vm.ClaimRewards("nobody")
require.NoError(err)
require.Equal("0", claimed)
_, err = vm.VerifyGPUAttestation(&attestation.GPUAttestation{})
require.Error(err)
require.NoError(vm.Shutdown(ctx))
require.ErrorIs(vm.SubmitTask(&aicore.Task{ID: "t2"}), ErrNotInitialized)
require.ErrorIs(vm.SubmitResult(&aicore.TaskResult{TaskID: "t1"}), ErrNotInitialized)
require.ErrorIs(vm.RegisterProvider(&aicore.Provider{ID: "p"}), ErrNotInitialized)
_, err = vm.GetTask("t1")
require.ErrorIs(err, ErrNotInitialized)
_, err = vm.ClaimRewards("p")
require.ErrorIs(err, ErrNotInitialized)
_, err = vm.GetRewardStats("p")
require.ErrorIs(err, ErrNotInitialized)
_, err = vm.VerifyGPUAttestation(nil)
require.ErrorIs(err, ErrNotInitialized)
require.Nil(vm.GetProviders())
require.Nil(vm.GetModels())
require.Nil(vm.GetStats())
require.Equal([32]byte{}, vm.GetMerkleRoot())
_, err = vm.BuildBlock(ctx)
require.ErrorIs(err, ErrNotInitialized)
health, err := vm.HealthCheck(ctx)
require.NoError(err)
require.False(health.Healthy)
}
// A submission wakes the builder. Returning only on ctx.Done() would mean
// BuildBlock is never called and the chain never leaves genesis.
func TestASubmissionWakesTheBuilder(t *testing.T) {
require := require.New(t)
vm := oneVM(t)
require.NoError(vm.SubmitTask(&aicore.Task{ID: "t1", Fee: 1_000_000_000_000_000}))
_, err := vm.WaitForEvent(context.Background())
require.NoError(err)
// And a cancelled context releases the waiter rather than hanging.
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = vm.WaitForEvent(ctx)
require.Error(err)
}
func TestUnknownBlocksAndHeightsAreNotFound(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
_, err := vm.GetBlock(ctx, ids.GenerateTestID())
require.Error(err)
// A stored value that is not a block does not become one by being read.
stray := ids.GenerateTestID()
require.NoError(vm.db.Put(blockKey(stray), []byte("not a block")))
_, err = vm.GetBlock(ctx, stray)
require.Error(err)
require.NoError(vm.SetPreference(ctx, ids.GenerateTestID()))
require.NoError(vm.SetState(ctx, 1))
}
// -----------------------------------------------------------------------------
// The HTTP surface.
// -----------------------------------------------------------------------------
// call drives one endpoint through the handler the node would mount.
func call(t *testing.T, vm *VM, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
switch b := body.(type) {
case nil:
r = nil
case string:
r = bytes.NewBufferString(b)
default:
raw, err := json.Marshal(b)
require.NoError(t, err)
r = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, r)
w := httptest.NewRecorder()
h, err := vm.NewHTTPHandler(context.Background())
require.NoError(t, err)
h.ServeHTTP(w, req)
return w
}
// Each endpoint answers at its own mount, refuses the wrong method, and refuses
// a body it cannot read — rather than acting on a half-decoded request.
func TestEveryEndpointRefusesTheWrongMethodAndABadBody(t *testing.T) {
require := require.New(t)
vm := oneVM(t)
gets := []string{"/providers", "/tasks", "/models", "/rewards/stats", "/stats", "/merkle"}
for _, p := range gets {
require.Equal(http.StatusMethodNotAllowed, call(t, vm, http.MethodPost, p, nil).Code, p)
}
posts := []string{"/providers/register", "/tasks/submit", "/tasks/result", "/attestation/verify", "/rewards/claim"}
for _, p := range posts {
require.Equal(http.StatusMethodNotAllowed, call(t, vm, http.MethodGet, p, nil).Code, p)
require.Equal(http.StatusBadRequest, call(t, vm, http.MethodPost, p, "{not json").Code, p)
}
}
func TestTheEndpointsAnswer(t *testing.T) {
require := require.New(t)
vm := oneVM(t)
require.Equal(http.StatusOK, call(t, vm, http.MethodGet, "/health", nil).Code)
require.Equal(http.StatusOK, call(t, vm, http.MethodGet, "/providers", nil).Code)
require.Equal(http.StatusOK, call(t, vm, http.MethodGet, "/models", nil).Code)
require.Equal(http.StatusOK, call(t, vm, http.MethodGet, "/stats", nil).Code)
require.Equal(http.StatusOK, call(t, vm, http.MethodGet, "/merkle", nil).Code)
require.Equal(http.StatusOK, call(t, vm, http.MethodGet, "/tasks", nil).Code)
// Register, then submit a task and a result through the wire.
reg := call(t, vm, http.MethodPost, "/providers/register",
RegisterProviderRequest{ID: "p1", Endpoint: "https://gpu.example", GPUs: []aicore.GPUInfo{{Model: "gb10"}}})
require.Equal(http.StatusOK, reg.Code, reg.Body.String())
sub := call(t, vm, http.MethodPost, "/tasks/submit",
SubmitTaskRequest{ID: "t1", Type: string(aicore.TaskTypeInference), Model: "zen", Fee: 1_000_000_000_000_000})
require.Equal(http.StatusOK, sub.Code, sub.Body.String())
got := call(t, vm, http.MethodGet, "/tasks?id=t1", nil)
require.Equal(http.StatusOK, got.Code)
require.Contains(got.Body.String(), "t1")
require.Equal(http.StatusNotFound, call(t, vm, http.MethodGet, "/tasks?id=nope", nil).Code)
res := call(t, vm, http.MethodPost, "/tasks/result",
SubmitResultRequest{TaskID: "t1", ProviderID: "p1", Output: json.RawMessage(`{"a":1}`)})
require.Equal(http.StatusOK, res.Code, res.Body.String())
// A result for a task nobody submitted is refused.
require.Equal(http.StatusBadRequest,
call(t, vm, http.MethodPost, "/tasks/result", SubmitResultRequest{TaskID: "ghost"}).Code)
// A task below the fee floor is refused at admission.
require.Equal(http.StatusBadRequest,
call(t, vm, http.MethodPost, "/tasks/submit", SubmitTaskRequest{ID: "cheap", Fee: 0}).Code)
// Rewards: the provider did work, so its stats exist and a claim answers.
stats := call(t, vm, http.MethodGet, "/rewards/stats?provider_id=p1", nil)
require.Equal(http.StatusOK, stats.Code, stats.Body.String())
require.Equal(http.StatusBadRequest, call(t, vm, http.MethodGet, "/rewards/stats", nil).Code)
require.Equal(http.StatusNotFound, call(t, vm, http.MethodGet, "/rewards/stats?provider_id=ghost", nil).Code)
claim := call(t, vm, http.MethodPost, "/rewards/claim", map[string]string{"provider_id": "p1"})
require.Equal(http.StatusOK, claim.Code, claim.Body.String())
// An attestation that does not verify comes back as a refusal.
require.Equal(http.StatusBadRequest, call(t, vm, http.MethodPost, "/attestation/verify",
VerifyAttestationRequest{GPUAttestation: &attestation.GPUAttestation{}}).Code)
// A registration the policy refuses comes back as a refusal too.
gated := initWith(t, DefaultPermissionedConfig())
require.Equal(http.StatusBadRequest,
call(t, gated, http.MethodPost, "/providers/register", RegisterProviderRequest{ID: "p2"}).Code)
}
// A task with no name cannot be tracked or answered, so the core refuses it and
// the refusal reaches the caller instead of a silent enqueue.
func TestATaskWithNoNameIsRefused(t *testing.T) {
vm := oneVM(t)
require.ErrorContains(t, vm.SubmitTask(&aicore.Task{Fee: 1_000_000_000_000_000}), "task")
}
// Shutdown is idempotent and does not depend on the core being there.
func TestShutdownWithoutACore(t *testing.T) {
vm := &VM{running: true}
require.NoError(t, vm.Shutdown(context.Background()))
require.False(t, vm.live())
}
// The VM reports the policy it came up under, so an operator can read from the
// boot line whether the permissioned gate is on. A no-op logger reports nothing
// and the line is skipped; a real one has to carry it.
func TestInitializeReportsItsPolicy(t *testing.T) {
logger := log.NewTestLogger()
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: logger},
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 8),
Log: logger,
Genesis: []byte(`{"timestamp":0,"version":1,"message":""}`),
}))
t.Cleanup(func() { _ = vm.Shutdown(context.Background()) })
require.False(t, vm.log.IsZero())
require.True(t, vm.live())
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package aivm
// vertex_test.go covers the DAG side: how a vertex is built from pending work,
// named, encoded, and recorded.
import (
"context"
"encoding/json"
"testing"
"github.com/luxfi/consensus/core/choices"
"github.com/luxfi/consensus/engine/dag/vertex"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
aicore "github.com/luxfi/ai/pkg/aivm"
)
func TestAVertexBatchesPendingWork(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
// Nothing pending, nothing to batch.
_, err := vm.BuildVertex(ctx)
require.ErrorContains(err, "no pending tasks")
fee := uint64(1_000_000_000_000_000)
require.NoError(vm.SubmitTask(&aicore.Task{ID: "job-a", Fee: fee}))
require.NoError(vm.SubmitTask(&aicore.Task{ID: "job-b", Fee: fee}))
v, err := vm.BuildVertex(ctx)
require.NoError(err)
av := v.(*AIVertex)
require.Equal(uint64(1), av.Height())
require.Equal(uint32(0), av.Epoch())
require.Equal([]ids.ID{vm.lastAccepted.ID_}, av.Parents())
require.Len(av.Txs(), 2)
require.Len(av.jobIDs, 2)
require.Equal(choices.Processing, av.Status())
require.NotEqual(ids.Empty, av.ID())
require.NotEmpty(av.Bytes())
require.NoError(av.Verify(ctx))
// It round-trips through the wire and keeps its name.
parsed, err := vm.ParseVertex(ctx, av.Bytes())
require.NoError(err)
require.Equal(av.ID(), parsed.ID())
require.True(av.ConflictsVertex(parsed), "the same jobs must still conflict")
require.False(av.ConflictsVertex(&AIVertex{jobIDs: []string{"elsewhere"}}))
// A vertex is not a block: recording one stores it under its own prefix and
// leaves the block chain's head where it was.
head := vm.lastAccepted.ID_
require.NoError(av.Accept(ctx))
require.Equal(choices.Accepted, av.Status())
stored, err := vm.db.Get(vertexKey(av.ID()))
require.NoError(err)
require.Equal(av.Bytes(), stored)
require.Equal(head, vm.lastAccepted.ID_, "accepting a vertex moved the block chain's tip")
_, err = vm.db.Get(blockKey(av.ID()))
require.ErrorIs(err, database.ErrNotFound)
require.NoError(parsed.Reject(ctx))
require.Equal(choices.Rejected, parsed.(*AIVertex).Status())
}
// A vertex carrying work with no name is not verifiable: the job id is the
// conflict key, so a task without one conflicts with nothing and commutes with
// everything.
func TestAVertexRefusesUnnamedWork(t *testing.T) {
v := &AIVertex{tasks: []*aicore.Task{{ID: ""}}}
require.ErrorContains(t, v.Verify(context.Background()), "task missing ID")
}
// The same wire names different vertices on different chains, for the reason a
// block's id does: the chain id is hashed in and never encoded, so a peer cannot
// supply it.
func TestAVertexDoesNotCrossChains(t *testing.T) {
require := require.New(t)
ctx := context.Background()
here := oneVM(t)
elsewhere := oneVM(t)
require.NoError(here.SubmitTask(&aicore.Task{ID: "job-a", Fee: 1_000_000_000_000_000}))
v, err := here.BuildVertex(ctx)
require.NoError(err)
foreign, err := elsewhere.ParseVertex(ctx, v.Bytes())
require.NoError(err)
require.NotEqual(v.ID(), foreign.ID())
// Malformed bytes are refused rather than decoded into an empty vertex.
_, err = here.ParseVertex(ctx, []byte("not a vertex"))
require.Error(err)
}
func TestBuildVertexNeedsARunningChain(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
require.NoError(vm.Shutdown(ctx))
_, err := vm.BuildVertex(ctx)
require.ErrorIs(err, ErrNotInitialized)
// A VM with no tip has no parent to hang a vertex from.
bare := &VM{running: true, db: memdb.New()}
_, err = bare.BuildVertex(ctx)
require.ErrorContains(err, "no parent block")
}
// A vertex carrying work that cannot be encoded cannot be built or recorded:
// the id is derived from the encoding, so a half-written one names a different
// vertex.
func TestAVertexThatCannotBeEncodedIsRefused(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
require.NoError(vm.SubmitTask(&aicore.Task{
ID: "job-a", Fee: 1_000_000_000_000_000, Input: json.RawMessage(`{`),
}))
_, err := vm.BuildVertex(ctx)
require.ErrorContains(err, "tasks")
v := &AIVertex{height: 1, jobIDs: []string{"j"}, tasks: []*aicore.Task{{ID: "j", Input: json.RawMessage(`{`)}}, vm: vm}
require.ErrorContains(v.Accept(ctx), "tasks")
// Conflict detection is over vertices; anything else conflicts with nothing.
require.False(v.ConflictsVertex(notAVertex{}))
}
// notAVertex satisfies the engine's vertex interface without being one of ours.
type notAVertex struct{ vertex.Vertex }
+208 -241
View File
@@ -41,7 +41,7 @@ package aivm
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -49,14 +49,13 @@ import (
"sync"
"time"
"github.com/holiman/uint256"
"github.com/luxfi/accel"
"github.com/luxfi/database"
"github.com/luxfi/database/versiondb"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/timer/mockable"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
@@ -219,26 +218,40 @@ type VM struct {
// Core AI VM from luxfi/ai package
core *aivm.VM
// view is what an ACCEPTING block writes through: a versiondb over db,
// committed exactly once per block together with the block itself, its
// height entry and the tip pointer. It is empty at every other moment, which
// is what stops one block's writes riding out on another block's commit.
view *versiondb.Database
// A-Chain-native quorum settlement engine (the AI task quorum-settlement
// state machine: provider registry, stake/slash, selection, commit-reveal,
// settlement, receipts, and the cross-chain seam). State lives in qstate
// (DB-backed, committed under consensus); custody in qledger. quorum is the
// stateless handle bound to the deployment's chain ids.
// settlement, receipts, and the cross-chain seam). qstate is COMMITTED
// engine state; custody is in qledger. quorum is the stateless handle bound
// to the deployment's chain ids.
quorum *Engine
qstate QuorumState
qdb *versiondb.Database // engine-state staging layer over vm.db; committed at Block.Accept
qledger QuorumLedger
qledgerSnap map[common.Address]*uint256.Int // last committed ledger balances (for abort rollback)
ccv CCommitVerifier // proves a C intent is committed before it can create a task
pendingIntents []CIntent // committed intents buffered for consensus-gated import
ccv CCommitVerifier // proves a C intent is committed before it can create a task
pendingIntents []CIntent // committed intents buffered for consensus-gated import
// Attestation verifier (local nvtrust - no cloud dependency)
verifier *attestation.Verifier
// Block management
lastAcceptedID ids.ID
lastAccepted *Block
pendingBlocks map[ids.ID]*Block
// The chain: the accepted tip and the blocks verified above it. The tip is
// the ONE in-memory answer to "where is this chain" — it is read back from
// the same commit that wrote it, so a restart resumes rather than replaying
// from genesis.
lastAccepted *Block
flight map[ids.ID]*Block
// chainID names this chain. Block ids are derived under it and it never
// travels on the wire, so a block cannot be carried between chains.
chainID ids.ID
// clock is chain time as this node sees it. Mockable so a test can state
// what "now" is rather than racing it.
clock mockable.Clock
// Consensus
toEngine chan<- vmcore.Message
@@ -260,46 +273,36 @@ type VM struct {
running bool
}
// Block represents an AIVM block
type Block struct {
ID_ ids.ID `json:"id"`
ParentID_ ids.ID `json:"parentID"`
Height_ uint64 `json:"height"`
Timestamp_ time.Time `json:"timestamp"`
// Keys. Blocks, the height index and the tip pointer are the chain's own;
// engine slots live under av/state/ and cannot collide with these.
var (
tipKey = []byte("av/tip")
blockPrefix = []byte("av/block/")
heightPrefix = []byte("av/height/")
vertexPrefix = []byte("av/vertex/")
)
// AI-specific data
Tasks []aivm.Task `json:"tasks,omitempty"`
Results []aivm.TaskResult `json:"results,omitempty"`
MerkleRoot [32]byte `json:"merkleRoot"`
ProviderRegs []ProviderReg `json:"providerRegs,omitempty"`
// ImportedIntents are the committed C-Chain intents that this block turned
// into A-Chain quorum tasks (under consensus, via the verified inbound seam).
// Recorded so Block.Verify can deterministically re-run the same imports and
// every validator reaches identical engine state.
ImportedIntents []CIntent `json:"importedIntents,omitempty"`
// ReceiptRoot is the engine's committed receipt_root as of this block — the
// single commitment the A->C boundary exports.
ReceiptRoot common.Hash `json:"receiptRoot"`
bytes []byte
vm *VM
func blockKey(id ids.ID) []byte {
return append(append([]byte(nil), blockPrefix...), id[:]...)
}
// ProviderReg represents a provider registration in a block
type ProviderReg struct {
ProviderID string `json:"providerId"`
WalletAddress string `json:"walletAddress"`
Endpoint string `json:"endpoint"`
CPUAttestation *attestation.AttestationQuote `json:"cpuAttestation,omitempty"`
GPUAttestation *attestation.GPUAttestation `json:"gpuAttestation,omitempty"`
func heightKey(h uint64) []byte {
var u [8]byte
binary.BigEndian.PutUint64(u[:], h)
return append(append([]byte(nil), heightPrefix...), u[:]...)
}
func vertexKey(id ids.ID) []byte {
return append(append([]byte(nil), vertexPrefix...), id[:]...)
}
// 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")
var (
errRuntimeRequired = errors.New("aivm: Initialize requires a runtime")
errChainIDRequired = errors.New("aivm: Initialize requires a chain id")
)
func (vm *VM) Initialize(ctx context.Context, init vmcore.Init) error {
vm.rt = init.Runtime
@@ -312,6 +315,13 @@ func (vm *VM) Initialize(ctx context.Context, init vmcore.Init) error {
return errRuntimeRequired
}
vm.networkID = vm.rt.NetworkID
// Block ids are derived under the chain id. Without one every A-Chain
// deployment names its blocks identically and a block built on one is
// resolvable on the next.
vm.chainID = vm.rt.ChainID
if vm.chainID == ids.Empty {
return errChainIDRequired
}
if logger, ok := vm.rt.Log.(log.Logger); ok {
vm.log = logger
@@ -319,7 +329,7 @@ func (vm *VM) Initialize(ctx context.Context, init vmcore.Init) error {
return errors.New("invalid logger type")
}
vm.pendingBlocks = make(map[ids.ID]*Block)
vm.flight = make(map[ids.ID]*Block)
// Parse configuration
if len(init.Config) > 0 {
@@ -346,7 +356,7 @@ func (vm *VM) Initialize(ctx context.Context, init vmcore.Init) error {
// inbound C-intent seam defaults to a FAIL-CLOSED verifier — until a real
// CCommitVerifier is installed (SetCommitVerifier), NO intent is treated as
// committed and no task can be created from the boundary.
vm.initQuorum(nil)
vm.initQuorum()
if vm.ccv == nil {
vm.ccv = VerifierFunc(func(CIntent) error { return ErrIntentNotCommitted })
}
@@ -367,17 +377,22 @@ func (vm *VM) Initialize(ctx context.Context, init vmcore.Init) error {
return fmt.Errorf("failed to start core AI VM: %w", err)
}
// Create genesis block
// Where this chain is. A fresh chain starts at genesis; one that has run
// before resumes at the tip its last Accept committed.
genesisBlock := &Block{
ID_: ids.Empty,
ParentID_: ids.Empty,
Height_: 0,
Timestamp_: time.Unix(genesis.Timestamp, 0),
vm: vm,
}
genesisBlock.ID_ = genesisBlock.computeID()
vm.lastAcceptedID = genesisBlock.ID_
vm.lastAccepted = genesisBlock
if err := genesisBlock.name(); err != nil {
return fmt.Errorf("aivm: encode genesis: %w", err)
}
tip, err := vm.openTip(genesisBlock)
if err != nil {
return err
}
vm.lastAccepted = tip
vm.running = true
if !vm.log.IsZero() {
@@ -397,6 +412,19 @@ type Genesis struct {
Timestamp int64 `json:"timestamp"`
}
// live reports whether the VM is running.
//
// It is a METHOD because `running` is written under vm.mu by Shutdown, and a
// read of it that does not take the lock is a data race — one the race detector
// reports on the first concurrent request. Eight read paths took that shortcut,
// so the fix is one reader that all of them use. The paths that already hold the
// lock read the field directly; taking it twice would deadlock.
func (vm *VM) live() bool {
vm.mu.RLock()
defer vm.mu.RUnlock()
return vm.running
}
// Shutdown shuts down the VM
func (vm *VM) Shutdown(ctx context.Context) error {
vm.mu.Lock()
@@ -495,7 +523,7 @@ func (vm *VM) RegisterProvider(provider *aivm.Provider) error {
// VerifyGPUAttestation verifies GPU attestation (local nvtrust - no cloud)
func (vm *VM) VerifyGPUAttestation(att *attestation.GPUAttestation) (*attestation.DeviceStatus, error) {
if !vm.running {
if !vm.live() {
return nil, ErrNotInitialized
}
return vm.verifier.VerifyGPUAttestation(att)
@@ -531,7 +559,7 @@ func (vm *VM) SubmitTask(task *aivm.Task) error {
// GetTask returns a task by ID
func (vm *VM) GetTask(taskID string) (*aivm.Task, error) {
if !vm.running {
if !vm.live() {
return nil, ErrNotInitialized
}
return vm.core.GetTask(taskID)
@@ -555,7 +583,7 @@ func (vm *VM) SubmitResult(result *aivm.TaskResult) error {
// GetProviders returns all registered providers
func (vm *VM) GetProviders() []*aivm.Provider {
if !vm.running {
if !vm.live() {
return nil
}
return vm.core.GetProviders()
@@ -563,7 +591,7 @@ func (vm *VM) GetProviders() []*aivm.Provider {
// GetModels returns available AI models
func (vm *VM) GetModels() []*aivm.ModelInfo {
if !vm.running {
if !vm.live() {
return nil
}
return vm.core.GetModels()
@@ -571,7 +599,7 @@ func (vm *VM) GetModels() []*aivm.ModelInfo {
// GetStats returns VM statistics
func (vm *VM) GetStats() map[string]interface{} {
if !vm.running {
if !vm.live() {
return nil
}
return vm.core.GetStats()
@@ -579,7 +607,7 @@ func (vm *VM) GetStats() map[string]interface{} {
// GetMerkleRoot returns merkle root for Q-Chain anchoring
func (vm *VM) GetMerkleRoot() [32]byte {
if !vm.running {
if !vm.live() {
return [32]byte{}
}
return vm.core.GetMerkleRoot()
@@ -587,7 +615,7 @@ func (vm *VM) GetMerkleRoot() [32]byte {
// ClaimRewards claims pending rewards for a provider
func (vm *VM) ClaimRewards(providerID string) (string, error) {
if !vm.running {
if !vm.live() {
return "", ErrNotInitialized
}
return vm.core.ClaimRewards(providerID)
@@ -595,7 +623,7 @@ func (vm *VM) ClaimRewards(providerID string) (string, error) {
// GetRewardStats returns reward statistics for a provider
func (vm *VM) GetRewardStats(providerID string) (map[string]interface{}, error) {
if !vm.running {
if !vm.live() {
return nil, ErrNotInitialized
}
return vm.core.GetRewardStats(providerID)
@@ -611,7 +639,16 @@ func (vm *VM) SetState(ctx context.Context, state uint32) error {
return nil
}
// BuildBlock implements chain.ChainVM interface
// BuildBlock proposes the next block. It DECIDES what the block carries and
// changes nothing: the candidate intents are applied to an overlay over
// committed state and a clone of the ledger, both discarded here, and what
// survives is the list of intents that imported cleanly plus the receipt root
// they reach. Every validator, including this one at Accept, re-derives that
// root from the same inputs.
//
// The buffered intents are NOT drained. A proposal that loses the round has
// consumed nothing, so the next proposer can still carry the same work; draining
// at build time destroyed it.
func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
@@ -619,89 +656,85 @@ func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
if !vm.running {
return nil, ErrNotInitialized
}
parent := vm.lastAccepted
if parent == nil {
return nil, errors.New("no parent block")
return nil, errors.New("aivm: no parent block")
}
// Chain time never runs backwards, so a proposer whose clock has not moved
// past its parent stamps the parent's time rather than building a block its
// own Verify would refuse.
stamp := vm.clock.Time()
if !stamp.After(parent.Timestamp_) {
stamp = parent.Timestamp_
}
height := parent.Height_ + 1
scratch := newOverlay(vm.qstate)
carried, root := vm.pick(scratch, newStateLedger(scratch), height)
// Snapshot the ledger so that if this proposed block is never accepted, the
// engine-state delta (staged in vm.qdb) is aborted AND the ledger is rolled
// back — no value or state moves outside consensus.
vm.snapshotLedger()
// Drain buffered committed C-Chain intents into A-Chain quorum tasks UNDER
// CONSENSUS (this is the block-build path). importPending is the only caller
// of the engine's verified inbound seam; a live request can never reach it.
// Writes land in the staging versiondb, not the durable DB.
imported := vm.importPending(height)
// Give a verdict to every task whose reveal window has closed. This is what
// pays operators: without it a task sits revealed and unsettled forever, and
// the work the network did for it is never credited to anyone.
vm.settleDue(height)
// Create new block, recording the imported intents and the resulting
// receipt_root so Verify can re-derive identical engine state on every node.
blk := &Block{
ParentID_: parent.ID_,
Height_: height,
Timestamp_: time.Now(),
ImportedIntents: imported,
Timestamp_: stamp,
ImportedIntents: carried,
ReceiptRoot: root,
vm: vm,
}
if vm.quorum != nil && vm.qstate != nil {
blk.ReceiptRoot = vm.quorum.ReceiptRoot(vm.qstate)
}
blk.ID_ = blk.computeID()
vm.pendingBlocks[blk.ID_] = blk
return blk, nil
}
// ParseBlock implements chain.ChainVM interface
func (vm *VM) ParseBlock(ctx context.Context, bytes []byte) (chain.Block, error) {
blk := &Block{vm: vm}
if err := parseBlock(bytes, blk); err != nil {
if err := blk.name(); err != nil {
return nil, err
}
blk.ID_ = blk.computeID()
blk.bytes = bytes
// A block this node builds is held to the size a block off the wire is held
// to, so a proposer cannot produce one its own peers refuse to parse.
if n := len(blk.bytes); n > MaxBlockSize {
return nil, fmt.Errorf("%w: %d bytes exceeds %d", ErrInvalidBlock, n, MaxBlockSize)
}
vm.track(blk)
return blk, nil
}
// GetBlock implements chain.ChainVM interface
// ParseBlock decodes a block off the wire. The size bound is checked BEFORE the
// decode, so the work a peer can make this node do is bounded by a number rather
// than by the peer.
func (vm *VM) ParseBlock(ctx context.Context, wire []byte) (chain.Block, error) {
if n := len(wire); n > MaxBlockSize {
return nil, fmt.Errorf("%w: %d bytes exceeds %d", ErrInvalidBlock, n, MaxBlockSize)
}
return vm.parseBlock(wire)
}
// parseBlock decodes wire into a Block belonging to this chain, and names it.
// The name is derived from the block's own canonical encoding rather than from
// the bytes handed over, so a buffer carrying padding the decoder ignores can
// never be stored under the id of the canonical one.
func (vm *VM) parseBlock(wire []byte) (*Block, error) {
blk := &Block{vm: vm}
if err := parseBlock(wire, blk); err != nil {
return nil, err
}
if err := blk.name(); err != nil {
return nil, err
}
return blk, nil
}
// GetBlock returns a block by id: one in flight, the tip, or one read back from
// committed state.
func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (chain.Block, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
// Check pending blocks (nil-safe for early calls before initialization)
if vm.pendingBlocks != nil {
if blk, exists := vm.pendingBlocks[id]; exists {
return blk, nil
}
if blk, ok := vm.flight[id]; ok {
return blk, nil
}
// Check if it's the last accepted
if vm.lastAccepted != nil && vm.lastAccepted.ID_ == id {
return vm.lastAccepted, nil
}
// Try to get from database
bytes, err := vm.db.Get(id[:])
wire, err := vm.db.Get(blockKey(id))
if err != nil {
return nil, err
}
blk := &Block{vm: vm}
if err := parseBlock(bytes, blk); err != nil {
return nil, err
}
blk.ID_ = id
blk.bytes = bytes
return blk, nil
return vm.parseBlock(wire)
}
// SetPreference implements chain.ChainVM interface
@@ -714,13 +747,65 @@ func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error {
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
return vm.lastAcceptedID, nil
if vm.lastAccepted == nil {
return ids.Empty, ErrNotInitialized
}
return vm.lastAccepted.ID_, nil
}
// GetBlockIDAtHeight implements chain.ChainVM interface
// GetBlockIDAtHeight answers from the height index Accept writes in the same
// commit as the block itself, so the index cannot name a block the chain did not
// accept.
func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
// For now, return error - would need height index for full implementation
return ids.Empty, errors.New("height index not implemented")
vm.mu.RLock()
defer vm.mu.RUnlock()
raw, err := vm.db.Get(heightKey(height))
if err != nil {
return ids.Empty, fmt.Errorf("aivm: height %d: %w", height, err)
}
return ids.ToID(raw)
}
// openTip reads back where this chain got to.
//
// Only an ABSENT tip means a fresh chain. Reading any other failure that way — a
// closed database, an unreadable volume, a short read — starts a live chain over
// at genesis and lets it build height 1 on top of state it cannot see, durably.
// A chain that cannot read its own tip does not know where it is, and the honest
// answer is to refuse to open.
func (vm *VM) openTip(genesis *Block) (*Block, error) {
raw, err := vm.db.Get(tipKey)
switch {
case errors.Is(err, database.ErrNotFound):
return genesis, nil
case err != nil:
return nil, fmt.Errorf("aivm: read tip: %w", err)
}
// ToID refuses anything that is not an id, which is the whole of what can be
// wrong with these bytes — checking the length first and then converting made
// the conversion's own error a branch nothing could take.
id, err := ids.ToID(raw)
if err != nil {
return nil, fmt.Errorf("aivm: tip: %w", err)
}
if id == genesis.ID_ {
return genesis, nil
}
wire, err := vm.db.Get(blockKey(id))
if err != nil {
return nil, fmt.Errorf("aivm: tip %s: %w", id, err)
}
blk, err := vm.parseBlock(wire)
if err != nil {
return nil, err
}
// The tip was named under this chain id when it was written. A different id
// here means the database belongs to another chain, which is not a chain to
// resume — it is one to refuse.
if blk.ID_ != id {
return nil, fmt.Errorf("aivm: stored tip names %s, its bytes name %s", id, blk.ID_)
}
return blk, nil
}
// NewHTTPHandler implements chain.ChainVM interface
@@ -755,125 +840,7 @@ func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
// HealthCheck implements chain.ChainVM interface
func (vm *VM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
return chain.HealthResult{
Healthy: vm.running,
Healthy: vm.live(),
Details: map[string]string{"status": "operational"},
}, nil
}
// =============================================================================
// Block Methods (implements chain.Block interface)
// =============================================================================
// computeID computes the block ID from its canonical ZAP wire (ID_ excluded).
func (blk *Block) computeID() ids.ID {
bytes, _ := blk.Marshal()
hash := sha256.Sum256(bytes)
return ids.ID(hash)
}
// ID returns the block ID
func (blk *Block) ID() ids.ID {
return blk.ID_
}
// Parent returns the parent block ID
func (blk *Block) Parent() ids.ID {
return blk.ParentID_
}
// ParentID returns the parent block ID
func (blk *Block) ParentID() ids.ID {
return blk.ParentID_
}
// Height returns the block height
func (blk *Block) Height() uint64 {
return blk.Height_
}
// Timestamp returns the block timestamp
func (blk *Block) Timestamp() time.Time {
return blk.Timestamp_
}
// Status returns the block status
func (blk *Block) Status() uint8 {
return 0 // Processing
}
// Verify verifies the block. For A-Chain quorum, this deterministically re-runs
// the committed C-Chain intents the proposer recorded (under consensus) and
// checks they reproduce the proposer's receipt_root — so every validator reaches
// identical engine state from the same committed inputs. A block that records an
// intent which fails id binding / committedness / replay, or whose imports do
// not reproduce the recorded receipt_root, is rejected.
func (blk *Block) Verify(ctx context.Context) error {
if blk.vm == nil {
return nil
}
blk.vm.mu.Lock()
defer blk.vm.mu.Unlock()
if len(blk.ImportedIntents) == 0 && blk.ReceiptRoot == (common.Hash{}) {
return nil
}
// Snapshot the ledger before applying the recorded imports into the staging
// layer, so a block that verifies but is later rejected (lost the round) rolls
// back both engine state and ledger value via abortEngine.
blk.vm.snapshotLedger()
return blk.vm.verifyImported(blk.Height_, blk.ImportedIntents, blk.ReceiptRoot)
}
// Accept accepts the block. This is the SOLE commit point for engine state: the
// staged engine-state delta (importPending/Settle writes accumulated in vm.qdb)
// is flushed to the durable DB here, and the ledger snapshot is cleared. A block
// that is built/verified but never accepted moves NO durable engine state and NO
// value (see Block.Reject -> abortEngine).
func (blk *Block) Accept(ctx context.Context) error {
blk.vm.mu.Lock()
defer blk.vm.mu.Unlock()
// Commit the staged engine state FIRST. If this fails the block is not made
// last-accepted, so consensus surfaces the fault rather than diverging.
if err := blk.vm.commitEngine(); err != nil {
return err
}
// Store the block bytes (commitEngine already flushed engine slots; the block
// key is disjoint from the av/state/ keyspace).
bytes, err := blk.Marshal()
if err != nil {
return err
}
if err := blk.vm.db.Put(blk.ID_[:], bytes); err != nil {
return err
}
// Update last accepted
blk.vm.lastAcceptedID = blk.ID_
blk.vm.lastAccepted = blk
// Remove from pending
delete(blk.vm.pendingBlocks, blk.ID_)
return nil
}
// Reject rejects the block and rolls back any engine-state delta + ledger value
// it staged during BuildBlock/Verify, so a block that loses the round leaves zero
// durable side effects (no funds pulled, no intent consumed, no task orphaned).
func (blk *Block) Reject(ctx context.Context) error {
blk.vm.mu.Lock()
defer blk.vm.mu.Unlock()
blk.vm.abortEngine()
delete(blk.vm.pendingBlocks, blk.ID_)
return nil
}
// Bytes returns the serialized block
func (blk *Block) Bytes() []byte {
if blk.bytes == nil {
blk.bytes, _ = blk.Marshal()
}
return blk.bytes
}
+31 -14
View File
@@ -107,7 +107,7 @@ func TestGenesisJSON(t *testing.T) {
require.Equal(g.Timestamp, parsed.Timestamp)
}
func TestBlockComputeID(t *testing.T) {
func TestBlockName(t *testing.T) {
require := require.New(t)
blk := &Block{
@@ -115,13 +115,14 @@ func TestBlockComputeID(t *testing.T) {
Height_: 1,
Timestamp_: time.Unix(1700000000, 0),
}
require.NoError(blk.name())
require.NotEqual(ids.Empty, blk.ID_)
require.NotEmpty(blk.bytes)
id := blk.computeID()
require.NotEqual(ids.Empty, id)
// Same block data → same ID (deterministic).
id2 := blk.computeID()
require.Equal(id, id2)
// Naming is deterministic.
id := blk.ID_
require.NoError(blk.name())
require.Equal(id, blk.ID_)
// Different height → different ID.
blk2 := &Block{
@@ -129,7 +130,8 @@ func TestBlockComputeID(t *testing.T) {
Height_: 2,
Timestamp_: time.Unix(1700000000, 0),
}
require.NotEqual(id, blk2.computeID())
require.NoError(blk2.name())
require.NotEqual(id, blk2.ID_)
}
func TestBlockInterface(t *testing.T) {
@@ -143,20 +145,28 @@ func TestBlockInterface(t *testing.T) {
Height_: 42,
Timestamp_: now,
}
blk.ID_ = blk.computeID()
require.NoError(blk.name())
require.Equal(parentID, blk.Parent())
require.Equal(parentID, blk.ParentID())
require.Equal(uint64(42), blk.Height())
require.Equal(now, blk.Timestamp())
require.NotNil(blk.Bytes())
require.Equal(uint8(0), blk.Status(), "a block with no chain is not accepted anywhere")
}
func TestBlockVerify(t *testing.T) {
// A block with no chain behind it has no parent to sit on, no state to check
// against and no chain id to be named by. Every question Verify asks of it is
// unanswerable, and it used to answer "no objection" to all of them — so a
// hand-built Block satisfied the one gate consensus has.
func TestADetachedBlockIsRefused(t *testing.T) {
require := require.New(t)
ctx := context.Background()
blk := &Block{Height_: 1, Timestamp_: time.Now()}
require.NoError(blk.Verify(context.Background()))
require.ErrorIs(blk.Verify(ctx), ErrDetachedBlock)
require.ErrorIs(blk.Accept(ctx), ErrDetachedBlock)
require.ErrorIs(blk.Reject(ctx), ErrDetachedBlock)
}
func TestVMNotInitialized(t *testing.T) {
@@ -211,12 +221,19 @@ func TestVMHealthCheck(t *testing.T) {
func TestVMLastAccepted(t *testing.T) {
require := require.New(t)
expectedID := ids.GenerateTestID()
vm := &VM{lastAcceptedID: expectedID}
// A VM that has not opened a chain has no head to report, and reporting one
// anyway is how a caller builds on a block that does not exist.
vm := &VM{}
_, err := vm.LastAccepted(context.Background())
require.ErrorIs(err, ErrNotInitialized)
tip := &Block{Height_: 7}
require.NoError(tip.name())
vm.lastAccepted = tip
id, err := vm.LastAccepted(context.Background())
require.NoError(err)
require.Equal(expectedID, id)
require.Equal(tip.ID_, id)
}
func TestVMSetState(t *testing.T) {
+98 -50
View File
@@ -100,9 +100,18 @@ const (
// canonical wire. blockID = sha256(this).
func (blk *Block) Marshal() ([]byte, error) {
intentLens, intentBlob := packObjs(blk.ImportedIntents, marshalCIntent)
taskLens, taskBlob := packJSON(blk.Tasks)
resLens, resBlob := packJSON(blk.Results)
pregLens, pregBlob := packJSON(blk.ProviderRegs)
taskLens, taskBlob, err := packJSON(blk.Tasks)
if err != nil {
return nil, fmt.Errorf("aivm block: tasks: %w", err)
}
resLens, resBlob, err := packJSON(blk.Results)
if err != nil {
return nil, fmt.Errorf("aivm block: results: %w", err)
}
pregLens, pregBlob, err := packJSON(blk.ProviderRegs)
if err != nil {
return nil, fmt.Errorf("aivm block: provider registrations: %w", err)
}
b := zap.NewBuilder(zap.HeaderSize + blkSize + len(intentBlob) + len(taskBlob) +
len(resBlob) + len(pregBlob) +
@@ -271,12 +280,18 @@ const (
// marshalVertex encodes the vertex's structural + payload fields (excluding the
// derived id, the local status, and the runtime vm/bytes fields).
func marshalVertex(v *AIVertex) []byte {
func marshalVertex(v *AIVertex) ([]byte, error) {
parents := concatIDs(v.parents)
txIDs := concatIDs(v.txIDs)
jobLens, jobBlob := packStrings(v.jobIDs)
taskLens, taskBlob := packJSON(v.tasks)
resLens, resBlob := packJSON(v.results)
taskLens, taskBlob, err := packJSON(v.tasks)
if err != nil {
return nil, fmt.Errorf("aivm vertex: tasks: %w", err)
}
resLens, resBlob, err := packJSON(v.results)
if err != nil {
return nil, fmt.Errorf("aivm vertex: results: %w", err)
}
b := zap.NewBuilder(zap.HeaderSize + vxSize + len(parents) + len(txIDs) +
len(jobBlob) + len(taskBlob) + len(resBlob) +
@@ -298,7 +313,7 @@ func marshalVertex(v *AIVertex) []byte {
ob.SetList(vxResLens, resLensOff, len(resLens))
ob.SetBytes(vxResBlob, resBlob)
ob.FinishAsRoot()
return b.Finish()
return b.Finish(), nil
}
// parseVertex fills the wire fields of v. The caller sets the derived id and the
@@ -317,9 +332,15 @@ func parseVertex(data []byte, v *AIVertex) error {
}
v.height = o.Uint64(vxHeight)
v.epoch = o.Uint32(vxEpoch)
v.parents = splitIDs(o.Bytes(vxParents))
v.txIDs = splitIDs(o.Bytes(vxTxIDs))
v.jobIDs = unpackStrings(readU32List(o, vxJobLens), o.Bytes(vxJobBlob))
if v.parents, err = splitIDs(o.Bytes(vxParents), "parent list"); err != nil {
return err
}
if v.txIDs, err = splitIDs(o.Bytes(vxTxIDs), "tx list"); err != nil {
return err
}
if v.jobIDs, err = unpackStrings(readU32List(o, vxJobLens), o.Bytes(vxJobBlob)); err != nil {
return err
}
if v.tasks, err = unpackJSON[*aicore.Task](readU32List(o, vxTaskLens), o.Bytes(vxTaskBlob)); err != nil {
return err
}
@@ -375,58 +396,83 @@ func packObjs[T any](items []T, marshal func(T) []byte) (lens []uint32, blob []b
return lens, blob
}
// unpackObjs re-splits a packed blob by lengths and parses each sub-object.
func unpackObjs[T any](lens []uint32, blob []byte, parse func([]byte) (T, error)) ([]T, error) {
if len(lens) == 0 {
return nil, nil
}
out := make([]T, 0, len(lens))
// span walks the declared lengths over a packed blob, handing each element's
// slice to visit, and requires the lengths to account for the blob EXACTLY.
//
// The exactness is the point. A decoder that stops when the lengths run out
// ignores whatever follows, so two different buffers decode to the same value —
// and since a block is named by its own canonical re-encoding, the padded one is
// stored and served under the canonical one's id. Four stray bytes appended to an
// intent blob were read as a well-formed block.
func span(lens []uint32, blob []byte, what string, visit func(i int, elem []byte) error) error {
pos := 0
for i, l := range lens {
if pos+int(l) > len(blob) {
return nil, fmt.Errorf("aivm: packed obj %d out of bounds", i)
return fmt.Errorf("aivm: %s %d runs %d bytes past the blob", what, i, pos+int(l)-len(blob))
}
v, err := parse(blob[pos : pos+int(l)])
if err := visit(i, blob[pos:pos+int(l)]); err != nil {
return err
}
pos += int(l)
}
if pos != len(blob) {
return fmt.Errorf("aivm: %d bytes past the last %s", len(blob)-pos, what)
}
return nil
}
// unpackObjs re-splits a packed blob by lengths and parses each sub-object.
func unpackObjs[T any](lens []uint32, blob []byte, parse func([]byte) (T, error)) ([]T, error) {
out := make([]T, 0, len(lens))
err := span(lens, blob, "packed object", func(_ int, elem []byte) error {
v, err := parse(elem)
if err != nil {
return nil, err
return err
}
out = append(out, v)
pos += int(l)
return nil
})
if err != nil || len(out) == 0 {
return nil, err
}
return out, nil
}
// packJSON marshals each item to canonical JSON and returns (lengths, concat
// blob). Used only for irreducibly-dynamic payloads (see file header).
func packJSON[T any](items []T) (lens []uint32, blob []byte) {
//
// A marshal failure is REPORTED. Dropping it wrote a zero-length element, so an
// item the encoder could not represent silently became no item at all — and the
// block's id, computed over that encoding, was the id of a block missing work
// its author believed it carried.
func packJSON[T any](items []T) (lens []uint32, blob []byte, err error) {
if len(items) == 0 {
return nil, nil
return nil, nil, nil
}
lens = make([]uint32, len(items))
for i, it := range items {
m, _ := json.Marshal(it)
m, err := json.Marshal(it)
if err != nil {
return nil, nil, fmt.Errorf("element %d: %w", i, err)
}
lens[i] = uint32(len(m))
blob = append(blob, m...)
}
return lens, blob
return lens, blob, nil
}
func unpackJSON[T any](lens []uint32, blob []byte) ([]T, error) {
if len(lens) == 0 {
return nil, nil
}
out := make([]T, 0, len(lens))
pos := 0
for i, l := range lens {
if pos+int(l) > len(blob) {
return nil, fmt.Errorf("aivm: json sub-blob %d out of bounds", i)
}
err := span(lens, blob, "json element", func(_ int, elem []byte) error {
var v T
if err := json.Unmarshal(blob[pos:pos+int(l)], &v); err != nil {
return nil, err
if err := json.Unmarshal(elem, &v); err != nil {
return err
}
out = append(out, v)
pos += int(l)
return nil
})
if err != nil || len(out) == 0 {
return nil, err
}
return out, nil
}
@@ -444,20 +490,16 @@ func packStrings(ss []string) ([]uint32, []byte) {
return lens, blob
}
func unpackStrings(lens []uint32, blob []byte) []string {
if len(lens) == 0 {
return nil
}
func unpackStrings(lens []uint32, blob []byte) ([]string, error) {
out := make([]string, 0, len(lens))
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
break
}
out = append(out, string(blob[pos:pos+int(l)]))
pos += int(l)
err := span(lens, blob, "string", func(_ int, elem []byte) error {
out = append(out, string(elem))
return nil
})
if err != nil || len(out) == 0 {
return nil, err
}
return out
return out, nil
}
func concatIDs(list []ids.ID) []byte {
@@ -471,14 +513,20 @@ func concatIDs(list []ids.ID) []byte {
return out
}
func splitIDs(blob []byte) []ids.ID {
// splitIDs re-splits a concatenation of 32-byte ids, and refuses a length that
// is not a whole number of them. Rounding down discarded a short tail, so a
// buffer with bytes the decoder never read still decoded.
func splitIDs(blob []byte, what string) ([]ids.ID, error) {
if len(blob)%32 != 0 {
return nil, fmt.Errorf("aivm: %s is %d bytes, not a multiple of 32", what, len(blob))
}
n := len(blob) / 32
if n == 0 {
return nil
return nil, nil
}
out := make([]ids.ID, n)
for i := 0; i < n; i++ {
copy(out[i][:], blob[i*32:(i+1)*32])
}
return out
return out, nil
}
+218 -5
View File
@@ -4,6 +4,7 @@
package aivm
import (
"context"
"encoding/json"
"testing"
"time"
@@ -11,6 +12,7 @@ import (
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
"github.com/stretchr/testify/require"
aicore "github.com/luxfi/ai/pkg/aivm"
@@ -99,8 +101,12 @@ func TestBlockWireRoundTrip(t *testing.T) {
require.Equal(orig.Tasks[0].ID, got.Tasks[0].ID)
require.JSONEq(string(orig.Tasks[0].Input), string(got.Tasks[0].Input))
// block id is stable across the wire round-trip (parse∘marshal is byte-stable)
require.Equal(orig.computeID(), got.computeID(), "block id stable across round-trip")
// The name is a function of the encoding, so parse∘marshal being byte-stable
// is what keeps a block's id stable across the wire.
require.NoError(orig.name())
require.NoError(got.name())
require.Equal(orig.bytes, got.bytes, "re-encoding a parsed block reproduces the wire")
require.Equal(orig.ID_, got.ID_, "block id stable across round-trip")
}
func TestBlockWireEmpty(t *testing.T) {
@@ -114,7 +120,9 @@ func TestBlockWireEmpty(t *testing.T) {
require.NoError(parseBlock(wire, got))
require.Equal(orig.Height_, got.Height_)
require.Empty(got.ImportedIntents)
require.Equal(orig.computeID(), got.computeID())
require.NoError(orig.name())
require.NoError(got.name())
require.Equal(orig.ID_, got.ID_)
}
func TestVertexWireRoundTrip(t *testing.T) {
@@ -129,7 +137,8 @@ func TestVertexWireRoundTrip(t *testing.T) {
}
orig.id = orig.computeID()
wire := marshalVertex(orig)
wire, err := marshalVertex(orig)
require.NoError(err)
got := &AIVertex{}
require.NoError(parseVertex(wire, got))
@@ -150,7 +159,8 @@ func TestWireKindGuards(t *testing.T) {
blk := &Block{ParentID_: ids.Empty, Height_: 1, Timestamp_: time.Unix(1, 0).UTC()}
blkWire, err := blk.Marshal()
require.NoError(err)
vtxWire := marshalVertex(&AIVertex{height: 1, jobIDs: []string{"j"}})
vtxWire, err := marshalVertex(&AIVertex{height: 1, jobIDs: []string{"j"}})
require.NoError(err)
// cross-parsing the wrong kind is rejected (defense-in-depth discriminator)
require.Error(parseBlock(vtxWire, &Block{}), "vertex bytes must not parse as a block")
@@ -160,3 +170,206 @@ func TestWireKindGuards(t *testing.T) {
require.Error(parseBlock(append(append([]byte(nil), blkWire...), 0x00), &Block{}))
require.Error(parseVertex(append(append([]byte(nil), vtxWire...), 0x00), &AIVertex{}))
}
// The decoder walked the declared lengths and stopped, ignoring whatever
// followed. Two different buffers therefore decoded to the same value — and
// since a block is named by its own canonical re-encoding, the padded one was
// accepted and would have been served under the canonical one's id.
func TestBytesPastTheDeclaredLengthsAreRefused(t *testing.T) {
require := require.New(t)
in := CIntent{IntentID: hashOf(1), Fee: uint256.NewInt(1), RewardPerOperator: uint256.NewInt(1)}
one := marshalCIntent(in)
wire := func(blob []byte) []byte {
b := zap.NewBuilder(zap.HeaderSize + blkSize + len(blob) + 256)
lensOff := writeU32List(b, []uint32{uint32(len(one))})
ob := b.StartObject(blkSize)
ob.SetUint8(blkKind, uint8(kindBlock))
ob.SetUint64(blkHeight, 3)
ob.SetList(blkIntentLens, lensOff, 1)
ob.SetBytes(blkIntentBlob, blob)
ob.FinishAsRoot()
return b.Finish()
}
// The honest encoding of the same object still parses.
require.NoError(parseBlock(wire(one), &Block{}))
// Four stray bytes past the one declared length do not.
padded := append(append([]byte(nil), one...), 0xDE, 0xAD, 0xBE, 0xEF)
require.ErrorContains(parseBlock(wire(padded), &Block{}), "past the last")
// So does a length running past the end of the blob it indexes.
require.ErrorContains(parseBlock(wire(one[:len(one)-4]), &Block{}), "past the blob")
}
// splitIDs rounded the blob length down to a whole number of ids, so a tail
// shorter than one id was discarded and a buffer with bytes the decoder never
// read still decoded.
func TestAShortTailInAnIDListIsRefused(t *testing.T) {
require := require.New(t)
v := &AIVertex{height: 3, parents: []ids.ID{ids.GenerateTestID()}, jobIDs: []string{"j"}}
wire, err := marshalVertex(v)
require.NoError(err)
require.NoError(parseVertex(wire, &AIVertex{}))
ids17 := make([]byte, 33) // one id plus one byte
_, err = splitIDs(ids17, "parent list")
require.ErrorContains(err, "not a multiple of 32")
got, err := splitIDs(nil, "parent list")
require.NoError(err)
require.Nil(got)
}
// A block arriving off the wire used to have no size bound at all: a 49 MB
// buffer of nothing but repeated provider registrations was decoded without
// complaint, so a peer chose how much memory this node allocated.
func TestAnOversizedBlockIsRefused(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := oneVM(t)
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(err)
require.Greater(len(wire), MaxBlockSize)
_, err = vm.ParseBlock(ctx, wire)
require.ErrorIs(err, ErrInvalidBlock)
// The bound is checked before the decode, so it holds whatever the bytes
// are — including a buffer that is not a block at all.
_, err = vm.ParseBlock(ctx, make([]byte, MaxBlockSize+1))
require.ErrorIs(err, ErrInvalidBlock)
// A block this node builds is held to the same bound, so a proposer cannot
// produce one its own peers refuse to parse.
oversize := &Block{ParentID_: vm.lastAccepted.ID_, Height_: 1, Timestamp_: vm.clock.Time(), ProviderRegs: big, vm: vm}
require.NoError(oversize.name())
require.ErrorIs(oversize.Verify(ctx), ErrInvalidBlock)
}
// An element the encoder cannot represent used to become no element at all: the
// marshal error was dropped, a zero length was written, and the block's id was
// computed over an encoding missing work its author believed it carried.
func TestAnUnencodableElementIsReported(t *testing.T) {
require := require.New(t)
blk := &Block{Height_: 1, Tasks: []aicore.Task{{ID: "t", Input: json.RawMessage(`{`)}}}
_, err := blk.Marshal()
require.ErrorContains(err, "tasks")
v := &AIVertex{height: 1, results: []*aicore.TaskResult{{TaskID: "t", Output: json.RawMessage(`}`)}}}
_, err = marshalVertex(v)
require.ErrorContains(err, "results")
}
// buildBlockWire assembles a block wire by hand so a test can put bytes on it
// that no encoder would produce.
func buildBlockWire(t *testing.T, set func(zap.ObjectBuilder, *zap.Builder)) []byte {
t.Helper()
b := zap.NewBuilder(zap.HeaderSize + blkSize + 4096)
ob := b.StartObject(blkSize)
ob.SetUint8(blkKind, uint8(kindBlock))
ob.SetUint64(blkHeight, 1)
set(ob, b)
ob.FinishAsRoot()
return b.Finish()
}
// Every nested payload is decoded, not trusted. A sub-blob that is not what its
// length says it is stops the parse rather than becoming an empty element.
func TestMalformedPayloadsStopTheParse(t *testing.T) {
require := require.New(t)
// A C intent that is not a zap message at all.
junk := []byte("nothing like an intent")
require.Error(parseBlock(buildBlockWire(t, func(ob zap.ObjectBuilder, b *zap.Builder) {
ob.SetList(blkIntentLens, writeU32List(b, []uint32{uint32(len(junk))}), 1)
ob.SetBytes(blkIntentBlob, junk)
}), &Block{}))
// One that is, with bytes past its own content: the element's length and the
// element's encoding have to agree, or two blobs decode to one intent.
one := marshalCIntent(CIntent{IntentID: hashOf(1), Fee: uint256.NewInt(1), RewardPerOperator: uint256.NewInt(1)})
padded := append(append([]byte(nil), one...), 0, 0, 0, 0)
require.ErrorContains(parseBlock(buildBlockWire(t, func(ob zap.ObjectBuilder, b *zap.Builder) {
ob.SetList(blkIntentLens, writeU32List(b, []uint32{uint32(len(padded))}), 1)
ob.SetBytes(blkIntentBlob, padded)
}), &Block{}), "trailing bytes")
// And the same for each JSON-carried payload.
bad := []byte("{{{")
for _, slot := range []struct {
lens, blob int
name string
}{
{blkTaskLens, blkTaskBlob, "tasks"},
{blkResLens, blkResBlob, "results"},
{blkPRegLens, blkPRegBlob, "provider registrations"},
} {
require.Error(parseBlock(buildBlockWire(t, func(ob zap.ObjectBuilder, b *zap.Builder) {
ob.SetList(slot.lens, writeU32List(b, []uint32{uint32(len(bad))}), 1)
ob.SetBytes(slot.blob, bad)
}), &Block{}), slot.name)
}
}
// The vertex wire is held to the same standard: id lists must be whole ids,
// string and JSON lists must account for their blob exactly.
func TestMalformedVertexPayloadsStopTheParse(t *testing.T) {
require := require.New(t)
wire := func(set func(zap.ObjectBuilder, *zap.Builder)) []byte {
b := zap.NewBuilder(zap.HeaderSize + vxSize + 4096)
ob := b.StartObject(vxSize)
ob.SetUint8(vxKind, uint8(kindVertex))
ob.SetUint64(vxHeight, 1)
set(ob, b)
ob.FinishAsRoot()
return b.Finish()
}
require.ErrorContains(parseVertex(wire(func(ob zap.ObjectBuilder, b *zap.Builder) {
ob.SetBytes(vxParents, make([]byte, 33))
}), &AIVertex{}), "parent list")
require.ErrorContains(parseVertex(wire(func(ob zap.ObjectBuilder, b *zap.Builder) {
ob.SetBytes(vxTxIDs, make([]byte, 31))
}), &AIVertex{}), "tx list")
require.ErrorContains(parseVertex(wire(func(ob zap.ObjectBuilder, b *zap.Builder) {
ob.SetList(vxJobLens, writeU32List(b, []uint32{2}), 1)
ob.SetBytes(vxJobBlob, []byte("abcd"))
}), &AIVertex{}), "past the last")
bad := []byte("{{{")
require.Error(parseVertex(wire(func(ob zap.ObjectBuilder, b *zap.Builder) {
ob.SetList(vxTaskLens, writeU32List(b, []uint32{uint32(len(bad))}), 1)
ob.SetBytes(vxTaskBlob, bad)
}), &AIVertex{}))
require.Error(parseVertex(wire(func(ob zap.ObjectBuilder, b *zap.Builder) {
ob.SetList(vxResLens, writeU32List(b, []uint32{uint32(len(bad))}), 1)
ob.SetBytes(vxResBlob, bad)
}), &AIVertex{}))
}
// A block that cannot be encoded cannot be named, and a name derived from a
// half-written encoding is the name of a different block.
func TestABlockThatCannotBeEncodedCannotBeNamed(t *testing.T) {
require := require.New(t)
blk := &Block{Height_: 1, Results: []aicore.TaskResult{{TaskID: "r", Output: json.RawMessage(`{`)}}}
require.ErrorContains(blk.name(), "results")
require.Empty(blk.Bytes())
blk = &Block{Height_: 1, ProviderRegs: []ProviderReg{{ProviderID: "p"}}}
require.NoError(blk.name())
}
-188
View File
@@ -1,188 +0,0 @@
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
@@ -1,314 +0,0 @@
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)
}