Files
precompile/aivmbridge/achain_verify_test.go

295 lines
10 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// achain_verify_test.go — Pattern B (VerifyInferenceReceipt) tests. The most
// security-critical surface. Asserts a valid committed receipt+proof is accepted and
// records the canonical output, and REJECTS: bad merkle path, uncommitted receipt
// root, intent_id mismatch, status != Completed, model/prompt mismatch, replay
// (double-consume), pending/failed receipt (no C credit). It also asserts Pattern B
// performs ZERO aivm calls (it depends only on C-committed state + the proof).
package aivmbridge
import (
"testing"
"github.com/luxfi/geth/common"
)
// verifyFixture builds a fully consistent (intent submitted, receipt, committed root,
// real proof) scenario over one mockState, ready to drive verifyReceipt. The receipt
// is placed as one leaf among several so the merkle proof is non-trivial.
type verifyFixture struct {
st *mockState
intentID [32]byte
receipt AInferenceReceipt
proof AInferenceProof
caller common.Address
achainID [32]byte
}
func newVerifyFixture(t *testing.T) *verifyFixture {
t.Helper()
achainID := h32(0xA0)
installNative(t, achainID, nil)
st := newMockState(common.HexToHash("0xABCD"))
st.callIndex = 1
caller := common.HexToAddress("0x4444444444444444444444444444444444444444")
modelSpec := h32(0x5E)
prompt := h32(0x9D)
// Submit the intent through the real Pattern-A path so the C outbox is genuinely
// written and we use the real intent_id.
in := encodeSubmit(modelSpec, prompt, 3, 2, feeWord(1_000_000), [32]byte{})
gas := BridgePrecompile.RequiredGas(in)
ret, _, err := BridgePrecompile.Run(st, caller, ContractAddress, in, gas, false)
if err != nil {
t.Fatalf("submit: %v", err)
}
intentID := toArr(ret)
// Build the committed receipt bound to that intent.
rcpt := AInferenceReceipt{
Version: ReceiptVersion,
IntentID: intentID,
TaskID: h32(0x7A),
CChainID: toArr(st.cChainID[:]),
AChainID: achainID,
Requester: caller,
ModelSpecHash: modelSpec,
PromptHash: prompt,
CanonicalOutputHash: h32(0x0F),
Status: StatusCompleted,
N: 3,
Threshold: 2,
WinnersRoot: h32(0x44),
OperatorsRoot: h32(0x55),
FeePaid: feeWord(1_000_000),
SettledAtHeight: 1234,
}
// Build a real merkle tree placing receipt_hash at index 2 of 5 leaves.
leaves := [][32]byte{h32(0x01), h32(0x02), ReceiptHash(rcpt), h32(0x04), h32(0x05)}
tree := buildMerkleTree(leaves)
proof := tree.proof(2)
// Commit the receipt root in C state (the A->C seam landing point).
CommitReceiptRoot(st.db, proof.ReceiptRoot, st.blockNumber)
return &verifyFixture{st: st, intentID: intentID, receipt: rcpt, proof: proof, caller: caller, achainID: achainID}
}
func (f *verifyFixture) run(t *testing.T, r AInferenceReceipt, p AInferenceProof) ([]byte, error) {
t.Helper()
input := encodeVerify(EncodeReceipt(r), encodeProof(p))
gas := BridgePrecompile.RequiredGas(input)
ret, _, err := BridgePrecompile.Run(f.st, f.caller, ContractAddress, input, gas+1, false)
return ret, err
}
func TestVerify_AcceptsValidCommittedReceipt(t *testing.T) {
f := newVerifyFixture(t)
ret, err := f.run(t, f.receipt, f.proof)
if err != nil {
t.Fatalf("verify: %v", err)
}
if len(ret) != 96 {
t.Fatalf("ret len = %d, want 96", len(ret))
}
if toArr(ret[0:32]) != f.intentID {
t.Fatalf("returned intentID mismatch")
}
if toArr(ret[32:64]) != f.receipt.CanonicalOutputHash {
t.Fatalf("returned canonical output hash mismatch")
}
if ret[95] != byte(StatusCompleted) {
t.Fatalf("returned status = %d, want Completed", ret[95])
}
// The intent MUST now be marked Consumed.
store := newStateKVStore(f.st.db, f.st.blockNumber)
if store.IntentStatus(f.intentID) != OutboxConsumed {
t.Fatalf("intent not consumed after successful verify")
}
}
func TestVerify_RejectsReplayDoubleConsume(t *testing.T) {
f := newVerifyFixture(t)
if _, err := f.run(t, f.receipt, f.proof); err != nil {
t.Fatalf("first verify: %v", err)
}
// Second verify of the SAME receipt must be rejected (double-consume guard).
if _, err := f.run(t, f.receipt, f.proof); err != ErrIntentConsumed {
t.Fatalf("second verify err = %v, want ErrIntentConsumed", err)
}
}
func TestVerify_RejectsBadMerklePath(t *testing.T) {
f := newVerifyFixture(t)
bad := f.proof
bad.Path = append([][32]byte(nil), f.proof.Path...)
bad.Path[0][0] ^= 0xFF // corrupt a sibling
if _, err := f.run(t, f.receipt, bad); err != ErrMerkleVerify {
t.Fatalf("err = %v, want ErrMerkleVerify", err)
}
}
func TestVerify_RejectsWrongIndex(t *testing.T) {
f := newVerifyFixture(t)
bad := f.proof
bad.Index = f.proof.Index ^ 1 // wrong left/right turns
if _, err := f.run(t, f.receipt, bad); err != ErrMerkleVerify {
t.Fatalf("err = %v, want ErrMerkleVerify", err)
}
}
func TestVerify_RejectsUncommittedRoot(t *testing.T) {
f := newVerifyFixture(t)
// A proof under a DIFFERENT (uncommitted) root: rebuild a tree with a tampered
// leaf so the root differs, then prove the receipt under it (it won't be committed).
leaves := [][32]byte{h32(0x01), ReceiptHash(f.receipt), h32(0x03)}
tree := buildMerkleTree(leaves)
p := tree.proof(1)
// p.ReceiptRoot is NOT committed in C state.
if _, err := f.run(t, f.receipt, p); err != ErrReceiptRootNotCommitted {
t.Fatalf("err = %v, want ErrReceiptRootNotCommitted", err)
}
}
func TestVerify_RejectsForgedRootZero(t *testing.T) {
f := newVerifyFixture(t)
bad := f.proof
bad.ReceiptRoot = [32]byte{} // all-zero root must never be "committed"
if _, err := f.run(t, f.receipt, bad); err != ErrReceiptRootNotCommitted {
t.Fatalf("err = %v, want ErrReceiptRootNotCommitted", err)
}
}
func TestVerify_RejectsIntentMismatch(t *testing.T) {
f := newVerifyFixture(t)
r := f.receipt
r.IntentID = h32(0xEE) // no such pending intent
// Re-merkle for the mutated receipt under a freshly committed root so we get PAST
// the merkle + root checks and exercise the intent-match check specifically.
leaves := [][32]byte{ReceiptHash(r), h32(0x02)}
tree := buildMerkleTree(leaves)
p := tree.proof(0)
CommitReceiptRoot(f.st.db, p.ReceiptRoot, f.st.blockNumber)
if _, err := f.run(t, r, p); err != ErrNoMatchingIntent {
t.Fatalf("err = %v, want ErrNoMatchingIntent", err)
}
}
func TestVerify_RejectsModelPromptMismatch(t *testing.T) {
f := newVerifyFixture(t)
for _, tc := range []struct {
name string
mutate func(r *AInferenceReceipt)
}{
{"wrong model", func(r *AInferenceReceipt) { r.ModelSpecHash = h32(0xBB) }},
{"wrong prompt", func(r *AInferenceReceipt) { r.PromptHash = h32(0xCC) }},
{"wrong requester", func(r *AInferenceReceipt) { r.Requester = common.Address{0x9} }},
{"wrong N", func(r *AInferenceReceipt) { r.N = 5 }},
{"wrong threshold", func(r *AInferenceReceipt) { r.Threshold = 1 }},
{"wrong cchain", func(r *AInferenceReceipt) { r.CChainID = h32(0xDD) }},
{"wrong achain", func(r *AInferenceReceipt) { r.AChainID = h32(0xEE) }},
} {
t.Run(tc.name, func(t *testing.T) {
r := f.receipt
r.IntentID = f.intentID // still targets the real pending intent
tc.mutate(&r)
// Re-merkle + commit so we reach the bind-mismatch check (not merkle/root).
leaves := [][32]byte{ReceiptHash(r), h32(0x02)}
tree := buildMerkleTree(leaves)
p := tree.proof(0)
CommitReceiptRoot(f.st.db, p.ReceiptRoot, f.st.blockNumber)
if _, err := f.run(t, r, p); err != ErrReceiptBindMismatch {
t.Fatalf("err = %v, want ErrReceiptBindMismatch", err)
}
})
}
}
func TestVerify_RejectsNonCompletedStatus(t *testing.T) {
f := newVerifyFixture(t)
for _, st := range []ReceiptStatus{StatusUnknown, StatusPending, StatusFailed, StatusChallenged} {
t.Run(statusName(st), func(t *testing.T) {
r := f.receipt
r.Status = st
leaves := [][32]byte{ReceiptHash(r), h32(0x02)}
tree := buildMerkleTree(leaves)
p := tree.proof(0)
CommitReceiptRoot(f.st.db, p.ReceiptRoot, f.st.blockNumber)
_, err := f.run(t, r, p)
if err != ErrReceiptNotCompleted {
t.Fatalf("status %d: err = %v, want ErrReceiptNotCompleted", st, err)
}
// And the intent must NOT be consumed (no credit on a non-final receipt).
store := newStateKVStore(f.st.db, f.st.blockNumber)
if store.IntentStatus(f.intentID) == OutboxConsumed {
t.Fatalf("status %d wrongly consumed the intent", st)
}
})
}
}
func TestVerify_RejectsCompletedZeroOutput(t *testing.T) {
f := newVerifyFixture(t)
r := f.receipt
r.CanonicalOutputHash = [32]byte{} // Completed but empty output is self-contradictory
leaves := [][32]byte{ReceiptHash(r), h32(0x02)}
tree := buildMerkleTree(leaves)
p := tree.proof(0)
CommitReceiptRoot(f.st.db, p.ReceiptRoot, f.st.blockNumber)
if _, err := f.run(t, r, p); err != ErrZeroOutput {
t.Fatalf("err = %v, want ErrZeroOutput", err)
}
}
func TestVerify_PendingFailedNoCredit(t *testing.T) {
// Explicit "pending/failed receipt -> no C credit, intent stays pending" check.
f := newVerifyFixture(t)
for _, st := range []ReceiptStatus{StatusPending, StatusFailed} {
r := f.receipt
r.Status = st
leaves := [][32]byte{ReceiptHash(r), h32(0x02)}
tree := buildMerkleTree(leaves)
p := tree.proof(0)
CommitReceiptRoot(f.st.db, p.ReceiptRoot, f.st.blockNumber)
if _, err := f.run(t, r, p); err != ErrReceiptNotCompleted {
t.Fatalf("status %d: %v", st, err)
}
store := newStateKVStore(f.st.db, f.st.blockNumber)
if store.IntentStatus(f.intentID) != OutboxPending {
t.Fatalf("status %d: intent no longer pending (got credited?)", st)
}
}
}
func TestVerify_ReadOnlyRejected(t *testing.T) {
f := newVerifyFixture(t)
f.st.readOnly = true
input := encodeVerify(EncodeReceipt(f.receipt), encodeProof(f.proof))
gas := BridgePrecompile.RequiredGas(input)
if _, _, err := BridgePrecompile.Run(f.st, f.caller, ContractAddress, input, gas, true); err != ErrReadOnly {
t.Fatalf("err = %v, want ErrReadOnly", err)
}
}
func statusName(s ReceiptStatus) string {
switch s {
case StatusUnknown:
return "unknown"
case StatusPending:
return "pending"
case StatusFailed:
return "failed"
case StatusChallenged:
return "challenged"
default:
return "?"
}
}