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

615 lines
28 KiB
Go

// Copyright (C) 2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Adversarial test suite. Each test encodes a concrete attack and asserts the
// precompile DEFENDS against it (the attack fails / has no profitable effect).
// These are the properties Red is invited to break.
package aiquorum
import (
"math/big"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// helper: open a standard N-of-M job at block 10 and return jobID + selected set.
func adversarialJob(t *testing.T, nOps int, n, threshold uint32) (*mockStateDB, Ledger, common.Hash, []common.Address) {
t.Helper()
db := newMockStateDB()
lg := newLedger(db)
jobID, sel := setupJob(t, db, lg, nOps, n, threshold, tokens(1), 10)
return db, lg, jobID, sel
}
const (
commitBlk = uint64(12)
revealBlkAt = uint64(10) + 30 + 1 // 10 + CommitBlocks + 1 (CommitBlocks=30)
settleBlkAt = uint64(10) + 30 + 30 + 1
)
// ===========================================================================
// ATTACK 1: Commit-copy / front-run.
// Operator B watches A's commit hash land on-chain and submits the SAME hash as
// its own commit, hoping to ride A's answer without doing the work. The commit
// binds the submitter's address, so B can never produce a matching reveal.
// ===========================================================================
func TestAttackCommitCopyDefeated(t *testing.T) {
db, _, jobID, sel := adversarialJob(t, 5, 5, 3)
alice, bob := sel[0], sel[1]
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
aliceNonce := common.BigToHash(big.NewInt(1))
// Alice commits honestly.
aliceCommit := ComputeCommit(jobID, spec1, prompt1, answer, embed, alice, aliceNonce)
require.NoError(t, CommitResponse(db, jobID, alice, aliceCommit, commitBlk))
// Bob COPIES Alice's commit hash verbatim as his own commit (allowed at commit
// time — the chain can't tell yet).
require.NoError(t, CommitResponse(db, jobID, bob, aliceCommit, commitBlk))
// Reveal window. Bob somehow learned Alice's (answer, embed, nonce) and tries
// to reveal them as his own. Recomputation uses operator=Bob → different
// digest → mismatch. Bob CANNOT reveal.
require.ErrorIs(t, RevealResponse(db, jobID, bob, answer, embed, aliceNonce, revealBlkAt), ErrCommitMismatch)
// Bob also can't reveal with any other preimage (he committed Alice's hash,
// which he cannot reproduce as his own).
require.ErrorIs(t, RevealResponse(db, jobID, bob, answer, embed, common.BigToHash(big.NewInt(2)), revealBlkAt), ErrCommitMismatch)
// Alice reveals fine.
require.NoError(t, RevealResponse(db, jobID, alice, answer, embed, aliceNonce, revealBlkAt))
// Bob is therefore a committed non-revealer → he will be slashed at settle.
// (He copied a peer and gained nothing but a slash.)
for i := 2; i < 5; i++ {
nonce := common.BigToHash(big.NewInt(int64(10 + i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[i], nonce), commitBlk))
require.NoError(t, RevealResponse(db, jobID, sel[i], answer, embed, nonce, revealBlkAt))
}
_, _, bobStakeBefore, _, _ := GetOperator(db, bob)
require.Equal(t, tokens(2).Uint64(), bobStakeBefore.Uint64())
res, err := Settle(db, newLedger(db), jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobSettled, res.Status)
// Bob slashed for withholding; got zero reward.
_, _, bobStakeAfter, _, _ := GetOperator(db, bob)
require.Equal(t, new(uint256.Int).Sub(tokens(2), SlashPerOperator).Uint64(), bobStakeAfter.Uint64(), "commit-copier is slashed")
require.Zero(t, GetCredit(db, bob).Uint64(), "commit-copier earns nothing")
}
// ===========================================================================
// ATTACK 2: Cross-job commit replay.
// A commit valid for job X is replayed on job Y. The commit binds job_id, so it
// cannot bind to a different job; and the operator may not even be selected for Y.
// ===========================================================================
func TestAttackCrossJobReplayDefeated(t *testing.T) {
db := newMockStateDB()
lg := newLedger(db)
// Two jobs over the same operator pool of 8 (N=5 + RED-A margin 2 needs >= 7;
// 8 also guarantees two independent 5-of-8 draws overlap by pigeonhole).
registerN(t, db, lg, spec1, 8, tokens(3))
fund(db, requester, tokens(1000))
jobX, err := RequestInference(db, lg, requester, spec1, prompt1, 5, 3, tokens(1), 10)
require.NoError(t, err)
jobY, err := RequestInference(db, lg, requester, spec1, prompt1, 5, 3, tokens(1), 10)
require.NoError(t, err)
require.NotEqual(t, jobX, jobY, "distinct jobs (nonce bumped)")
// Find an operator selected for BOTH (two 5-of-8 draws must overlap).
var op common.Address
for i := range 8 {
o := opAddr(i)
if IsSelected(db, jobX, o) && IsSelected(db, jobY, o) {
op = o
break
}
}
require.NotEqual(t, common.Address{}, op)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
nonce := common.BigToHash(big.NewInt(1))
// Commit on X.
commitX := ComputeCommit(jobX, spec1, prompt1, answer, embed, op, nonce)
require.NoError(t, CommitResponse(db, jobX, op, commitX, commitBlk))
// Replay X's commit hash onto Y, then try to reveal the same preimage on Y.
// Reveal recomputes with job_id=Y → different digest → mismatch.
require.NoError(t, CommitResponse(db, jobY, op, commitX, commitBlk))
require.ErrorIs(t, RevealResponse(db, jobY, op, answer, embed, nonce, revealBlkAt), ErrCommitMismatch)
// The honest reveal on X still works.
require.NoError(t, RevealResponse(db, jobX, op, answer, embed, nonce, revealBlkAt))
}
// ===========================================================================
// ATTACK 3: ModelSpec / prompt substitution.
// An operator tries to settle a job using output computed under a DIFFERENT
// ModelSpec/prompt than the job requires. The reveal recomputes the commit using
// the JOB's stored modelSpecHash+promptHash (server-side, not calldata), so a
// commit built against a different spec/prompt cannot reveal.
// ===========================================================================
func TestAttackModelSpecSubstitutionDefeated(t *testing.T) {
db, _, jobID, sel := adversarialJob(t, 5, 5, 3)
op := sel[0]
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
nonce := common.BigToHash(big.NewInt(1))
wrongSpec := common.HexToHash("0xdead")
wrongPrompt := common.HexToHash("0xbeef")
// Operator commits a hash built against the WRONG spec/prompt.
badCommit := ComputeCommit(jobID, wrongSpec, wrongPrompt, answer, embed, op, nonce)
require.NoError(t, CommitResponse(db, jobID, op, badCommit, commitBlk))
// At reveal the precompile recomputes using the JOB's real spec1/prompt1 →
// mismatch. The operator cannot pass off off-spec work.
require.ErrorIs(t, RevealResponse(db, jobID, op, answer, embed, nonce, revealBlkAt), ErrCommitMismatch)
}
// ===========================================================================
// ATTACK 4: Beacon grinding / selection bias.
// A requester wants to hand-pick the operator set. job_id folds in a monotonic
// requester nonce the precompile controls — the requester cannot reuse or choose
// it, so identical-parameter requests select (generally) different sets and the
// requester cannot fish for a favorable draw by replaying.
// ===========================================================================
func TestAttackBeaconGrindingDefeated(t *testing.T) {
db := newMockStateDB()
lg := newLedger(db)
registerN(t, db, lg, spec1, 20, tokens(2))
fund(db, requester, tokens(1000))
// Two identical requests (same spec, prompt, N, threshold, block) → different
// job_ids (nonce bumped) → independent selections. The requester cannot pin
// the set by repeating the call.
j1, err := RequestInference(db, lg, requester, spec1, prompt1, 5, 3, tokens(1), 10)
require.NoError(t, err)
j2, err := RequestInference(db, lg, requester, spec1, prompt1, 5, 3, tokens(1), 10)
require.NoError(t, err)
require.NotEqual(t, j1, j2)
set1 := map[common.Address]bool{}
set2 := map[common.Address]bool{}
for i := range uint32(5) {
set1[SelectedAt(db, j1, i)] = true
set2[SelectedAt(db, j2, i)] = true
}
// With 20 operators and an unpredictable nonce, the two sets differ. (The
// point is the requester cannot DETERMINE the set; equality would be a fluke,
// and even then the requester could not have predicted it pre-call.)
require.NotEqual(t, set1, set2, "identical-param requests must not yield a pinnable set")
// An operator cannot influence its own selection: selection depends only on
// job_id (set by requester+chain) and the operator's index in the append-only
// array (fixed at registration). There is no operator-supplied input to the
// draw. We assert reproducibility from public data as the dual of this.
require.Equal(t, []common.Address{
SelectedAt(db, j1, 0), SelectedAt(db, j1, 1), SelectedAt(db, j1, 2),
SelectedAt(db, j1, 3), SelectedAt(db, j1, 4),
}, reproduceSelection(t, db, j1, spec1, 5))
}
// ===========================================================================
// ATTACK 5: Nothing-at-stake — a slashed/under-min operator keeps getting work.
// An operator slashed below MinStake on one job must be excluded from the next
// job's selection (eligibility is re-checked at selection time, not cached).
// ===========================================================================
func TestAttackNothingAtStakeExcludesUnderMinOperator(t *testing.T) {
db := newMockStateDB()
lg := newLedger(db)
// Register 6 strong operators + 1 weak (right at MinStake so a single slash
// drops it below the floor). Pool of 7 clears N=4's RED-A margin (need 6) both
// before and after the weak operator is excluded (6 strong remain).
for i := range 6 {
op := opAddr(i)
db.balance[op] = tokens(5)
require.NoError(t, RegisterOperator(db, lg, op, tokens(3), spec1, common.Hash{}))
}
weak := opAddr(6)
db.balance[weak] = tokens(5)
require.NoError(t, RegisterOperator(db, lg, weak, MinStake, spec1, common.Hash{}))
fund(db, requester, tokens(1000))
// Job 1: N=4, threshold=3. Find a request block where the weak operator is in
// the 4-of-7 draw (deterministic test setup), so it commits, withholds, and is
// slashed below MinStake.
var j1 common.Hash
var blk1 uint64
for b := uint64(10); b < 300; b++ {
jid, err := RequestInference(db, lg, requester, spec1, prompt1, 4, 3, tokens(1), b)
require.NoError(t, err)
if IsSelected(db, jid, weak) {
j1, blk1 = jid, b
break
}
}
require.NotEqual(t, common.Hash{}, j1, "no block selected the weak operator")
require.True(t, IsSelected(db, j1, weak))
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
commit1 := blk1 + 2
reveal1 := blk1 + CommitBlocks + 1
settle1 := blk1 + CommitBlocks + RevealBlocks + 1
for i := range uint32(4) {
op := SelectedAt(db, j1, i)
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, j1, op, ComputeCommit(j1, spec1, prompt1, answer, embed, op, nonce), commit1))
if op != weak {
require.NoError(t, RevealResponse(db, j1, op, answer, embed, nonce, reveal1))
}
}
_, err := Settle(db, lg, j1, settle1)
require.NoError(t, err)
// weak is now below MinStake → ineligible (eligibility re-checked at selection).
_, _, weakStake, _, _ := GetOperator(db, weak)
require.True(t, weakStake.Lt(MinStake), "weak operator slashed below MinStake")
// Only 6 operators remain eligible. N=6 needs margin requiredMargin(6)=3 → pool
// must be >= 9, but only 6 remain, so a large job is rejected by the RED-A margin
// (the under-min operator is NOT counted toward the pool).
_, err = RequestInference(db, lg, requester, spec1, prompt1, 6, 4, tokens(1), 400)
require.ErrorIs(t, err, ErrEligibleBelowMargin)
// A job that fits the remaining eligible pool (N=4, needs 6; 6 remain) succeeds
// and NEVER reselects the under-min operator.
j3, err := RequestInference(db, lg, requester, spec1, prompt1, 4, 3, tokens(1), 400)
require.NoError(t, err)
for i := range uint32(4) {
require.NotEqual(t, weak, SelectedAt(db, j3, i), "under-min operator must not be reselected")
}
}
// ===========================================================================
// ATTACK 6: Slash-griefing the honest minority.
// A majority cannot weaponize slashing against operators who honestly REVEAL a
// minority hash (e.g. legitimately different output). Default policy slashes
// withholding only, not dissent.
// ===========================================================================
func TestAttackHonestMinorityNotSlashed(t *testing.T) {
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
minority := common.HexToHash("0x0b66")
embed := common.HexToHash("0x0e3b")
// 3 reveal the majority answer; 2 HONESTLY reveal a different hash.
for i := range 5 {
nonce := common.BigToHash(big.NewInt(int64(i)))
out := answer
if i >= 3 {
out = minority
}
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, out, embed, sel[i], nonce), commitBlk))
require.NoError(t, RevealResponse(db, jobID, sel[i], out, embed, nonce, revealBlkAt))
}
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobSettled, res.Status)
require.Equal(t, answer, res.CanonicalHash)
// The two honest dissenters REVEALED → not slashed (SlashDissenters=false).
require.Zero(t, res.Slashed.Uint64(), "no one withheld → nothing slashed")
for i := 3; i < 5; i++ {
_, _, stake, _, _ := GetOperator(db, sel[i])
require.Equal(t, tokens(2).Uint64(), stake.Uint64(), "honest dissenter keeps full stake")
require.Zero(t, GetCredit(db, sel[i]).Uint64(), "dissenter earns no reward (didn't match canonical)")
}
}
// ===========================================================================
// ATTACK 7: Slash a selected operator who never committed.
// Only committed-then-withheld operators are culpable. A selected operator that
// never committed at all is NOT slashed (it simply forfeits the chance to earn).
// This prevents an attacker from forcing slashes on operators who opted out.
// ===========================================================================
func TestAttackNeverCommittedNotSlashed(t *testing.T) {
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
// 3 commit+reveal the answer; sel[3] commits but withholds; sel[4] NEVER
// commits at all.
for i := range 3 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[i], nonce), commitBlk))
require.NoError(t, RevealResponse(db, jobID, sel[i], answer, embed, nonce, revealBlkAt))
}
nonce3 := common.BigToHash(big.NewInt(3))
require.NoError(t, CommitResponse(db, jobID, sel[3], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[3], nonce3), commitBlk))
// sel[4]: nothing.
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobSettled, res.Status)
// Only sel[3] (committed, withheld) is slashed; sel[4] (never committed) is not.
require.Equal(t, SlashPerOperator.Uint64(), res.Slashed.Uint64(), "exactly one withholder slashed")
_, _, s3, _, _ := GetOperator(db, sel[3])
require.Equal(t, new(uint256.Int).Sub(tokens(2), SlashPerOperator).Uint64(), s3.Uint64())
_, _, s4, _, _ := GetOperator(db, sel[4])
require.Equal(t, tokens(2).Uint64(), s4.Uint64(), "never-committed operator is NOT slashed")
}
// ===========================================================================
// ATTACK 8: Threshold edge — a plurality that does NOT reach threshold.
// 2 + 2 + 1 split with threshold 3: there is a plurality (2) but no group meets
// the threshold → job must FAIL, not settle on the plurality.
// ===========================================================================
func TestAttackPluralityBelowThresholdFails(t *testing.T) {
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
embed := common.HexToHash("0x0e3b")
a := common.HexToHash("0x0a11")
b := common.HexToHash("0x0b22")
c := common.HexToHash("0x0c33")
outs := []common.Hash{a, a, b, b, c} // 2+2+1, max group = 2 < threshold 3
for i := range 5 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, outs[i], embed, sel[i], nonce), commitBlk))
require.NoError(t, RevealResponse(db, jobID, sel[i], outs[i], embed, nonce, revealBlkAt))
}
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobFailed, res.Status, "plurality below threshold must NOT settle")
require.Equal(t, common.Hash{}, GetCanonicalResult(db, jobID))
// Full escrow refunded; nobody paid.
require.Equal(t, tokens(5).Uint64(), GetCredit(db, requester).Uint64())
}
// ===========================================================================
// ATTACK 9: Threshold tie — two equal groups, neither reaching threshold.
// N=6, threshold=4, split 3+3 → no quorum (a tie cannot reach a strict-majority
// threshold), and the deterministic tie-break never invents a winner.
// ===========================================================================
func TestAttackThresholdTieNoQuorum(t *testing.T) {
db := newMockStateDB()
lg := newLedger(db)
jobID, sel := setupJob(t, db, lg, 6, 6, 4, tokens(1), 10)
embed := common.HexToHash("0x0e3b")
g1 := common.HexToHash("0x0a11")
g2 := common.HexToHash("0x0b22")
outs := []common.Hash{g1, g1, g1, g2, g2, g2} // 3+3, threshold 4
for i := range 6 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, outs[i], embed, sel[i], nonce), commitBlk))
require.NoError(t, RevealResponse(db, jobID, sel[i], outs[i], embed, nonce, revealBlkAt))
}
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobFailed, res.Status, "3+3 tie under threshold 4 → no quorum")
}
// ===========================================================================
// ATTACK 10: Exactly-threshold settles (boundary in the defender's favor).
// N=5, threshold=3, exactly 3 agree (2 reveal a different hash) → settles on the
// 3, pays them, does not pay the dissenters.
// ===========================================================================
func TestExactlyThresholdSettles(t *testing.T) {
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
other := common.HexToHash("0x0b66")
embed := common.HexToHash("0x0e3b")
outs := []common.Hash{answer, answer, answer, other, other}
for i := range 5 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, outs[i], embed, sel[i], nonce), commitBlk))
require.NoError(t, RevealResponse(db, jobID, sel[i], outs[i], embed, nonce, revealBlkAt))
}
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobSettled, res.Status)
require.Equal(t, answer, res.CanonicalHash)
require.Equal(t, uint32(3), res.WinnerCount)
// Winners paid; dissenters earn nothing (they revealed, so not slashed either).
for i := range 3 {
require.Positive(t, GetCredit(db, sel[i]).Uint64())
}
for i := 3; i < 5; i++ {
require.Zero(t, GetCredit(db, sel[i]).Uint64(), "non-canonical revealer earns nothing")
}
require.Zero(t, res.Slashed.Uint64(), "no withholders → nothing slashed")
}
// ===========================================================================
// ATTACK 11: Escrow drain / over-payment.
// Across ANY revealer configuration, total rewards paid never exceed the escrow
// (N*reward) plus the slashed pool, and the escrow account can never be driven
// negative. We assert the exact accounting on the happy path and check the
// invariant by reconstructing it.
// ===========================================================================
func TestAttackNoEscrowDrain(t *testing.T) {
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
// All 5 commit; 3 reveal the answer, 2 withhold (so there IS a slashed pool).
for i := range 5 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[i], nonce), commitBlk))
}
for i := range 3 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, RevealResponse(db, jobID, sel[i], answer, embed, nonce, revealBlkAt))
}
escrowBefore := db.GetBalance(ContractAddress) // = 5 stake*tokens(2) + 5*reward
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
// Total credited to winners + requester refund must EXACTLY equal the job
// escrow (5*reward) — slashed wei stays bonded-then-redistributed, it does not
// add to or subtract from the escrow ACCOUNT balance. So the escrow account is
// unchanged by settle (no real wei leaves until withdraw).
require.Equal(t, escrowBefore.Uint64(), db.GetBalance(ContractAddress).Uint64(),
"settle moves no wei out of the escrow account (credits only)")
// Reward paid (excluding slashed-pool share) cannot exceed N*reward.
require.LessOrEqual(t, res.Paid.Uint64(),
new(uint256.Int).Add(tokens(5), res.Slashed).Uint64(),
"payout bounded by escrow + slashed pool")
// Sum of all credits + remaining bonded stake == escrow account (conservation).
// setupJob registers 7 operators (N=5 + RED-A margin 2); the 2 unselected ones
// still hold bonded stake in the escrow account, so we sum over ALL registered
// operators, not just the selected set, or the identity would be short by their
// stake. The burned request fee is NOT in the escrow account (it went to
// BurnAddress), so it correctly does not appear in this identity.
credits := new(uint256.Int)
for i := range 7 {
credits.Add(credits, GetCredit(db, opAddr(i)))
_, _, stake, _, _ := GetOperator(db, opAddr(i))
credits.Add(credits, stake)
}
credits.Add(credits, GetCredit(db, requester))
require.Equal(t, db.GetBalance(ContractAddress).Uint64(), credits.Uint64(),
"escrow == sum(credits) + sum(bonded stake)")
}
// ===========================================================================
// ATTACK 12: Non-selected operator participates.
// A non-selected operator can neither commit (ErrNotSelected) nor reveal (it has
// no commit → ErrNotCommitted).
// ===========================================================================
func TestAttackNonSelectedCannotParticipate(t *testing.T) {
db, _, jobID, _ := adversarialJob(t, 6, 5, 3)
var outsider common.Address
for i := range 6 {
if !IsSelected(db, jobID, opAddr(i)) {
outsider = opAddr(i)
break
}
}
require.NotEqual(t, common.Address{}, outsider)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
nonce := common.BigToHash(big.NewInt(1))
commit := ComputeCommit(jobID, spec1, prompt1, answer, embed, outsider, nonce)
require.ErrorIs(t, CommitResponse(db, jobID, outsider, commit, commitBlk), ErrNotSelected)
require.ErrorIs(t, RevealResponse(db, jobID, outsider, answer, embed, nonce, revealBlkAt), ErrNotCommitted)
}
// ===========================================================================
// ATTACK 13: Settle, then re-drive the job.
// After settlement the job is no longer Committing, so further commits/reveals
// are rejected and a second settle is a replay no-op error. No post-settle state
// mutation is possible.
// ===========================================================================
func TestAttackPostSettleMutationDefeated(t *testing.T) {
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
for i := range 3 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[i], nonce), commitBlk))
require.NoError(t, RevealResponse(db, jobID, sel[i], answer, embed, nonce, revealBlkAt))
}
_, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
// A latecomer commit/reveal after settle → rejected (job not Committing).
nonce := common.BigToHash(big.NewInt(99))
require.ErrorIs(t, CommitResponse(db, jobID, sel[4], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[4], nonce), settleBlkAt), ErrJobNotCommitting)
require.ErrorIs(t, RevealResponse(db, jobID, sel[3], answer, embed, common.BigToHash(big.NewInt(1)), settleBlkAt), ErrJobNotCommitting)
// Replay settle → rejected.
_, err = Settle(db, lg, jobID, settleBlkAt)
require.ErrorIs(t, err, ErrJobAlreadySettled)
}
// ===========================================================================
// ATTACK 14: Withdraw stake without unbonding / before cooldown / twice.
// An operator cannot pull bonded stake while it could still be slashed on an
// open job: it must deregister and wait the cooldown, and can only withdraw once.
// ===========================================================================
func TestAttackEarlyStakeWithdrawDefeated(t *testing.T) {
db := newMockStateDB()
lg := newLedger(db)
op := opAddr(1)
db.balance[op] = tokens(10)
require.NoError(t, RegisterOperator(db, lg, op, tokens(2), spec1, common.Hash{}))
// Without deregister → rejected (can't yank stake out from under live jobs).
_, err := WithdrawStake(db, lg, op, 5)
require.ErrorIs(t, err, ErrOperatorUnbonding)
require.NoError(t, DeregisterOperator(db, op, 100))
// Before cooldown → rejected.
_, err = WithdrawStake(db, lg, op, 100+UnbondCooldownBlocks-1)
require.ErrorIs(t, err, ErrCooldownActive)
// After cooldown → ok once.
_, err = WithdrawStake(db, lg, op, 100+UnbondCooldownBlocks)
require.NoError(t, err)
// Twice → no record.
_, err = WithdrawStake(db, lg, op, 100+UnbondCooldownBlocks)
require.ErrorIs(t, err, ErrOperatorUnknown)
// Escrow fully drained for this operator.
require.Zero(t, db.GetBalance(ContractAddress).Uint64())
}
// ===========================================================================
// ATTACK 15: Withholding cannot flip an honest super-majority's result.
// Even if the would-be-dissenters withhold (rather than reveal), the honest
// majority's canonical hash still settles — withholding only costs the withholder
// a slash; it cannot change consensus or block settlement.
// ===========================================================================
func TestAttackWithholdingCannotBlockConsensus(t *testing.T) {
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
answer := common.HexToHash("0x0a55")
embed := common.HexToHash("0x0e3b")
// 4 commit the answer, 1 commits garbage. Only the 3 needed reveal; the 4th
// honest one and the garbage one both withhold. Quorum (3) is still reached.
for i := range 4 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, answer, embed, sel[i], nonce), commitBlk))
}
garbageNonce := common.BigToHash(big.NewInt(4))
require.NoError(t, CommitResponse(db, jobID, sel[4], ComputeCommit(jobID, spec1, prompt1, common.HexToHash("0x0bad"), embed, sel[4], garbageNonce), commitBlk))
for i := range 3 {
nonce := common.BigToHash(big.NewInt(int64(i)))
require.NoError(t, RevealResponse(db, jobID, sel[i], answer, embed, nonce, revealBlkAt))
}
res, err := Settle(db, lg, jobID, settleBlkAt)
require.NoError(t, err)
require.Equal(t, JobSettled, res.Status, "honest 3-quorum settles despite 2 withholders")
require.Equal(t, answer, res.CanonicalHash)
// Both withholders (sel[3] honest-but-silent, sel[4] garbage) committed and
// did not reveal → both slashed.
require.Equal(t, new(uint256.Int).Mul(SlashPerOperator, uint256.NewInt(2)).Uint64(), res.Slashed.Uint64())
}