Files
zeekayandHanzo Dev 104bf5f954 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:39:55 -07:00

308 lines
13 KiB
Go

// Copyright (C) 2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// RED-1 adversarial suite (third pass): calldata hardening through the REAL Run
// dispatcher, settle-integrity edges, window/state-machine abuse, and gas-on-
// revert semantics.
package aiquorum
import (
"math/big"
"slices"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// ===========================================================================
// RED-G: calldata fuzzing through Precompile.Run. Every selector is hit with
// truncated, empty, and oversized inputs. The contract must NEVER panic, and a
// logic/decode error must return WITH remaining gas (a normal revert), never
// gas==0 (all-gas-burn) unless it was a genuine out-of-gas. An all-gas-burn fork
// vs a co-validator that decodes differently would be a consensus split.
// ===========================================================================
func TestRedG_CalldataFuzz_NoPanic_NoAllGasBurn(t *testing.T) {
selectors := []uint32{
SelectorRegisterOperator, SelectorDeregisterOperator, SelectorWithdrawStake,
SelectorRequestInference, SelectorCommitResponse, SelectorRevealResponse,
SelectorSettle, SelectorWithdrawRewards, SelectorGetOperator, SelectorGetCredit,
SelectorGetJob, SelectorGetCanonicalResult, SelectorComputeModelSpec, SelectorIsSelected,
}
// A grab-bag of malformed tails: empty, 1 byte, 31 bytes (sub-word), 33 bytes
// (dirty trailing), a huge dynamic-length claim, and an all-0xff word.
tails := [][]byte{
{},
{0x00},
make([]byte, 31),
make([]byte, 33),
// A dynamic-length field claiming a gigantic size (for the bytes readers).
append(padLeft32(uint256.NewInt(1<<40).Bytes()), 0x01),
// All-ones words (dirty high bytes for uint32/address readers).
append(append([]byte{}, bytes32(0xff)...), bytes32(0xff)...),
}
for _, sel := range selectors {
for ti, tail := range tails {
as := newAS()
as.advance(100)
input := append(selBytes(sel), tail...)
// Wrap in a recover so a panic FAILS the test loudly with context.
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("RED-G: PANIC selector=%#x tail#%d (len=%d): %v", sel, ti, len(tail), r)
}
}()
gasIn := uint64(5_000_000)
_, gasOut, err := run(t, as, opAddr(1), input, gasIn, false)
// If it errored on decode/logic, gas must be conserved (==remaining
// after the fixed DeductGas) — i.e. NOT zero, unless it was real OOG.
if err != nil && gasOut == 0 {
// gasOut==0 is only acceptable for an out-of-gas DeductGas failure,
// which cannot happen with 5M gas and these tiny fixed costs.
t.Fatalf("RED-G: all-gas-burn on revert selector=%#x tail#%d err=%v (consensus-fork risk)", sel, ti, err)
}
}()
}
}
}
func bytes32(b byte) []byte {
w := make([]byte, 32)
for i := range w {
w[i] = b
}
return w
}
// ===========================================================================
// RED-G2: dirty-word rejection. readUint32/readAddress must reject words with
// nonzero high bytes (so a caller cannot smuggle data the handler ignores). N
// encoded with dirty high bytes must be rejected rather than silently truncated.
// ===========================================================================
func TestRedG2_DirtyWordsRejected(t *testing.T) {
as := newAS()
as.advance(10)
// requestInference with N word = 0xff...ff05 (low byte 5, but dirty high bytes).
input := selBytes(SelectorRequestInference)
input = append(input, spec1.Bytes()...)
input = append(input, prompt1.Bytes()...)
dirtyN := bytes32(0xff)
dirtyN[31] = 0x05
input = append(input, dirtyN...) // N (dirty)
input = append(input, padLeft32Uint32(3)...) // threshold
input = append(input, h32(tokens(1)).Bytes()...)
_, gasOut, err := run(t, as, requester, input, 5_000_000, false)
require.Error(t, err, "dirty uint32 word must be rejected, not truncated to 5")
require.NotZero(t, gasOut, "revert keeps gas")
// getOperator with a dirty address word (nonzero in the top 12 bytes).
input2 := selBytes(SelectorGetOperator)
dirtyAddr := bytes32(0x00)
dirtyAddr[5] = 0x01 // nonzero in the high 12 bytes
input2 = append(input2, dirtyAddr...)
_, _, err = run(t, as, requester, input2, 5_000_000, false)
require.Error(t, err, "dirty address word must be rejected")
}
// ===========================================================================
// RED-E: settle-integrity edges.
// (1) settle before the reveal window closes -> ErrSettleTooEarly (no state
// change, can still settle later).
// (2) settle a nonexistent job -> ErrJobUnknown.
// (3) settle a FAILED job again -> ErrJobAlreadySettled (the settled marker is
// set on the failed path too -> no re-settle, no double refund).
// ===========================================================================
func TestRedE_SettleEdges(t *testing.T) {
// (1) too early
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
for i := range 3 {
nce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[i], nce), 12))
require.NoError(t, RevealResponse(db, jobID, sel[i], answer, embed, nce, revealBlkAt))
}
_, err := Settle(db, lg, jobID, revealBlkAt) // == revealDeadline boundary -> still too early (block <= revealDeadline)
require.ErrorIs(t, err, ErrSettleTooEarly)
// can still settle after the window
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobSettled, res.Status)
// (2) unknown job
_, err = Settle(db, lg, common.HexToHash("0xdeadbeef"), settleBlkAt)
require.ErrorIs(t, err, ErrJobUnknown)
// (3) re-settle a FAILED job must be rejected (no double refund).
db2 := newMockStateDB()
lg2 := newLedger(db2)
jobID2, sel2 := setupJob(t, db2, lg2, 5, 5, 3, tokens(1), 10)
// 2+2+1 -> fail
outs := []common.Hash{common.HexToHash("0xa"), common.HexToHash("0xa"), common.HexToHash("0xb"), common.HexToHash("0xb"), common.HexToHash("0xc")}
for i := range 5 {
nce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db2, jobID2, sel2[i], ComputeCommit(jobID2, spec1, prompt1, outs[i], embed, sel2[i], nce), 12))
require.NoError(t, RevealResponse(db2, jobID2, sel2[i], outs[i], embed, nce, revealBlkAt))
}
res2, err := Settle(db2, lg2, jobID2, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobFailed, res2.Status)
refundAfterFirst := GetCredit(db2, requester).Uint64()
// Replay the failed settle -> must reject, refund unchanged.
_, err = Settle(db2, lg2, jobID2, settleBlkAt)
require.ErrorIs(t, err, ErrJobAlreadySettled)
require.Equal(t, refundAfterFirst, GetCredit(db2, requester).Uint64(), "failed-job refund must not be paid twice")
}
// ===========================================================================
// RED-F: window/state-machine abuse.
// reveal before commit window closes -> ErrRevealNotOpen.
// reveal without committing -> ErrNotCommitted.
// commit after deadline -> ErrCommitClosed.
// commit twice -> ErrAlreadyCommitted.
// reveal twice -> ErrAlreadyRevealed.
// ===========================================================================
func TestRedF_WindowStateMachine(t *testing.T) {
db, _, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
op := sel[0]
nce := common.BigToHash(big.NewInt(1))
commit := ComputeCommit(jobID, spec1, prompt1, answer, embed, op, nce)
// reveal before committing -> ErrNotCommitted (in reveal window).
require.ErrorIs(t, RevealResponse(db, jobID, op, answer, embed, nce, revealBlkAt), ErrNotCommitted)
// commit ok.
require.NoError(t, CommitResponse(db, jobID, op, commit, 12))
// commit twice -> rejected.
require.ErrorIs(t, CommitResponse(db, jobID, op, commit, 12), ErrAlreadyCommitted)
// commit after deadline -> rejected.
require.ErrorIs(t, CommitResponse(db, jobID, sel[1], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[1], nce), 10+CommitBlocks+1), ErrCommitClosed)
// reveal before commit window CLOSES (block <= commitDeadline) -> not open.
require.ErrorIs(t, RevealResponse(db, jobID, op, answer, embed, nce, 10+CommitBlocks), ErrRevealNotOpen)
// reveal in window -> ok.
require.NoError(t, RevealResponse(db, jobID, op, answer, embed, nce, revealBlkAt))
// reveal twice -> rejected.
require.ErrorIs(t, RevealResponse(db, jobID, op, answer, embed, nce, revealBlkAt), ErrAlreadyRevealed)
// reveal after the reveal window closes -> rejected by the window guard
// (ErrRevealClosed fires before the commit check — correct ordering).
require.ErrorIs(t, RevealResponse(db, jobID, sel[1], answer, embed, nce, 10+CommitBlocks+RevealBlocks+1), ErrRevealClosed)
}
// ===========================================================================
// RED-I: single-identity quorum forgery WITHOUT colluding requester. A lone
// selected operator tries to reveal multiple times / manufacture multiple votes
// to reach threshold by itself. Each operator has exactly one selection flag and
// one reveal flag, so it gets exactly one vote — a minority cannot inflate itself.
// ===========================================================================
func TestRedI_SingleOperatorCannotManufactureQuorum(t *testing.T) {
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
// Only ONE operator reveals; it cannot reveal again to count as 3.
nce := common.BigToHash(big.NewInt(0))
require.NoError(t, CommitResponse(db, jobID, sel[0], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[0], nce), 12))
require.NoError(t, RevealResponse(db, jobID, sel[0], answer, embed, nce, revealBlkAt))
require.ErrorIs(t, RevealResponse(db, jobID, sel[0], answer, embed, nce, revealBlkAt), ErrAlreadyRevealed)
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobFailed, res.Status, "one revealer cannot reach threshold 3")
require.Equal(t, uint32(0), res.WinnerCount)
}
// ===========================================================================
// RED-J: gas-on-revert for the O(N) ops. RequestInference deducts the per-N
// surcharge BEFORE selection; a selection failure (ErrNotEnoughEligible) must
// still return remaining gas (not 0). And a replay-Settle of an already-settled
// job charges the per-N surcharge then reverts -> gas burned for no work (note
// this as a minor mispricing, but it must NOT be all-gas-burn).
// ===========================================================================
func TestRedJ_GasOnRevert_ONOps(t *testing.T) {
// Not enough eligible: 2 registered, request N=5.
as := newAS()
as.advance(10)
innerLg := newLedger(as.db)
registerN(t, as.db, innerLg, spec1, 2, tokens(2))
fund(as.db, requester, tokens(1000))
input := encRequest(spec1, prompt1, 5, 3, tokens(1))
_, gasOut, err := run(t, as, requester, input, 5_000_000, false)
require.Error(t, err, "not enough eligible")
require.NotZero(t, gasOut, "revert after per-N deduction must keep remaining gas, not all-burn")
// Replay-settle an already-settled job through Run -> rejected, gas kept.
// Fresh AS so operator registration does not collide with the block above.
as = newAS()
as.advance(10)
db := as.db
lg2 := newLedger(db)
jobID, sel := setupJob(t, db, lg2, 5, 5, 3, tokens(1), 10)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
for i := range 3 {
nce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[i], nce), 12))
require.NoError(t, RevealResponse(db, jobID, sel[i], answer, embed, nce, revealBlkAt))
}
as.advance(settleBlkAt)
settleInput := append(selBytes(SelectorSettle), jobID.Bytes()...)
_, _, err = run(t, as, requester, settleInput, 5_000_000, false)
require.NoError(t, err)
_, gasOut2, err := run(t, as, requester, settleInput, 5_000_000, false)
require.ErrorIs(t, err, ErrJobAlreadySettled)
require.NotZero(t, gasOut2, "replay-settle reverts with gas, not all-burn")
}
// ===========================================================================
// RED-K: requester GRINDS to EXCLUDE a specific honest operator from selection
// (censorship). Even without colluding operators, the requester controls
// promptHash and can grind job_id until a target honest operator is NOT selected.
// This is a liveness/censorship attack on a specific operator's ability to earn.
// ===========================================================================
func TestRedK_GrindToExcludeVictim_Censorship(t *testing.T) {
db := newMockStateDB()
lg := newLedger(db)
registerN(t, db, lg, spec1, 6, tokens(2))
victim := opAddr(3)
attacker := reqAddr(7)
fund(db, attacker, tokens(100000))
nonceSlot := slotAddr(nsReqNonce, attacker)
nonce := db.GetState(ContractAddress, nonceSlot)
const N = uint32(5)
const threshold = uint32(3)
const block = uint64(10)
tries := 0
excluded := false
for k := range 100000 {
p := common.BigToHash(big.NewInt(int64(k)))
jid := computeJobID(attacker, nonce, spec1, p, block, N, threshold)
sel, err := selectOperators(db, jid, spec1, N)
require.NoError(t, err)
in := slices.Contains(sel, victim)
if !in {
tries = k + 1
excluded = true
break
}
}
require.True(t, excluded, "requester can grind to exclude a target operator")
t.Logf("RED-K: excluded victim from an N=5-of-6 selection after %d promptHash tries", tries)
}