mirror of
https://github.com/hanzoai/chains.git
synced 2026-10-30 18:46:11 +00:00
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]>
308 lines
13 KiB
Go
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)
|
|
}
|