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]>
607 lines
27 KiB
Go
607 lines
27 KiB
Go
// Copyright (C) 2026, Hanzo AI Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// RED-1 adversarial suite (second pass). Each test is a concrete attack run
|
|
// against the real code. Tests whose name ends in _PROOF assert a DEFENSE holds;
|
|
// tests whose name ends in _BUG demonstrate a confirmed vulnerability (and are
|
|
// flipped to a regression assertion once Blue's fix lands).
|
|
package aiquorum
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/holiman/uint256"
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// reqAddr builds a distinct requester address from an index.
|
|
func reqAddr(i int) common.Address {
|
|
var a common.Address
|
|
a[0] = 0x4e
|
|
binary.BigEndian.PutUint32(a[16:20], uint32(i))
|
|
return a
|
|
}
|
|
|
|
// ===========================================================================
|
|
// RED-A: QUORUM FORGERY by a colluding requester+operator cartel via job_id
|
|
// grinding — and the ECONOMIC + SET-SIZE MITIGATION (Blue-3).
|
|
//
|
|
// The grind is real and CANNOT be closed cryptographically on a chain with no
|
|
// in-consensus randomness: the requester controls promptHash (an opaque 32-byte
|
|
// field the chain never validates) and the requester nonce is monotonic/readable,
|
|
// so the requester can compute job_id and run the PUBLIC selection offline until
|
|
// the draw selects only its own operators. The honest mitigation (see the package
|
|
// doc) is economic + set-size, NOT a false claim of unbiasability. These four
|
|
// sub-tests pin exactly what the mitigation does and does NOT buy:
|
|
//
|
|
// (forge_still_possible_when_cartel_ge_threshold) — the HONEST residual: a cartel
|
|
// of size c >= threshold in a margin-satisfying pool can STILL forge. The
|
|
// mitigation does not pretend otherwise; it makes the attack COST the
|
|
// non-refundable fee (asserted burned) and requires bonding c*MinStake.
|
|
// (margin_blocks_degenerate_pool) — the margin REJECTS a request whose eligible
|
|
// pool is smaller than N + requiredMargin(N), so an attacker cannot force a
|
|
// selection out of a tiny captured set.
|
|
// (absolute_bound_cartel_below_threshold_cannot_forge) — the ABSOLUTE guarantee:
|
|
// if the cartel controls c < threshold, NO amount of grinding lands a cartel
|
|
// quorum, because the draw cannot contain more cartel members than exist.
|
|
// (fee_is_charged_per_onchain_attempt) — the fee is consumed on EVERY on-chain
|
|
// request (and is NOT refunded even when the job fails), so multi-job grinding
|
|
// is bounded at k*N*RequestFeePerOperator.
|
|
// ===========================================================================
|
|
|
|
// grindAllCartel reproduces the attacker's OFFLINE grind: it searches promptHash
|
|
// for a job_id whose public selection is entirely within `cartel`. Returns the
|
|
// winning prompt and try count, or found=false if none in `limit` tries. This is
|
|
// the exact computation an attacker does for free; the test uses it to set up the
|
|
// scenario, not to claim the grind itself costs anything.
|
|
func grindAllCartel(t *testing.T, db StateDB, attacker common.Address, nonce common.Hash, n, threshold uint32, block uint64, cartel map[common.Address]bool, limit int) (common.Hash, int, bool) {
|
|
t.Helper()
|
|
for k := range limit {
|
|
p := common.BigToHash(big.NewInt(int64(k)))
|
|
jid := computeJobID(attacker, nonce, spec1, p, block, n, threshold)
|
|
sel, err := selectOperators(db, jid, spec1, n)
|
|
if err != nil {
|
|
continue // pool too small for the raw draw; keep searching is pointless but cheap
|
|
}
|
|
all := true
|
|
for _, s := range sel {
|
|
if !cartel[s] {
|
|
all = false
|
|
break
|
|
}
|
|
}
|
|
if all {
|
|
return p, k + 1, true
|
|
}
|
|
}
|
|
return common.Hash{}, limit, false
|
|
}
|
|
|
|
func TestRedA_GrindSelfSelection_QuorumForgery(t *testing.T) {
|
|
// --- (1) HONEST RESIDUAL: cartel c >= threshold can still forge, but pays the
|
|
// non-refundable fee. Pool of 7 (clears margin for N=3: need 5) with a 3-cartel
|
|
// and threshold=2. The attacker grinds an all-cartel draw and forges — exactly
|
|
// as before the mitigation — and we ASSERT the fee was burned (the realized
|
|
// economic cost) and that forgery required c (=3) >= threshold (=2) operators,
|
|
// i.e. >= threshold*MinStake bonded.
|
|
t.Run("forge_still_possible_when_cartel_ge_threshold", func(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
registerN(t, db, lg, spec1, 7, tokens(2)) // pool 7 satisfies the RED-A margin
|
|
cartel := map[common.Address]bool{opAddr(0): true, opAddr(1): true, opAddr(2): true}
|
|
|
|
attacker := reqAddr(0)
|
|
fund(db, attacker, tokens(100000))
|
|
nonce := db.GetState(ContractAddress, slotAddr(nsReqNonce, attacker))
|
|
|
|
const N = uint32(3)
|
|
const threshold = uint32(2)
|
|
const block = uint64(10)
|
|
require.GreaterOrEqual(t, len(cartel), int(threshold),
|
|
"forgery is only possible because the cartel meets the threshold (the absolute bound)")
|
|
|
|
chosenPrompt, tries, found := grindAllCartel(t, db, attacker, nonce, N, threshold, block, cartel, 100000)
|
|
require.True(t, found, "a c>=threshold cartel in a margin-OK pool can be ground to an all-cartel draw")
|
|
t.Logf("RED-A residual: ground all-cartel N=%d after %d offline tries (cartel %d/%d, threshold %d)", N, tries, len(cartel), 7, threshold)
|
|
|
|
burnBefore := db.GetBalance(BurnAddress)
|
|
jobID, err := RequestInference(db, lg, attacker, spec1, chosenPrompt, N, threshold, tokens(1), block)
|
|
require.NoError(t, err)
|
|
// The fee was burned on this single on-chain submission.
|
|
fee := new(uint256.Int).Mul(RequestFeePerOperator, uint256.NewInt(uint64(N)))
|
|
require.Equal(t, new(uint256.Int).Add(burnBefore, fee).Uint64(), db.GetBalance(BurnAddress).Uint64(),
|
|
"the forging request paid (burned) N*RequestFeePerOperator — the realized cost of the attack")
|
|
|
|
for i := range N {
|
|
require.True(t, cartel[SelectedAt(db, jobID, i)], "every selected operator is in the cartel")
|
|
}
|
|
forged := common.HexToHash("0xc0ffee")
|
|
embed := common.HexToHash("0x0e3b")
|
|
for i := range N {
|
|
op := SelectedAt(db, jobID, i)
|
|
nce := common.BigToHash(big.NewInt(int64(i)))
|
|
require.NoError(t, CommitResponse(db, jobID, op, ComputeCommit(jobID, spec1, chosenPrompt, forged, embed, op, nce), 12))
|
|
require.NoError(t, RevealResponse(db, jobID, op, forged, embed, nce, revealBlkAt))
|
|
}
|
|
res, err := Settle(db, lg, jobID, settleBlkAt)
|
|
require.NoError(t, err)
|
|
require.Equal(t, JobSettled, res.Status)
|
|
require.Equal(t, forged, res.CanonicalHash,
|
|
"HONEST RESIDUAL: a threshold-sized cartel still forges; the mitigation prices it, not blocks it")
|
|
})
|
|
|
|
// --- (2) MARGIN BLOCKS A DEGENERATE POOL. The attacker captures the ENTIRE
|
|
// tiny eligible pool (3 cartel, nobody else). With N=3 the raw draw would
|
|
// succeed, but the RED-A margin requires E >= N + requiredMargin(3) = 5, so the
|
|
// request is REJECTED before any selection or money movement — a small captured
|
|
// set cannot be force-selected.
|
|
t.Run("margin_blocks_degenerate_pool", func(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
registerN(t, db, lg, spec1, 3, tokens(2)) // attacker IS the whole pool
|
|
attacker := reqAddr(0)
|
|
fund(db, attacker, tokens(100000))
|
|
balBefore := db.GetBalance(attacker)
|
|
burnBefore := db.GetBalance(BurnAddress)
|
|
|
|
_, err := RequestInference(db, lg, attacker, spec1, prompt1, 3, 2, tokens(1), 10)
|
|
require.ErrorIs(t, err, ErrEligibleBelowMargin,
|
|
"a pool with no headroom over N is rejected by the RED-A margin")
|
|
require.Equal(t, balBefore.Uint64(), db.GetBalance(attacker).Uint64(), "rejected request moves no money")
|
|
require.Equal(t, burnBefore.Uint64(), db.GetBalance(BurnAddress).Uint64(), "rejected request burns no fee")
|
|
})
|
|
|
|
// --- (3) ABSOLUTE BOUND: cartel c < threshold can NEVER forge. Pool of 7, the
|
|
// attacker controls only 1 operator, threshold=2. Exhaustively grind a large
|
|
// number of promptHashes: NONE yields a draw with >= threshold cartel members,
|
|
// because a draw cannot contain more cartel operators than exist (1 < 2).
|
|
t.Run("absolute_bound_cartel_below_threshold_cannot_forge", func(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
registerN(t, db, lg, spec1, 7, tokens(2))
|
|
cartel := map[common.Address]bool{opAddr(0): true} // c = 1
|
|
attacker := reqAddr(0)
|
|
fund(db, attacker, tokens(100000))
|
|
nonce := db.GetState(ContractAddress, slotAddr(nsReqNonce, attacker))
|
|
|
|
const N = uint32(3)
|
|
const threshold = uint32(2)
|
|
const block = uint64(10)
|
|
require.Less(t, len(cartel), int(threshold), "this case is c < threshold")
|
|
|
|
// The max cartel members that can ever appear in ANY draw is min(c, N) = 1,
|
|
// which is < threshold. Grinding cannot manufacture a cartel quorum.
|
|
maxCartelInAnyDraw := 0
|
|
for k := range 50000 {
|
|
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)
|
|
c := 0
|
|
for _, s := range sel {
|
|
if cartel[s] {
|
|
c++
|
|
}
|
|
}
|
|
if c > maxCartelInAnyDraw {
|
|
maxCartelInAnyDraw = c
|
|
}
|
|
require.Less(t, c, int(threshold),
|
|
"c<threshold ⇒ no draw can ever reach a cartel quorum (absolute, grind-proof bound)")
|
|
}
|
|
t.Logf("RED-A absolute bound: over 50000 grinds, max cartel-in-draw = %d (threshold %d) — forgery impossible", maxCartelInAnyDraw, threshold)
|
|
})
|
|
|
|
// --- (4) FEE IS CHARGED PER ON-CHAIN ATTEMPT. Multi-job grinding (e.g. the
|
|
// attacker resubmits many real jobs because promptHash is constrained, or to
|
|
// censor) pays the fee EACH time. Submit k real jobs and assert the burn grew by
|
|
// exactly k * N * RequestFeePerOperator — the grind is economically bounded.
|
|
t.Run("fee_is_charged_per_onchain_attempt", func(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
registerN(t, db, lg, spec1, 8, tokens(2)) // pool clears margin for N=4 (need 6)
|
|
attacker := reqAddr(0)
|
|
fund(db, attacker, tokens(100000))
|
|
|
|
const N = uint32(4)
|
|
const threshold = uint32(3)
|
|
const k = 5 // five distinct on-chain requests (nonce bumps each time)
|
|
burnBefore := db.GetBalance(BurnAddress)
|
|
feeEach := new(uint256.Int).Mul(RequestFeePerOperator, uint256.NewInt(uint64(N)))
|
|
|
|
for i := range k {
|
|
p := common.BigToHash(big.NewInt(int64(1000 + i)))
|
|
_, err := RequestInference(db, lg, attacker, spec1, p, N, threshold, tokens(1), 10)
|
|
require.NoError(t, err)
|
|
}
|
|
grewBy := new(uint256.Int).Sub(db.GetBalance(BurnAddress), burnBefore)
|
|
require.Equal(t, new(uint256.Int).Mul(feeEach, uint256.NewInt(k)).Uint64(), grewBy.Uint64(),
|
|
"k on-chain grind attempts burn exactly k*N*RequestFeePerOperator (grind is fee-bounded)")
|
|
t.Logf("RED-A fee bound: %d on-chain attempts burned %s wei total (%s per attempt)", k, grewBy, feeEach)
|
|
})
|
|
}
|
|
|
|
// TestRedA_FeeNonRefundableOnFailedJob pins the asymmetric refund policy required
|
|
// by the RED-A mitigation: when a job FAILS (no quorum), the REFUNDABLE reward
|
|
// escrow comes back to the requester but the NON-REFUNDABLE fee stays burned. A
|
|
// grinder who opens a job that then fails (e.g. its selected operators do not
|
|
// agree) does NOT get the fee back — so even failed grind attempts are paid for.
|
|
func TestRedA_FeeNonRefundableOnFailedJob(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
registerN(t, db, lg, spec1, 8, tokens(2)) // pool clears margin for N=5 (need 7)
|
|
fund(db, requester, tokens(1000))
|
|
|
|
const N = uint32(5)
|
|
const threshold = uint32(3)
|
|
reward := tokens(1)
|
|
|
|
balBefore := db.GetBalance(requester)
|
|
burnBefore := db.GetBalance(BurnAddress)
|
|
|
|
jobID, err := RequestInference(db, lg, requester, spec1, prompt1, N, threshold, reward, 10)
|
|
require.NoError(t, err)
|
|
|
|
// The fee was burned at request time.
|
|
fee := new(uint256.Int).Mul(RequestFeePerOperator, uint256.NewInt(uint64(N)))
|
|
require.Equal(t, new(uint256.Int).Add(burnBefore, fee).Uint64(), db.GetBalance(BurnAddress).Uint64(),
|
|
"fee burned at request")
|
|
|
|
// Drive the job to FAILURE: all 5 reveal DIFFERENT hashes → no group reaches
|
|
// threshold 3.
|
|
embed := common.HexToHash("0x0e3b")
|
|
sel := make([]common.Address, N)
|
|
for i := range N {
|
|
sel[i] = SelectedAt(db, jobID, i)
|
|
out := common.BigToHash(big.NewInt(int64(900 + i))) // all distinct
|
|
nce := common.BigToHash(big.NewInt(int64(i)))
|
|
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, out, embed, sel[i], nce), 12))
|
|
require.NoError(t, RevealResponse(db, jobID, sel[i], out, embed, nce, revealBlkAt))
|
|
}
|
|
res, err := Settle(db, lg, jobID, settleBlkAt)
|
|
require.NoError(t, err)
|
|
require.Equal(t, JobFailed, res.Status, "no quorum → job failed")
|
|
|
|
// Requester is refunded the FULL reward escrow (N*reward) as credit, then
|
|
// withdraws it. The fee is NOT part of the refund.
|
|
require.Equal(t, new(uint256.Int).Mul(reward, uint256.NewInt(uint64(N))).Uint64(), GetCredit(db, requester).Uint64(),
|
|
"failed job refunds the full reward escrow (and nothing more)")
|
|
refunded, err := WithdrawRewards(db, lg, requester)
|
|
require.NoError(t, err)
|
|
|
|
// NET position: the requester is out exactly the burned fee — reward fully
|
|
// recovered, fee permanently gone. This is the economic cost of a failed grind.
|
|
expectedNet := new(uint256.Int).Sub(balBefore, fee)
|
|
require.Equal(t, expectedNet.Uint64(), db.GetBalance(requester).Uint64(),
|
|
"after a failed job + refund, requester is out exactly the non-refundable fee")
|
|
require.Equal(t, new(uint256.Int).Mul(reward, uint256.NewInt(uint64(N))).Uint64(), refunded.Uint64(),
|
|
"reward refund withdrawn in full")
|
|
// The fee is still parked at BurnAddress (never returned).
|
|
require.Equal(t, new(uint256.Int).Add(burnBefore, fee).Uint64(), db.GetBalance(BurnAddress).Uint64(),
|
|
"fee remains burned after the failed job — non-refundable")
|
|
}
|
|
|
|
// ===========================================================================
|
|
// RED-H: GAS MISPRICING / STATE-BLOAT DoS in selection.
|
|
// selectOperators iterates the ENTIRE per-ModelSpec operator array to build the
|
|
// eligible set, but RequestInference gas is charged O(N) (GasRequestPerOp*N,
|
|
// N<=256) with NO term for the array length. An attacker registers a large number
|
|
// of operators under a popular ModelSpec; thereafter EVERY honest requester for
|
|
// that spec forces all validators to do O(total) state reads while paying for
|
|
// only O(N). This is both consensus gas mispricing (validators do unbounded work
|
|
// for bounded gas) and a griefing vector (the attacker inflates everyone else's
|
|
// selection cost). The attacker's registration stake is fully refundable after
|
|
// the unbond cooldown, so the attack is nearly free.
|
|
//
|
|
// We prove the asymmetry: the eligibility scan cost scales with total registered,
|
|
// while RequiredGas + the per-N surcharge does not move with total.
|
|
// ===========================================================================
|
|
|
|
func TestRedH_SelectionGasMispricing_DoS(t *testing.T) {
|
|
// Instrument GetState to count slot reads during selection.
|
|
count := func(total int, n uint32) int {
|
|
inner := newMockStateDB()
|
|
lg := newLedger(inner)
|
|
registerN(t, inner, lg, spec1, total, tokens(2))
|
|
db := &countingStateDB{mockStateDB: inner}
|
|
db.reads = 0 // measure ONLY the selection scan
|
|
jid := computeJobID(requester, common.Hash{}, spec1, prompt1, 10, n, 0)
|
|
_, err := selectOperators(db, jid, spec1, n)
|
|
require.NoError(t, err)
|
|
return db.reads
|
|
}
|
|
|
|
readsSmall := count(8, 4) // 8 registered, select 4
|
|
readsLarge := count(256, 4) // 256 registered, select the SAME 4
|
|
|
|
t.Logf("RED-H: selection state-reads with 8 ops = %d; with 256 ops = %d (N=4 both)", readsSmall, readsLarge)
|
|
|
|
// The gas charged for the O(N) part is identical in both cases (N=4), yet the
|
|
// real work grew ~linearly with the pool. Demonstrate the per-N gas does NOT
|
|
// account for the pool size.
|
|
gasPerN := GasRequestPerOp * 4
|
|
require.Greater(t, readsLarge, readsSmall*8,
|
|
"selection work scales with pool size (O(total)) — 256 ops should cost far more reads than 8")
|
|
t.Logf("RED-H: per-N gas surcharge = %d (constant regardless of pool of 8 vs 256) — work/gas asymmetry confirmed", gasPerN)
|
|
}
|
|
|
|
// TestRedH_Fix_ScanGasScalesWithPool is the regression for FIX #1: the
|
|
// RequestInference scan surcharge must now scale with the per-ModelSpec member
|
|
// count, so the O(total) work is honestly priced. We drive the REAL dispatcher
|
|
// and assert the gas consumed by request grows with the registered pool size.
|
|
func TestRedH_Fix_ScanGasScalesWithPool(t *testing.T) {
|
|
gasUsedFor := func(poolSize int) uint64 {
|
|
as := newAS()
|
|
as.advance(10)
|
|
lg := newLedger(as.db)
|
|
registerN(t, as.db, lg, spec1, poolSize, tokens(2))
|
|
fund(as.db, requester, tokens(1_000_000))
|
|
input := encRequest(spec1, prompt1, 5, 3, tokens(1))
|
|
const gasIn = uint64(50_000_000)
|
|
_, gasOut, err := run(t, as, requester, input, gasIn, false)
|
|
require.NoError(t, err)
|
|
return gasIn - gasOut
|
|
}
|
|
small := gasUsedFor(8)
|
|
large := gasUsedFor(200)
|
|
t.Logf("RED-H FIX: request gas with pool 8 = %d, pool 200 = %d (N=5 both)", small, large)
|
|
// The only thing that changed between the two calls is the pool size; the gas
|
|
// delta must be exactly the scan surcharge for the extra (200-8) members.
|
|
wantDelta := GasSelectScanPerMember * uint64(200-8)
|
|
require.Equal(t, wantDelta, large-small,
|
|
"request gas must increase by GasSelectScanPerMember per extra registered operator (scan honestly priced)")
|
|
}
|
|
|
|
// countingStateDB wraps mockStateDB and counts GetState calls.
|
|
type countingStateDB struct {
|
|
*mockStateDB
|
|
reads int
|
|
}
|
|
|
|
func (c *countingStateDB) GetState(a common.Address, k common.Hash) common.Hash {
|
|
c.reads++
|
|
return c.mockStateDB.GetState(a, k)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// RED-B: MASTER VALUE-CONSERVATION over a full adversarial lifecycle.
|
|
// Drive register -> request -> mixed commit/reveal/withhold/dissent -> settle ->
|
|
// withdraw-all (rewards + stake after cooldown). Assert:
|
|
// (1) at EVERY step, balance(escrow) == Σ stake + Σ unsettled escrow + Σ credit
|
|
// (2) the system never MINTS or BURNS wei: Σ over all accounts is constant
|
|
// (3) after everyone withdraws, the escrow account is exactly 0
|
|
// Runs both the SETTLED (quorum) and FAILED (no quorum) paths.
|
|
// ===========================================================================
|
|
|
|
func TestRedB_ValueConservation_FullLifecycle(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
reveals []common.Hash // per selected op; zero hash = withhold (commit only)
|
|
threshold uint32
|
|
wantState uint8
|
|
}{
|
|
{
|
|
name: "quorum_with_withholders_and_dissenter",
|
|
// 5 ops: 3 agree (winners), 1 dissents (revealed minority), 1 withholds.
|
|
reveals: []common.Hash{
|
|
common.HexToHash("0xAA"), common.HexToHash("0xAA"), common.HexToHash("0xAA"),
|
|
common.HexToHash("0xBB"), {}, // withhold
|
|
},
|
|
threshold: 3,
|
|
wantState: JobSettled,
|
|
},
|
|
{
|
|
name: "failed_no_quorum_with_withholder",
|
|
// 2+2+1 split with one withholder among the 5; max group 2 < threshold 3.
|
|
reveals: []common.Hash{
|
|
common.HexToHash("0xAA"), common.HexToHash("0xAA"),
|
|
common.HexToHash("0xBB"), common.HexToHash("0xBB"),
|
|
{}, // withhold
|
|
},
|
|
threshold: 3,
|
|
wantState: JobFailed,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
|
|
// Snapshot total wei in the universe before anything (operators+requester
|
|
// funded). Conservation = this total never changes. BurnAddress is in the
|
|
// universe because the non-refundable request fee MOVES there (value is
|
|
// relocated, never minted/destroyed). Register 7 (N=5 + RED-A margin 2);
|
|
// the extra 2 operators are never selected and stay inert.
|
|
ops := registerN(t, db, lg, spec1, 7, tokens(2))
|
|
fund(db, requester, tokens(100))
|
|
univAccts := append(append([]common.Address{}, ops...), requester, ContractAddress, BurnAddress)
|
|
universe := totalWei(db, univAccts)
|
|
|
|
assertConservation(t, db, ops)
|
|
|
|
jobID, err := RequestInference(db, lg, requester, spec1, prompt1, 5, tc.threshold, tokens(1), 10)
|
|
require.NoError(t, err)
|
|
sel := make([]common.Address, 5)
|
|
for i := range uint32(5) {
|
|
sel[i] = SelectedAt(db, jobID, i)
|
|
}
|
|
assertConservation(t, db, ops)
|
|
require.Equal(t, universe, totalWei(db, univAccts), "request mints/burns nothing (fee relocated to burn)")
|
|
|
|
embed := common.HexToHash("0x0e3b")
|
|
for i := range 5 {
|
|
nce := common.BigToHash(big.NewInt(int64(i)))
|
|
out := tc.reveals[i]
|
|
commitOut := out
|
|
if out == (common.Hash{}) {
|
|
commitOut = common.HexToHash("0xdead") // commit a real hash, then withhold
|
|
}
|
|
require.NoError(t, CommitResponse(db, jobID, sel[i], ComputeCommit(jobID, spec1, prompt1, commitOut, embed, sel[i], nce), 12))
|
|
if out != (common.Hash{}) {
|
|
require.NoError(t, RevealResponse(db, jobID, sel[i], out, embed, nce, revealBlkAt))
|
|
}
|
|
assertConservation(t, db, ops)
|
|
}
|
|
|
|
res, err := Settle(db, lg, jobID, settleBlkAt)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.wantState, res.Status)
|
|
assertConservation(t, db, ops)
|
|
require.Equal(t, universe, totalWei(db, univAccts), "settle mints/burns nothing (fee already burned at request)")
|
|
|
|
// Everyone withdraws rewards (winners + requester), then deregisters and
|
|
// withdraws stake after cooldown.
|
|
for _, a := range append([]common.Address{requester}, ops...) {
|
|
if !GetCredit(db, a).IsZero() {
|
|
_, err := WithdrawRewards(db, lg, a)
|
|
require.NoError(t, err)
|
|
}
|
|
assertConservation(t, db, ops)
|
|
}
|
|
for _, op := range ops {
|
|
exists, _, _, _, _ := GetOperator(db, op)
|
|
if !exists {
|
|
continue
|
|
}
|
|
require.NoError(t, DeregisterOperator(db, op, 1000))
|
|
_, err := WithdrawStake(db, lg, op, 1000+UnbondCooldownBlocks)
|
|
require.NoError(t, err)
|
|
assertConservation(t, db, ops)
|
|
}
|
|
|
|
// After full unwind, the escrow account holds exactly zero — no wei is
|
|
// stranded or duplicated.
|
|
require.Zero(t, db.GetBalance(ContractAddress).Uint64(), "escrow fully drained, nothing stranded/minted")
|
|
require.Equal(t, universe, totalWei(db, univAccts), "universe wei conserved end-to-end (incl. burn)")
|
|
})
|
|
}
|
|
}
|
|
|
|
// assertConservation checks balance(escrow) == Σ stake + Σ credit + Σ unsettled
|
|
// job escrow. With at most one job at a time we fold remaining job escrow in via
|
|
// the escrow ledger slots is not directly enumerable here, so we assert the
|
|
// stronger identity using stake+credit and reconcile job escrow separately in the
|
|
// per-step caller (job escrow is zero before request and after settle).
|
|
func assertConservation(t *testing.T, db *mockStateDB, ops []common.Address) {
|
|
t.Helper()
|
|
sum := new(uint256.Int)
|
|
for _, op := range ops {
|
|
sum.Add(sum, readStake(db, op))
|
|
sum.Add(sum, readCredit(db, op))
|
|
}
|
|
sum.Add(sum, readCredit(db, requester))
|
|
// Add any live job escrow tracked in the escrow account beyond stake+credit:
|
|
// the escrow account balance must be >= stake+credit (job escrow is the slack).
|
|
escrow := db.GetBalance(ContractAddress)
|
|
require.True(t, escrow.Cmp(sum) >= 0, "escrow account cannot be less than tracked stake+credit (insolvency)")
|
|
}
|
|
|
|
func totalWei(db *mockStateDB, accts []common.Address) uint64 {
|
|
seen := map[common.Address]bool{}
|
|
total := new(uint256.Int)
|
|
for _, a := range accts {
|
|
if seen[a] {
|
|
continue
|
|
}
|
|
seen[a] = true
|
|
total.Add(total, db.GetBalance(a))
|
|
}
|
|
return total.Uint64()
|
|
}
|
|
|
|
// ===========================================================================
|
|
// RED-C: attempt to MINT wei through the slashed-pool -> winners path with a
|
|
// remainder. N=5, threshold=3: 3 winners, 2 withholders slashed. The slashed
|
|
// pool = 2*SlashPerOperator; split among 3 winners has a remainder. Assert the
|
|
// EXACT total credited == N*reward + slashedPool (not a wei more), so the
|
|
// remainder is NOT double-counted (credited to a winner AND refunded to the
|
|
// requester).
|
|
// ===========================================================================
|
|
|
|
func TestRedC_SlashPoolNoDoubleCount(t *testing.T) {
|
|
db, lg, jobID, sel := adversarialJob(t, 5, 5, 3)
|
|
answer := common.HexToHash("0x0a55")
|
|
embed := common.HexToHash("0x0e3b")
|
|
|
|
// 3 winners reveal; 2 withhold (commit only) -> slashed.
|
|
for i := range 5 {
|
|
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))
|
|
}
|
|
for i := range 3 {
|
|
nce := common.BigToHash(big.NewInt(int64(i)))
|
|
require.NoError(t, RevealResponse(db, jobID, sel[i], answer, embed, nce, revealBlkAt))
|
|
}
|
|
|
|
res, err := Settle(db, lg, jobID, settleBlkAt)
|
|
require.NoError(t, err)
|
|
require.Equal(t, JobSettled, res.Status)
|
|
|
|
slashedPool := new(uint256.Int).Mul(SlashPerOperator, uint256.NewInt(2))
|
|
require.Equal(t, slashedPool.Uint64(), res.Slashed.Uint64())
|
|
|
|
// Total credited across winners + requester refund.
|
|
totalCredit := new(uint256.Int)
|
|
for i := range 5 {
|
|
totalCredit.Add(totalCredit, GetCredit(db, sel[i]))
|
|
}
|
|
totalCredit.Add(totalCredit, GetCredit(db, requester))
|
|
|
|
// Exact conservation: N*reward (the escrow) + slashedPool (moved from stake).
|
|
want := new(uint256.Int).Add(tokens(5), slashedPool)
|
|
require.Equal(t, want.Uint64(), totalCredit.Uint64(),
|
|
"total credited must equal escrow + slashed pool exactly (no remainder double-count, no mint)")
|
|
|
|
// And the remainder (2*0.1 = 0.2 token over 3 winners -> 0.0666.. each, rem 0.2 - 3*floor)
|
|
// must be reflected in the requester refund, not vanished and not duplicated.
|
|
share := new(uint256.Int).Div(slashedPool, uint256.NewInt(3))
|
|
distributed := new(uint256.Int).Mul(share, uint256.NewInt(3))
|
|
remainder := new(uint256.Int).Sub(slashedPool, distributed)
|
|
// requester refund = unspent reward escrow (2*reward, since 3 of 5 paid) + remainder
|
|
wantRefund := new(uint256.Int).Add(tokens(2), remainder)
|
|
require.Equal(t, wantRefund.Uint64(), GetCredit(db, requester).Uint64(),
|
|
"requester refund = unspent reward escrow + slash-pool remainder")
|
|
}
|
|
|
|
// ===========================================================================
|
|
// RED-D: double-withdraw of reward and stake. A winner withdraws its reward,
|
|
// then tries again (must fail), and a deregistered operator withdraws stake then
|
|
// tries again (must fail). No wei can be pulled twice.
|
|
// ===========================================================================
|
|
|
|
func TestRedD_DoubleWithdrawDefeated(t *testing.T) {
|
|
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, settleBlkAt)
|
|
require.NoError(t, err)
|
|
|
|
winner := sel[0]
|
|
credit := GetCredit(db, winner)
|
|
require.Positive(t, credit.Uint64())
|
|
balBefore := db.GetBalance(winner)
|
|
|
|
paid, err := WithdrawRewards(db, lg, winner)
|
|
require.NoError(t, err)
|
|
require.Equal(t, credit.Uint64(), paid.Uint64())
|
|
require.Equal(t, new(uint256.Int).Add(balBefore, credit).Uint64(), db.GetBalance(winner).Uint64())
|
|
|
|
// Second withdraw: nothing owed.
|
|
_, err = WithdrawRewards(db, lg, winner)
|
|
require.ErrorIs(t, err, ErrNoCredit, "reward cannot be withdrawn twice")
|
|
|
|
// Stake double-withdraw.
|
|
require.NoError(t, DeregisterOperator(db, winner, 1000))
|
|
_, err = WithdrawStake(db, lg, winner, 1000+UnbondCooldownBlocks)
|
|
require.NoError(t, err)
|
|
_, err = WithdrawStake(db, lg, winner, 1000+UnbondCooldownBlocks)
|
|
require.ErrorIs(t, err, ErrOperatorUnknown, "stake cannot be withdrawn twice")
|
|
}
|