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]>
641 lines
25 KiB
Go
641 lines
25 KiB
Go
// Copyright (C) 2026, Hanzo AI Inc. All rights reserved.
|
|
// RED-2 adversarial exploit battery. NOT part of Blue's suite.
|
|
// Each test is an attack attempt; the assertion documents whether it succeeds.
|
|
|
|
package aimarket
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/holiman/uint256"
|
|
"github.com/luxfi/crypto"
|
|
gethsecp "github.com/luxfi/crypto/secp256k1"
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// secp256k1 group order n.
|
|
var secp256k1N, _ = new(big.Int).SetString("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16)
|
|
|
|
// malleate flips an ECDSA signature [R||S||V] to its malleable twin
|
|
// [R || n-S || V^1]. This is the canonical signature-malleability identity:
|
|
// it recovers to the SAME public key over the SAME digest.
|
|
func malleate(sig []byte) []byte {
|
|
require := sig // keep linter calm; not used
|
|
_ = require
|
|
out := make([]byte, 65)
|
|
copy(out, sig)
|
|
s := new(big.Int).SetBytes(sig[32:64])
|
|
sPrime := new(big.Int).Sub(secp256k1N, s)
|
|
sp := sPrime.Bytes()
|
|
// left-pad to 32
|
|
for i := range out[32:64] {
|
|
out[32+i] = 0
|
|
}
|
|
copy(out[64-len(sp):64], sp)
|
|
out[64] = sig[64] ^ 1 // flip recovery bit
|
|
return out
|
|
}
|
|
|
|
// ATTACK 1a: ground-truth — does luxfi/crypto recovery accept a high-s
|
|
// (non-canonical) signature at all, and does it recover to the same signer?
|
|
func TestRed_Malleability_GroundTruth(t *testing.T) {
|
|
k, err := crypto.GenerateKey()
|
|
require.NoError(t, err)
|
|
signer := crypto.PubkeyToAddress(k.PublicKey)
|
|
|
|
digest := crypto.Keccak256([]byte("attack-digest-padded-to-32-bytesXX"))[:32]
|
|
sig, err := crypto.Sign(digest, k)
|
|
require.NoError(t, err)
|
|
|
|
// The canonical signature from Sign() is low-s. Recover from it.
|
|
pub1, err := crypto.SigToPub(digest, sig)
|
|
require.NoError(t, err)
|
|
require.Equal(t, signer, crypto.PubkeyToAddress(*pub1))
|
|
|
|
// Malleate it (s -> n-s, v flipped).
|
|
bad := malleate(sig)
|
|
pub2, err := crypto.SigToPub(digest, bad)
|
|
if err != nil {
|
|
t.Logf("malleated sig REJECTED by recovery: %v (low-s enforced at recover layer)", err)
|
|
return
|
|
}
|
|
rec := crypto.PubkeyToAddress(*pub2)
|
|
t.Logf("malleated sig ACCEPTED by recovery; recovers to %x (signer=%x)", rec[:4], signer[:4])
|
|
// The malleability identity: a high-s twin recovers to the SAME signer.
|
|
require.Equal(t, signer, rec,
|
|
"malleated high-s signature recovered to a DIFFERENT address — that would be an impersonation primitive")
|
|
}
|
|
|
|
// ATTACK 1b: can a malleated-but-valid operator signature settle a record a
|
|
// SECOND time (double-settle) after the canonical one already settled it?
|
|
func TestRed_Malleability_CannotDoubleSettle(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
opKey, op := redKeypair(t)
|
|
fund(db, caller, 100_000_000_000)
|
|
require.NoError(t, RegisterOffering(db, op, model, uint256.NewInt(1_000_000_000), 4096))
|
|
|
|
recordId, _, err := RequestInference(db, lg, caller, op, model, 2000)
|
|
require.NoError(t, err)
|
|
|
|
resultHash := common.HexToHash("0xdeadbeef")
|
|
digest := crypto.Keccak256(recordId.Bytes(), resultHash.Bytes())
|
|
sig, err := crypto.Sign(digest, opKey)
|
|
require.NoError(t, err)
|
|
|
|
// First settle with canonical sig.
|
|
_, err = SettleInference(db, recordId, resultHash, sig, DefaultAttest)
|
|
require.NoError(t, err)
|
|
|
|
// Malleated twin is a DIFFERENT 65-byte string but a valid attestation.
|
|
bad := malleate(sig)
|
|
require.NotEqual(t, sig, bad, "malleated sig must differ byte-wise")
|
|
// Confirm the malleated sig is itself a VALID attestation (recovers to op).
|
|
require.True(t, DefaultAttest(op, common.BytesToHash(digest), bad),
|
|
"malleated twin should still pass attestation (proves malleability is real)")
|
|
|
|
// Attempt to settle again with the malleated twin.
|
|
_, err = SettleInference(db, recordId, resultHash, bad, DefaultAttest)
|
|
require.ErrorIs(t, err, ErrAlreadySettled,
|
|
"replay guard is keyed by recordId, so malleability cannot double-settle")
|
|
}
|
|
|
|
// ATTACK 1c: can a malleated sig impersonate a DIFFERENT operator? (i.e. does
|
|
// recovery ever land on an attacker-chosen address). This is the only way
|
|
// malleability could matter — and it should be impossible.
|
|
func TestRed_Malleability_NoImpersonation(t *testing.T) {
|
|
victimKey, victim := redKeypair(t)
|
|
_, attacker := redKeypair(t)
|
|
require.NotEqual(t, victim, attacker)
|
|
|
|
digest := crypto.Keccak256([]byte("some-32-byte-digest-padding-aaaaaa"))[:32]
|
|
sig, err := crypto.Sign(digest, victimKey)
|
|
require.NoError(t, err)
|
|
bad := malleate(sig)
|
|
|
|
// Neither the canonical nor the malleated sig attests for the attacker.
|
|
require.False(t, DefaultAttest(attacker, common.BytesToHash(digest), sig))
|
|
require.False(t, DefaultAttest(attacker, common.BytesToHash(digest), bad))
|
|
// Both attest for the victim (that's expected; victim signed).
|
|
require.True(t, DefaultAttest(victim, common.BytesToHash(digest), sig))
|
|
}
|
|
|
|
// ATTACK 1d: recovery-id confusion. Does DefaultAttest accept v in {27,28}
|
|
// (Ethereum-style) as well as {0,1}? If both are accepted for the SAME (r,s),
|
|
// that's a second valid sig string for one signer — another malleability axis.
|
|
func TestRed_RecoveryIDConfusion(t *testing.T) {
|
|
k, err := crypto.GenerateKey()
|
|
require.NoError(t, err)
|
|
signer := crypto.PubkeyToAddress(k.PublicKey)
|
|
digest := crypto.Keccak256([]byte("recovery-id-digest-padding-bbbbbbb"))[:32]
|
|
sig, err := crypto.Sign(digest, k)
|
|
require.NoError(t, err)
|
|
require.True(t, sig[64] == 0 || sig[64] == 1, "Sign yields v in {0,1}")
|
|
|
|
// Variant with v += 27.
|
|
v27 := make([]byte, 65)
|
|
copy(v27, sig)
|
|
v27[64] = sig[64] + 27
|
|
ok27 := DefaultAttest(common.BytesToAddress(signer[:]), common.BytesToHash(digest), v27)
|
|
t.Logf("v+27 accepted by DefaultAttest = %v", ok27)
|
|
|
|
// Variant with v in {2,3} (extra recids).
|
|
v2 := make([]byte, 65)
|
|
copy(v2, sig)
|
|
v2[64] = 2
|
|
ok2 := DefaultAttest(common.BytesToAddress(signer[:]), common.BytesToHash(digest), v2)
|
|
t.Logf("v=2 accepted by DefaultAttest = %v (recovers to signer = %v)", ok2, ok2)
|
|
|
|
// Whatever the answer, it must never let v-confusion attest for someone else.
|
|
// (Documented by the ground-truth log above; no money impact regardless.)
|
|
}
|
|
|
|
// ATTACK 2: PREPAID RUG. Operator is credited at Meter time and can Withdraw
|
|
// BEFORE attesting. The caller pays for inference that is never served and has
|
|
// NO refund path. Demonstrate the full theft-of-service concretely.
|
|
func TestRed_PrepaidRug_OperatorWithdrawsWithoutServing(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
opKey, op := redKeypair(t)
|
|
_ = opKey // operator NEVER signs an attestation in this attack
|
|
|
|
const callerStart = 100_000_000_000
|
|
fund(db, caller, callerStart)
|
|
require.NoError(t, RegisterOffering(db, op, model, uint256.NewInt(1_000_000_000), 4096))
|
|
|
|
// Victim caller requests (and is charged for) inference.
|
|
recordId, cost, err := RequestInference(db, lg, caller, op, model, 4096)
|
|
require.NoError(t, err)
|
|
|
|
// Operator immediately withdraws the prepaid funds — WITHOUT serving or
|
|
// attesting anything.
|
|
paid, err := Withdraw(db, lg, op)
|
|
require.NoError(t, err)
|
|
require.Equal(t, cost.Uint64(), paid.Uint64(), "operator pockets the full prepaid cost")
|
|
|
|
// State of the world after the rug:
|
|
require.Equal(t, uint64(callerStart)-cost.Uint64(), db.GetBalance(caller).Uint64(),
|
|
"caller was debited")
|
|
require.Equal(t, cost.Uint64(), db.GetBalance(op).Uint64(),
|
|
"operator holds the caller's money")
|
|
require.Zero(t, db.GetBalance(ContractAddress).Uint64(), "escrow drained, nothing to claw back")
|
|
|
|
// The request is still only Metered (never settled). There is NO refund op.
|
|
_, _, status := GetRequest(db, recordId)
|
|
require.Equal(t, StatusMetered, status,
|
|
"request never settled — caller paid for nothing and cannot recover funds")
|
|
|
|
// Prove there is no mechanism for the caller to get money back: the only
|
|
// state-changing ops are register/meter/withdraw/request/settle. None
|
|
// returns funds to a caller. (No Refund selector exists.)
|
|
require.NotContains(t, []uint32{
|
|
SelectorRegisterOffering, SelectorGetOffering, SelectorMeter,
|
|
SelectorWithdraw, SelectorRequestInference, SelectorSettleInference,
|
|
SelectorGetCredit, SelectorGetRequest,
|
|
}, uint32(0xFF000000), "there is provably no refund selector")
|
|
}
|
|
|
|
// ATTACK 3a: force-credit griefing — can caller A credit an operator they do
|
|
// not control, to manipulate the operator's withdrawable balance? (They pay,
|
|
// operator gains — so it grieves the CALLER, not the operator. Confirm net.)
|
|
func TestRed_ForceCredit_GriefsSelfNotOperator(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
_, op := redKeypair(t)
|
|
fund(db, caller, 10_000_000_000)
|
|
require.NoError(t, RegisterOffering(db, op, model, uint256.NewInt(1_000_000_000), 4096))
|
|
|
|
before := GetCredit(db, op).Uint64()
|
|
cost, err := Meter(db, lg, caller, op, model, 1000)
|
|
require.NoError(t, err)
|
|
after := GetCredit(db, op).Uint64()
|
|
require.Equal(t, before+cost.Uint64(), after,
|
|
"caller can inflate ANY operator's credit — but only by spending their own funds")
|
|
// This is not an attack on the operator (operator only gains). It IS a way
|
|
// to force an offering to be 'used' without consent; harmless economically.
|
|
}
|
|
|
|
// ATTACK 3b: self-dealing — register your own price-0 offering, meter against
|
|
// yourself, withdraw. Can you net-gain anything (mint wei)? Must be net-zero
|
|
// minus the base cost held in escrow then returned.
|
|
func TestRed_SelfDealing_IsNetNeutral(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
self := common.HexToAddress("0x5e1f000000000000000000000000000000000001")
|
|
const start = 5_000_000_000
|
|
fund(db, self, start)
|
|
require.NoError(t, RegisterOffering(db, self, model, uint256.NewInt(0), 4096))
|
|
|
|
cost, err := Meter(db, lg, self, self, model, 1000) // caller==operator==self
|
|
require.NoError(t, err)
|
|
require.Equal(t, BaseCostWei.Uint64(), cost.Uint64(), "price-0 → cost is just base")
|
|
|
|
paid, err := Withdraw(db, lg, self)
|
|
require.NoError(t, err)
|
|
require.Equal(t, cost.Uint64(), paid.Uint64())
|
|
|
|
// Net: self spent `cost` to escrow, then withdrew `cost`. Balance restored.
|
|
require.Equal(t, uint64(start), db.GetBalance(self).Uint64(),
|
|
"self-dealing mints nothing; perfectly net-neutral")
|
|
require.Zero(t, db.GetBalance(ContractAddress).Uint64())
|
|
}
|
|
|
|
// ATTACK 4a: recordId preimage ambiguity. The recordId hashes a VARIABLE-LENGTH
|
|
// model name in the MIDDLE of the preimage. Try to find two distinct
|
|
// (model, tokens) pairs that collide to the same recordId for the same
|
|
// (caller, nonce, operator). If they collide, one settle consumes the other.
|
|
func TestRed_RecordIDNoPreimageCollision(t *testing.T) {
|
|
c := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
op := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
|
nonce := common.HexToHash("0x07")
|
|
|
|
// Candidate collision: model "A\x00\x00\x00\x01" + tokens=2 (0x00000002)
|
|
// vs model "A" + tokens reinterpret. Because tokens is a FIXED 4-byte
|
|
// suffix and model is everything between the 81-byte fixed prefix and that
|
|
// suffix, the decode is unambiguous — assert no collision across a sweep.
|
|
seen := map[common.Hash][2]string{}
|
|
models := [][]byte{
|
|
[]byte("A"),
|
|
[]byte("A\x00"),
|
|
[]byte("A\x00\x00"),
|
|
append([]byte("A"), []byte{0, 0, 0, 1}...), // "A"+bytes that look like a token tail
|
|
[]byte("AB"),
|
|
[]byte("ABC"),
|
|
{0x00},
|
|
{0x00, 0x00},
|
|
}
|
|
toks := []uint32{1, 2, 0x41, 0x10000, 0xFFFFFFFF}
|
|
for _, m := range models {
|
|
for _, tk := range toks {
|
|
id := computeRecordID(c, nonce, op, m, tk)
|
|
if prev, ok := seen[id]; ok {
|
|
t.Fatalf("recordId COLLISION: (%q,%d) and (%s,%s) → %x",
|
|
m, tk, prev[0], prev[1], id)
|
|
}
|
|
seen[id] = [2]string{string(m), string(rune(tk))}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ATTACK 4b: nonce reuse within a frame. There is no external call in
|
|
// RequestInference, so no reentrancy can rewind the nonce. Two sequential
|
|
// requests with identical args must produce DISTINCT recordIds (nonce bumped).
|
|
func TestRed_NonceMonotonicAcrossIdenticalRequests(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
fund(db, caller, 100_000_000_000)
|
|
require.NoError(t, RegisterOffering(db, operator, model, uint256.NewInt(1), 4096))
|
|
|
|
ids := map[common.Hash]bool{}
|
|
for i := range 50 {
|
|
id, _, err := RequestInference(db, lg, caller, operator, model, 100)
|
|
require.NoError(t, err)
|
|
require.False(t, ids[id], "nonce reuse produced a duplicate recordId at i=%d", i)
|
|
ids[id] = true
|
|
}
|
|
}
|
|
|
|
// ATTACK 5a: dispatcher fuzz — truncated calldata must never panic; it must
|
|
// return a clean revert error. Walk every selector with every truncation.
|
|
func TestRed_DispatcherTruncationNoPanic(t *testing.T) {
|
|
sels := []uint32{
|
|
SelectorRegisterOffering, SelectorGetOffering, SelectorMeter,
|
|
SelectorWithdraw, SelectorRequestInference, SelectorSettleInference,
|
|
SelectorGetCredit, SelectorGetRequest,
|
|
}
|
|
// Build a maximal well-formed-ish payload then truncate at every length.
|
|
for _, sel := range sels {
|
|
full := selBytes(sel)
|
|
full = append(full, make([]byte, 256)...) // 8 words of zero tail
|
|
for n := 0; n <= len(full); n++ {
|
|
as := newAS()
|
|
fund(as.db, caller, 1_000_000_000_000)
|
|
input := full[:n]
|
|
require.NotPanics(t, func() {
|
|
_, _, _ = Precompile.Run(as, caller, ContractAddress, input, 5_000_000, false)
|
|
}, "selector %#x truncated to %d bytes panicked", sel, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ATTACK 5b: dynamic-length bomb. Claim a 2^64-1 byte string. Must be rejected
|
|
// cleanly, never allocate or panic.
|
|
func TestRed_DynamicLengthBomb(t *testing.T) {
|
|
as := newAS()
|
|
bad := selBytes(SelectorGetOffering)
|
|
bad = append(bad, addrWord(operator)...)
|
|
// length word = max uint256
|
|
maxWord := make([]byte, 32)
|
|
for i := range maxWord {
|
|
maxWord[i] = 0xFF
|
|
}
|
|
bad = append(bad, maxWord...)
|
|
bad = append(bad, []byte("x")...)
|
|
require.NotPanics(t, func() {
|
|
_, _, err := Precompile.Run(as, caller, ContractAddress, bad, 5_000_000, false)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
// length word = 2^63 (would be negative when cast to int) must be rejected.
|
|
// With the maxDynamicLen cap this is caught at the length check (before any
|
|
// int conversion / slice), which is strictly safer than the old buffer-bound
|
|
// path that relied on int(uint64) wrap behaviour.
|
|
bad2 := selBytes(SelectorGetOffering)
|
|
bad2 = append(bad2, addrWord(operator)...)
|
|
big63 := make([]byte, 32)
|
|
big63[24] = 0x80 // 2^63
|
|
bad2 = append(bad2, big63...)
|
|
bad2 = append(bad2, []byte("x")...)
|
|
require.NotPanics(t, func() {
|
|
_, _, err := Precompile.Run(as, caller, ContractAddress, bad2, 5_000_000, false)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "dynamic length too large")
|
|
})
|
|
}
|
|
|
|
// ATTACK 5c: dirty high bytes in the uint32 token word must be rejected (not
|
|
// silently truncated to a smaller token count that under-charges).
|
|
func TestRed_DirtyUint32Rejected(t *testing.T) {
|
|
as := newAS()
|
|
fund(as.db, caller, 1_000_000_000_000)
|
|
_, _, err := Precompile.Run(as, operator, ContractAddress, encReg(model, 1_000_000_000, 4096), 1_000_000, false)
|
|
require.NoError(t, err)
|
|
|
|
// meter(operator, model, tokens) but set a high byte in the tokens word.
|
|
in := selBytes(SelectorMeter)
|
|
in = append(in, addrWord(operator)...)
|
|
dirty := make([]byte, 32)
|
|
dirty[0] = 0x01 // dirty high byte (would be ignored by a naive reader)
|
|
dirty[28] = 0x00 //
|
|
dirty[31] = 0x64 // low byte = 100
|
|
in = append(in, dirty...)
|
|
in = append(in, padLeft32(uint256.NewInt(uint64(len(model))).Bytes())...)
|
|
in = append(in, padRight32(model)...)
|
|
_, _, err = Precompile.Run(as, caller, ContractAddress, in, 1_000_000, false)
|
|
require.Error(t, err, "dirty high bytes in uint32 token word must be rejected")
|
|
}
|
|
|
|
// REGRESSION for the readBytes cap fix: a dynamic length above maxDynamicLen is
|
|
// rejected at the length check (before slicing), even when enough bytes are
|
|
// physically present in calldata. Also asserts a name at exactly the cap+1
|
|
// boundary is rejected, and that a legitimately-sized field still works.
|
|
func TestRed_ReadBytesCapEnforced(t *testing.T) {
|
|
// Claim length = maxDynamicLen+1 and actually PROVIDE that many bytes so the
|
|
// buffer-bound check would pass — only the explicit cap can stop this.
|
|
overCap := maxDynamicLen + 1
|
|
bad := selBytes(SelectorGetOffering)
|
|
bad = append(bad, addrWord(operator)...)
|
|
bad = append(bad, padLeft32(uint256.NewInt(uint64(overCap)).Bytes())...)
|
|
bad = append(bad, make([]byte, overCap)...) // enough real bytes to satisfy buffer bound
|
|
as := newAS()
|
|
_, _, err := Precompile.Run(as, caller, ContractAddress, bad, 50_000_000, false)
|
|
require.Error(t, err, "length above maxDynamicLen must be rejected even with bytes present")
|
|
require.Contains(t, err.Error(), "dynamic length too large")
|
|
|
|
// A legitimately-sized model name (well under the cap) still decodes fine.
|
|
as2 := newAS()
|
|
fund(as2.db, caller, 1_000_000_000_000)
|
|
_, _, err = Precompile.Run(as2, operator, ContractAddress, encReg(model, 1, 1), 1_000_000, false)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// ATTACK 6: adversarial value-conservation. Random-ish interleaving of
|
|
// register/meter/withdraw/settle across 3 operators and 3 callers; assert
|
|
// total wei constant and escrow == Σ credits at every step.
|
|
func TestRed_AdversarialValueConservation(t *testing.T) {
|
|
as := newAS()
|
|
db := as.db
|
|
lg := newLedger(db)
|
|
|
|
ops := []struct {
|
|
key *ecdsa.PrivateKey
|
|
ad common.Address
|
|
}{}
|
|
for range 3 {
|
|
k, a := redKeypair(t)
|
|
ops = append(ops, struct {
|
|
key *ecdsa.PrivateKey
|
|
ad common.Address
|
|
}{k, a})
|
|
}
|
|
callers := []common.Address{
|
|
common.HexToAddress("0xC0"), common.HexToAddress("0xC1"), common.HexToAddress("0xC2"),
|
|
}
|
|
const each = 1_000_000_000_000
|
|
for _, c := range callers {
|
|
fund(db, c, each)
|
|
}
|
|
models := [][]byte{[]byte("m0"), []byte("m1"), []byte("m2")}
|
|
for i, o := range ops {
|
|
require.NoError(t, RegisterOffering(db, o.ad, models[i], uint256.NewInt(uint64(1_000_000*(i+1))), 100000))
|
|
}
|
|
|
|
allAddrs := append([]common.Address{ContractAddress}, callers...)
|
|
for _, o := range ops {
|
|
allAddrs = append(allAddrs, o.ad)
|
|
}
|
|
total := func() *uint256.Int {
|
|
s := new(uint256.Int)
|
|
for _, a := range allAddrs {
|
|
s.Add(s, db.GetBalance(a))
|
|
}
|
|
return s
|
|
}
|
|
credits := func() *uint256.Int {
|
|
s := new(uint256.Int)
|
|
for _, o := range ops {
|
|
s.Add(s, GetCredit(db, o.ad))
|
|
}
|
|
return s
|
|
}
|
|
want := total().Clone()
|
|
check := func(where string) {
|
|
require.Equal(t, want.Hex(), total().Hex(), "wei conservation broken at %s", where)
|
|
require.Equal(t, credits().Hex(), db.GetBalance(ContractAddress).Hex(), "escrow != Σcredits at %s", where)
|
|
}
|
|
|
|
type req struct {
|
|
id common.Hash
|
|
op int
|
|
rh common.Hash
|
|
}
|
|
var pending []req
|
|
step := 0
|
|
do := func(label string, fn func()) { fn(); step++; check(label) }
|
|
|
|
for round := range 8 {
|
|
ci := round % len(callers)
|
|
oi := round % len(ops)
|
|
do("meter", func() {
|
|
_, _ = Meter(db, lg, callers[ci], ops[oi].ad, models[oi], uint32(1000+round*37))
|
|
})
|
|
do("request", func() {
|
|
id, _, err := RequestInference(db, lg, callers[(ci+1)%3], ops[oi].ad, models[oi], uint32(500+round*11))
|
|
if err == nil {
|
|
pending = append(pending, req{id, oi, common.BigToHash(big.NewInt(int64(round + 1)))})
|
|
}
|
|
})
|
|
if round%2 == 1 && len(pending) > 0 {
|
|
p := pending[0]
|
|
pending = pending[1:]
|
|
digest := crypto.Keccak256(p.id.Bytes(), p.rh.Bytes())
|
|
sig, _ := crypto.Sign(digest, ops[p.op].key)
|
|
do("settle", func() {
|
|
_, _ = SettleInference(db, p.id, p.rh, sig, DefaultAttest)
|
|
})
|
|
}
|
|
if round%3 == 0 {
|
|
do("withdraw", func() { _, _ = Withdraw(db, lg, ops[oi].ad) })
|
|
}
|
|
}
|
|
// Drain everyone.
|
|
for _, o := range ops {
|
|
do("final-withdraw", func() { _, _ = Withdraw(db, lg, o.ad) })
|
|
}
|
|
require.Zero(t, db.GetBalance(ContractAddress).Uint64(), "escrow fully drained at end")
|
|
}
|
|
|
|
// ATTACK 7: settle after withdraw — operator withdraws prepaid funds, THEN
|
|
// settles. Settlement must not credit a second time (no double-spend).
|
|
func TestRed_SettleDoesNotDoubleCreditAfterWithdraw(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
opKey, op := redKeypair(t)
|
|
fund(db, caller, 100_000_000_000)
|
|
require.NoError(t, RegisterOffering(db, op, model, uint256.NewInt(1_000_000_000), 4096))
|
|
|
|
recordId, cost, err := RequestInference(db, lg, caller, op, model, 2000)
|
|
require.NoError(t, err)
|
|
|
|
// Operator withdraws BEFORE settling.
|
|
paid, err := Withdraw(db, lg, op)
|
|
require.NoError(t, err)
|
|
require.Equal(t, cost.Uint64(), paid.Uint64())
|
|
require.Zero(t, GetCredit(db, op).Uint64())
|
|
require.Zero(t, db.GetBalance(ContractAddress).Uint64())
|
|
|
|
// Now settle. If settle credited again, GetCredit would be > 0 and escrow
|
|
// would be negative/underflow on the next withdraw.
|
|
resultHash := common.HexToHash("0xfaceb00c")
|
|
sig := attestSig(t, opKey, recordId, resultHash)
|
|
_, err = SettleInference(db, recordId, resultHash, sig, DefaultAttest)
|
|
require.NoError(t, err)
|
|
require.Zero(t, GetCredit(db, op).Uint64(), "settle must NOT add a second credit")
|
|
|
|
// A withdraw now must fail (nothing owed) — proves no phantom credit.
|
|
_, err = Withdraw(db, lg, op)
|
|
require.ErrorIs(t, err, ErrNoCredit)
|
|
}
|
|
|
|
// ATTACK 10: packing round-trip at exact boundaries. price=2^128-1 and
|
|
// maxSize=2^32-1 must survive pack→unpack, and metering at maxSize must compute
|
|
// an aggregate cost that exceeds 2^128 while staying within uint256 (no
|
|
// overflow, no truncation into the flags/reserved bytes).
|
|
func TestRed_PackingBoundaries(t *testing.T) {
|
|
maxPrice := new(uint256.Int).Set(uint128Max) // 2^128 - 1
|
|
const maxSize = uint32(0xFFFFFFFF) // 2^32 - 1
|
|
|
|
h, err := packOffering(maxPrice, maxSize)
|
|
require.NoError(t, err)
|
|
o := unpackOffering(h)
|
|
require.True(t, o.Exists)
|
|
require.Equal(t, maxPrice.Hex(), o.Price.Hex(), "max price must round-trip")
|
|
require.Equal(t, maxSize, o.MaxSizeTokens, "max size must round-trip")
|
|
|
|
// price = 2^128 exactly must be rejected (one over the cap).
|
|
over := new(uint256.Int).Add(uint128Max, uint256.NewInt(1))
|
|
_, err = packOffering(over, 1)
|
|
require.ErrorIs(t, err, ErrPriceTooLarge)
|
|
|
|
// Aggregate cost at the boundary: (2^128-1) * (2^32-1) / 1000 + base.
|
|
// This is ~2^160, far above 2^128, and must compute without overflow.
|
|
cost, err := ComputeCost(maxPrice, maxSize)
|
|
require.NoError(t, err)
|
|
require.True(t, cost.Gt(uint128Max), "aggregate cost legitimately exceeds 2^128")
|
|
// Sanity: recompute independently and compare.
|
|
want := new(uint256.Int)
|
|
want.Mul(maxPrice, uint256.NewInt(uint64(maxSize)))
|
|
want.Div(want, thousand)
|
|
want.Add(want, BaseCostWei)
|
|
require.Equal(t, want.Hex(), cost.Hex())
|
|
|
|
// requestRecord packing at max tokens must round-trip the operator + tokens
|
|
// without the status byte or reserved tail corrupting them.
|
|
rr := requestRecord{Status: StatusSettled, Operator: operator, Tokens: maxSize}
|
|
got := unpackRequest(packRequest(rr))
|
|
require.Equal(t, rr.Status, got.Status)
|
|
require.Equal(t, rr.Operator, got.Operator)
|
|
require.Equal(t, rr.Tokens, got.Tokens)
|
|
}
|
|
|
|
// ATTACK 8: EVM value injection. The EVM core runs Context.Transfer(caller,
|
|
// precompile, msg.value) BEFORE Run(). So a caller can push native value into
|
|
// the escrow account with ANY call. Prove this surplus (a) cannot be stolen by
|
|
// an operator over-withdrawing, and (b) is locked (no op pays it out). It is a
|
|
// self-burn, not a theft primitive.
|
|
func TestRed_ValueInjectionSurplusIsLockedNotStealable(t *testing.T) {
|
|
db := newMockStateDB()
|
|
lg := newLedger(db)
|
|
_, op := redKeypair(t)
|
|
fund(db, caller, 100_000_000_000)
|
|
require.NoError(t, RegisterOffering(db, op, model, uint256.NewInt(1_000_000_000), 4096))
|
|
|
|
cost, err := Meter(db, lg, caller, op, model, 1000)
|
|
require.NoError(t, err)
|
|
require.Equal(t, cost.Uint64(), db.GetBalance(ContractAddress).Uint64())
|
|
|
|
// Simulate the EVM transferring msg.value=surplus into escrow BEFORE a call.
|
|
surplus := uint256.NewInt(7_777_777_777)
|
|
db.AddBalance(ContractAddress, surplus, 0)
|
|
|
|
// Operator withdraws — must receive EXACTLY its credit (cost), never the
|
|
// surplus. Pay only ever pays the recorded credit.
|
|
paid, err := Withdraw(db, lg, op)
|
|
require.NoError(t, err)
|
|
require.Equal(t, cost.Uint64(), paid.Uint64(), "operator cannot drain injected surplus")
|
|
|
|
// The surplus is now stranded in escrow; no op can pay it out.
|
|
require.Equal(t, surplus.Uint64(), db.GetBalance(ContractAddress).Uint64(),
|
|
"surplus is locked — a self-burn, not stealable")
|
|
|
|
// Another operator with zero credit cannot withdraw the surplus either.
|
|
_, otherOp := redKeypair(t)
|
|
_, err = Withdraw(db, lg, otherOp)
|
|
require.ErrorIs(t, err, ErrNoCredit)
|
|
require.Equal(t, surplus.Uint64(), db.GetBalance(ContractAddress).Uint64())
|
|
}
|
|
|
|
// ATTACK 9: confirm reverting handlers return a NON-ErrExecutionReverted error.
|
|
// The EVM zeroes gas on exactly those errors (evm.go: gas=0 unless
|
|
// ErrExecutionReverted), so logic failures consume all gas like a native
|
|
// precompile — there is no gas-refund-on-revert grief vector. If any handler
|
|
// returned vm.ErrExecutionReverted, the caller would keep gas on a failed
|
|
// state-changing attempt; assert none do.
|
|
func TestRed_RevertErrorsAreGasConsuming(t *testing.T) {
|
|
as := newAS()
|
|
fund(as.db, caller, 1_000_000_000_000)
|
|
// Meter an unregistered model → ErrOfferingNotFound (a logic revert).
|
|
_, _, err := Precompile.Run(as, caller, ContractAddress,
|
|
encMeterLike(SelectorMeter, operator, []byte("nope"), 100), 1_000_000, false)
|
|
require.Error(t, err)
|
|
// It must NOT be the EVM's ErrExecutionReverted sentinel (which would
|
|
// preserve gas). It is a package-local error → EVM sets gas=0.
|
|
require.NotEqual(t, "execution reverted", err.Error(),
|
|
"handler errors must be gas-consuming, not the gas-preserving revert sentinel")
|
|
}
|
|
|
|
// redKeypair mirrors the harness keypair() but is local to the exploit file.
|
|
func redKeypair(t *testing.T) (*ecdsa.PrivateKey, common.Address) {
|
|
t.Helper()
|
|
k, err := crypto.GenerateKey()
|
|
require.NoError(t, err)
|
|
a := crypto.PubkeyToAddress(k.PublicKey)
|
|
return k, common.BytesToAddress(a[:])
|
|
}
|
|
|
|
// touch the secp import so it's available for any low-level probing.
|
|
var _ = gethsecp.S256
|