Files
zeekay 7883fef008 ai, graph, fhe: price the work, enforce readOnly, refuse the impossible
graph 58.0 -> 99.1, graphvm 0 -> 100, ai 90.0 -> 96.2, inference 87.3 -> 98.0,
modelregistry 70.2 -> 98.9, aivmbridge 93.1 -> 99.8, anchor 98.1 -> 99.1,
fhe 33.0 -> 80.1.

graph charged one flat fee for any amount of work. The sharpest form was not
response size but TargetChains: decoded from caller JSON with no cap, one
GraphQL query per entry, so seven bytes of calldata bought an unbounded
fan-out. Response size is only known after the work, so the fee now reserves
the worst case up front and refunds down -- there is no deduction that can come
up short, and a failed run consumes the reservation rather than everything
supplied, so malformed input is not a cheap probe.

ai halted the chain from eight bytes. Two handlers bounded their length
prefixes in uint32, so near 2^32 the sum wrapped, the check passed and the
slice panicked. Reproduced at both 96 and 8 bytes. The correct int-based parser
already existed in the same package; the handlers just did not call it, and the
existing test only probed 0xFFFF, which never wraps.

fhe never enforced readOnly. It was threaded through Run and all 42 handlers
and read by none, so every op but decrypt and sealOutput wrote ciphertext,
metadata and ownership to the trie under STATICCALL. Decided once at dispatch;
an unknown selector counts as a writer.

fhe also allocated before it checked: five bytes of calldata asked for 34 GB
behind a flat 50,000 gas fee. div and rem were registered and priced at 655M
gas with no case in the switch, so both fell through and returned the zero
handle with a nil error -- success for a guaranteed no-op, with the real
implementations written and never wired. select decoded its control as a bare
Ciphertext when every ebool the precompile stores is a BitCiphertext, so it
could not have worked for any input; it surfaced only once failures stopped
being reported as success.

graph decodeArgs accepted truncated calldata as a shorter argument list, so
trimming bytes changed which variables a query expanded with. graphvm chose
resolvers by map iteration order, which is consensus-visible.

Deleted a dead second gas table that disagreed with the real one, an address
constant appearing only at its own declaration, and ten other symbols with no
reader.
2026-08-28 11:53:22 -07:00

193 lines
6.3 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package ai
import (
"encoding/binary"
"math"
"testing"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// TestBlobs_LengthOverflowIsRefusedNotPanicked is the chain-halt regression.
//
// The two verification handlers used to bound-check their length prefixes in uint32:
//
// pubkeyLen := binary.BigEndian.Uint32(input[0:4])
// if uint32(len(input)) < 4+pubkeyLen+4 { ... }
// pubkey := input[4 : 4+pubkeyLen]
//
// For pubkeyLen near 2^32 the sum wraps, the check passes, and the slice expression that
// follows panics with "slice bounds out of range". Nothing recovers a panic on the
// precompile execution path, so it halts every validator that processed the transaction —
// 96 bytes of calldata for the ML-DSA handler, 8 for the TEE handler.
//
// The whole wrap band is swept, not one sample: the value that shipped past review
// (0xFFFF) is below it and never wrapped.
func TestBlobs_LengthOverflowIsRefusedNotPanicked(t *testing.T) {
c := &AIMiningContract{}
lengths := []uint32{
math.MaxUint32, math.MaxUint32 - 1, math.MaxUint32 - 3, math.MaxUint32 - 4,
math.MaxUint32 - 7, math.MaxUint32 - 8, math.MaxUint32 - 11,
1 << 31, 1<<31 + 4, 1 << 24, 0xFFFF, 97, 96, 1,
}
for _, n := range lengths {
for _, size := range []int{8, 12, 96, 200} {
t.Run("", func(t *testing.T) {
// Position 0: the first length prefix.
in := make([]byte, size)
binary.BigEndian.PutUint32(in[0:4], n)
requireRefusedNotPanicked(t, c, in)
// Position 1: a well-formed first blob followed by an overflowing one.
if size >= 12 {
in2 := make([]byte, size)
binary.BigEndian.PutUint32(in2[0:4], 0)
binary.BigEndian.PutUint32(in2[4:8], n)
requireRefusedNotPanicked(t, c, in2)
// Position 2.
in3 := make([]byte, size)
binary.BigEndian.PutUint32(in3[0:4], 0)
binary.BigEndian.PutUint32(in3[4:8], 0)
binary.BigEndian.PutUint32(in3[8:12], n)
requireRefusedNotPanicked(t, c, in3)
}
})
}
}
}
// requireRefusedNotPanicked drives every handler that decodes length-prefixed blobs, both
// directly and through the registered dispatch path.
//
// The invariant asserted is the one that holds for ALL framing, well-formed or not: the
// decode never panics, and it never hands back a blob the calldata did not contain. A
// declared length that overruns must be refused; a declared length that fits may be
// accepted, but only for exactly the bytes present.
func requireRefusedNotPanicked(t *testing.T, c *AIMiningContract, body []byte) {
t.Helper()
for _, count := range []int{1, 2, 3} {
require.NotPanics(t, func() {
parts, off, err := blobs(body, count)
if err != nil {
return
}
require.LessOrEqual(t, off, len(body), "decode ran past the end of the input")
total := 4 * count
for _, p := range parts {
total += len(p)
require.LessOrEqual(t, len(p), len(body), "decoded a blob longer than the input")
}
require.Equal(t, off, total, "offset must account for every header and blob")
}, "blobs(%d) panicked on %x", count, body[:min(len(body), 16)])
}
require.NotPanics(t, func() {
_, _, err := c.runVerifyMLDSA(nil, body, 1_000_000, false)
_ = err
})
require.NotPanics(t, func() {
_, _, err := c.runVerifyTEE(nil, body, 1_000_000)
_ = err
})
require.NotPanics(t, func() {
_, _, _, _, err := parseTriple(body)
_ = err
})
for _, sel := range []uint32{
SelectorVerifyMLDSA, SelectorVerifyTEE,
SelectorVerifyAndMintWork, SelectorVerifyAndMintData,
} {
input := make([]byte, 4+len(body))
binary.BigEndian.PutUint32(input[:4], sel)
copy(input[4:], body)
require.NotPanics(t, func() {
_, _, err := c.Run(newTestAS(), common.Address{}, ContractAddress, input, 1_000_000, false)
require.Error(t, err, "selector %#x accepted degenerate framing", sel)
}, "selector %#x panicked", sel)
}
}
// TestBlobs_WellFormedFramingRoundTrips proves the decoder is not simply refusing
// everything: correct framing decodes to exactly the blobs that were encoded, including
// empty ones, and the reported offset lands just past the last blob.
func TestBlobs_WellFormedFramingRoundTrips(t *testing.T) {
cases := [][][]byte{
{},
{nil},
{[]byte("a")},
{nil, nil, nil},
{[]byte("pubkey"), nil, []byte("sig")},
{[]byte("pubkey"), []byte("message"), []byte("signature")},
}
for _, want := range cases {
var enc []byte
for _, b := range want {
hdr := make([]byte, 4)
binary.BigEndian.PutUint32(hdr, uint32(len(b)))
enc = append(append(enc, hdr...), b...)
}
got, off, err := blobs(enc, len(want))
require.NoError(t, err)
require.Equal(t, len(enc), off, "offset must land just past the last blob")
require.Len(t, got, len(want))
for i := range want {
require.Equal(t, len(want[i]), len(got[i]))
if len(want[i]) > 0 {
require.Equal(t, want[i], got[i])
}
}
// Trailing bytes are permitted and are not consumed.
got2, off2, err := blobs(append(enc, 0xde, 0xad), len(want))
require.NoError(t, err)
require.Equal(t, off, off2)
require.Len(t, got2, len(want))
// Every proper prefix is refused: truncation is never read as a shorter list.
for n := 0; n < len(enc); n++ {
if _, _, err := blobs(enc[:n], len(want)); err == nil {
// A prefix may legitimately still contain all the blobs only when the
// encoding is empty.
require.Empty(t, want)
}
}
// Asking for one more blob than was encoded is refused.
_, _, err = blobs(enc, len(want)+1)
require.ErrorIs(t, err, ErrInputTooShort)
}
}
// TestParseTriple_ChainIDTailRequired proves the trailing chainId is mandatory: a triple
// with a short or missing tail is refused rather than read out of adjacent memory.
func TestParseTriple_ChainIDTailRequired(t *testing.T) {
body := make([]byte, 12) // three zero-length blobs
_, _, _, _, err := parseTriple(body)
require.ErrorIs(t, err, ErrInputTooShort)
for n := 1; n < 8; n++ {
_, _, _, _, err := parseTriple(append(body, make([]byte, n)...))
require.ErrorIs(t, err, ErrInputTooShort, "tail of %d bytes must be refused", n)
}
full := append(body, make([]byte, 8)...)
binary.BigEndian.PutUint64(full[12:], 96369)
a, b, cc, chainID, err := parseTriple(full)
require.NoError(t, err)
require.Empty(t, a)
require.Empty(t, b)
require.Empty(t, cc)
require.Equal(t, uint64(96369), chainID)
}