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.
This commit is contained in:
2026-08-28 11:53:22 -07:00
parent 59c880b1e8
commit 7883fef008
29 changed files with 6227 additions and 381 deletions
+9
View File
@@ -493,6 +493,15 @@ func (ba *BatchAccumulator) Size() int {
// each element, so it is byte-stable across the whole validator set
// regardless of build tags or hardware.
func BatchVerifyMLDSA(pubkeys, messages, signatures [][]byte) ([]bool, error) {
// The three slices are indexed in lockstep, so a caller that supplies fewer keys or
// messages than signatures used to index past the end and panic. Refuse instead:
// there is no sensible answer for a signature with no key to check it against, and
// silently verifying the shortest prefix would report "all valid" for a batch that
// was never fully checked.
if len(pubkeys) != len(signatures) || len(messages) != len(signatures) {
return nil, ErrInvalidWorkProof
}
// CPU is the single source of truth (see BatchAccumulator.flushLocked):
// the accel batch kernel truncates messages to 32 bytes and is mode-fixed,
// so it cannot match VerifyMLDSA. Verify each element on the CPU oracle.
+27 -17
View File
@@ -40,39 +40,49 @@ const (
// ErrInputTooShort is returned when calldata is truncated.
var ErrInputTooShort = fmt.Errorf("input too short")
// parseTriple decodes three length-prefixed blobs followed by a uint64 chainId:
// blobs decodes count length-prefixed blobs from the front of input and reports the offset
// just past the last one:
//
// [4]lenA | A | [4]lenB | B | [4]lenC | C | [8]chainId
func parseTriple(input []byte) (a, b, c []byte, chainId uint64, err error) {
// [4]len0 | blob0 | [4]len1 | blob1 | ...
//
// Every length is widened to int before it is used. Computing the bound in uint32 — as
// `uint32(len(input)) < 4+n+4` — wraps for n near 2^32, so the check passes and the slice
// expression that follows panics with "slice bounds out of range". A panic inside a
// precompile is not recovered anywhere on the execution path, so it takes down every
// validator that processed the transaction; eight bytes of calldata were enough. int is
// wide enough on 64-bit, and the n < 0 test covers a 32-bit build where int(uint32) can go
// negative.
func blobs(input []byte, count int) ([][]byte, int, error) {
out := make([][]byte, 0, count)
off := 0
readBlob := func() ([]byte, error) {
for range count {
if off+4 > len(input) {
return nil, ErrInputTooShort
return nil, 0, ErrInputTooShort
}
n := int(binary.BigEndian.Uint32(input[off : off+4]))
off += 4
if n < 0 || off+n > len(input) {
return nil, ErrInputTooShort
return nil, 0, ErrInputTooShort
}
blob := input[off : off+n]
out = append(out, input[off:off+n])
off += n
return blob, nil
}
if a, err = readBlob(); err != nil {
return
}
if b, err = readBlob(); err != nil {
return
}
if c, err = readBlob(); err != nil {
return out, off, nil
}
// parseTriple decodes three length-prefixed blobs followed by a uint64 chainId:
//
// [4]lenA | A | [4]lenB | B | [4]lenC | C | [8]chainId
func parseTriple(input []byte) (a, b, c []byte, chainId uint64, err error) {
parts, off, err := blobs(input, 3)
if err != nil {
return
}
if off+8 > len(input) {
err = ErrInputTooShort
return
}
chainId = binary.BigEndian.Uint64(input[off : off+8])
return
return parts[0], parts[1], parts[2], binary.BigEndian.Uint64(input[off : off+8]), nil
}
// mintWork atomically settles an attested compute work-proof: workId is bound to
+192
View File
@@ -0,0 +1,192 @@
// 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)
}
+566
View File
@@ -0,0 +1,566 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package ai
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/binary"
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
"github.com/stretchr/testify/require"
"github.com/zeebo/blake3"
)
// memState is a StateDB the mint helpers can be driven against directly.
type memState struct{ slots map[[32]byte][32]byte }
func newMemState() *memState { return &memState{slots: make(map[[32]byte][32]byte)} }
func (m *memState) GetState(_ [20]byte, k [32]byte) [32]byte { return m.slots[k] }
func (m *memState) SetState(_ [20]byte, k, v [32]byte) { m.slots[k] = v }
// workProof builds a proof with the documented layout:
// deviceId(32) | nonce(32) | timestamp(8) | privacy(2) | computeMinutes(4).
func workProof(device, nonce byte, privacy uint16, minutes uint32) []byte {
p := make([]byte, WorkProofMinSize)
for i := 0; i < 32; i++ {
p[i] = device
p[32+i] = nonce
}
binary.BigEndian.PutUint64(p[64:72], 1_700_000_000)
binary.BigEndian.PutUint16(p[72:74], privacy)
binary.BigEndian.PutUint32(p[74:78], minutes)
return p
}
// descriptor builds a data contribution: dataHash(32) | size(8) | privacy(2).
func descriptor(hash byte, size uint64, privacy uint16) []byte {
d := make([]byte, DataContributionSize)
for i := 0; i < 32; i++ {
d[i] = hash
}
binary.BigEndian.PutUint64(d[32:40], size)
binary.BigEndian.PutUint16(d[40:42], privacy)
return d
}
// TestMintWork_RefusesReplay is the double-spend property: the same work may be minted
// exactly once per chain. A second attempt must be refused, not silently re-paid.
func TestMintWork_RefusesReplay(t *testing.T) {
st := newMemState()
proof := workProof(0xAA, 0xBB, PrivacyConfidential, 10)
first, err := mintWork(st, proof, 96369)
require.NoError(t, err)
require.NotNil(t, first)
require.Positive(t, first.Sign(), "a valid work proof must pay something")
_, err = mintWork(st, proof, 96369)
require.ErrorIs(t, err, ErrWorkAlreadySpent, "the same work must not mint twice")
// The work id binds the chain, so the same proof on another chain is a distinct claim.
other, err := mintWork(st, proof, 200200)
require.NoError(t, err)
require.Equal(t, first, other)
// ...and that one is now spent too.
_, err = mintWork(st, proof, 200200)
require.ErrorIs(t, err, ErrWorkAlreadySpent)
}
// TestMintWork_RefusesMalformedProof covers the length and content refusals, including the
// boundary: one byte short of the minimum is refused, exactly the minimum is accepted.
func TestMintWork_RefusesMalformedProof(t *testing.T) {
st := newMemState()
for n := 0; n < WorkProofMinSize; n++ {
_, err := mintWork(st, make([]byte, n), 1)
require.ErrorIs(t, err, ErrInvalidWorkProof, "proof of %d bytes", n)
}
_, err := mintWork(st, workProof(1, 2, PrivacyConfidential, 1), 1)
require.NoError(t, err, "exactly the minimum length is a valid proof")
// An unknown privacy level is refused rather than priced at some default.
_, err = mintWork(st, workProof(3, 4, 0xBEEF, 1), 1)
require.ErrorIs(t, err, ErrInvalidPrivacyLevel)
}
// TestMintData_RefusesReplayAndMalformed mirrors the work path for data contributions and
// pins that the contribution id binds size and privacy as well as the hash — otherwise the
// same dataset could be re-claimed by relabelling it.
func TestMintData_RefusesReplayAndMalformed(t *testing.T) {
st := newMemState()
d := descriptor(0x11, 1024, PrivacyConfidential)
first, err := mintData(st, d, 96369)
require.NoError(t, err)
require.Positive(t, first.Sign())
_, err = mintData(st, d, 96369)
require.ErrorIs(t, err, ErrWorkAlreadySpent)
// A different declared size is a different contribution id, so it is mintable...
_, err = mintData(st, descriptor(0x11, 2048, PrivacyConfidential), 96369)
require.NoError(t, err)
// ...and it pays proportionally more for more data.
bigger, err := mintData(st, descriptor(0x22, 2048, PrivacyConfidential), 96369)
require.NoError(t, err)
require.Positive(t, bigger.Cmp(first), "a larger contribution must pay more")
for n := 0; n < DataContributionSize; n++ {
_, err := mintData(st, make([]byte, n), 1)
require.ErrorIs(t, err, ErrInvalidWorkProof, "descriptor of %d bytes", n)
}
_, err = mintData(st, descriptor(0x33, 1, 0xBEEF), 1)
require.ErrorIs(t, err, ErrInvalidPrivacyLevel)
}
// TestVerifyDeviceBinding_Refusals covers every refusal in the envelope parser. The
// envelope is [4]sigLen | teeSig | receipt, and a caller controls all of it.
func TestVerifyDeviceBinding_Refusals(t *testing.T) {
roots := x509.NewCertPool()
pub := []byte("some public key")
// Too short to hold the length prefix at all.
for n := 0; n < 4; n++ {
require.ErrorIs(t, verifyDeviceBinding(pub, make([]byte, n), roots), ErrInvalidTEEReceipt,
"envelope of %d bytes", n)
}
// A zero-length signature is refused: an empty signature can never be verified, and
// accepting it would let the receipt stand unsigned.
zero := make([]byte, 64)
binary.BigEndian.PutUint32(zero[:4], 0)
require.ErrorIs(t, verifyDeviceBinding(pub, zero, roots), ErrInvalidTEEReceipt)
// A declared signature length that overruns the envelope is refused rather than
// slicing past the end.
for _, n := range []uint32{61, 100, 1 << 31, ^uint32(0)} {
env := make([]byte, 64)
binary.BigEndian.PutUint32(env[:4], n)
require.NotPanics(t, func() {
require.ErrorIs(t, verifyDeviceBinding(pub, env, roots), ErrInvalidTEEReceipt,
"declared sigLen %d", n)
})
}
// A well-framed envelope with an unverifiable receipt fails closed against an empty
// root pool — never "no roots, therefore trusted".
env := make([]byte, 200)
binary.BigEndian.PutUint32(env[:4], 64)
require.Error(t, verifyDeviceBinding(pub, env, roots))
}
// TestVerifyAndMint_RefusalsThroughDispatch covers the mint entry points' own guards:
// read-only, out of gas, malformed framing, and a signature that does not verify. Each must
// leave the spent set untouched.
func TestVerifyAndMint_RefusalsThroughDispatch(t *testing.T) {
c := &AIMiningContract{}
roots := x509.NewCertPool()
body := func() []byte {
proof := workProof(0x01, 0x02, PrivacyConfidential, 5)
enc := func(b []byte) []byte {
h := make([]byte, 4)
binary.BigEndian.PutUint32(h, uint32(len(b)))
return append(h, b...)
}
out := append(enc(proof), enc(make([]byte, MLDSA44PublicKeySize))...)
out = append(out, enc(make([]byte, MLDSA44SignatureSize))...)
tail := make([]byte, 8)
binary.BigEndian.PutUint64(tail, 96369)
return append(out, tail...)
}()
for name, fn := range map[string]func(contract.AccessibleState, []byte, uint64, bool) ([]byte, uint64, error){
"work": func(s contract.AccessibleState, in []byte, gas uint64, ro bool) ([]byte, uint64, error) {
return c.verifyAndMintWork(s, in, gas, ro, roots)
},
"data": func(s contract.AccessibleState, in []byte, gas uint64, ro bool) ([]byte, uint64, error) {
return c.verifyAndMintData(s, in, gas, ro, roots)
},
} {
t.Run(name, func(t *testing.T) {
st := newTestAS()
// Read-only: minting writes the spent set, so a static call must be refused.
_, _, err := fn(st, body, 1_000_000, true)
require.ErrorContains(t, err, "read-only")
// Out of gas, at the boundary.
_, _, err = fn(st, body, GasVerifyAndMintWork-1, false)
require.ErrorContains(t, err, "out of gas")
// Malformed framing.
_, _, err = fn(st, []byte{0, 0, 0}, 1_000_000, false)
require.ErrorIs(t, err, ErrInputTooShort)
// A signature of the right length that is not a signature must not mint.
_, _, err = fn(st, body, 1_000_000, false)
require.Error(t, err, "an unverifiable attestation must never mint")
})
}
}
// TestVerifyECDSAReport_AcceptsBothEncodingsAndRefusesJunk covers the two signature
// encodings TEE quotes use in practice, on both curve widths (which select different
// pre-hashes), and proves a wrong-length or tampered signature is refused.
func TestVerifyECDSAReport_AcceptsBothEncodingsAndRefusesJunk(t *testing.T) {
report := make([]byte, teeReportLen)
for i := range report {
report[i] = byte(i)
}
for _, curve := range []elliptic.Curve{elliptic.P256(), elliptic.P384()} {
key, err := ecdsa.GenerateKey(curve, rand.Reader)
require.NoError(t, err)
digest := reportDigest(curve, report)
// ASN.1 DER, the encoding crypto/ecdsa produces by default.
der, err := ecdsa.SignASN1(rand.Reader, key, digest)
require.NoError(t, err)
require.True(t, verifyECDSAReport(&key.PublicKey, report, der),
"%s: DER signature must verify", curve.Params().Name)
// Fixed-width raw r||s, the SGX/TDX quote encoding.
r, s, err := ecdsa.Sign(rand.Reader, key, digest)
require.NoError(t, err)
byteLen := (curve.Params().BitSize + 7) / 8
raw := make([]byte, 2*byteLen)
r.FillBytes(raw[:byteLen])
s.FillBytes(raw[byteLen:])
require.True(t, verifyECDSAReport(&key.PublicKey, report, raw),
"%s: raw r||s signature must verify", curve.Params().Name)
// A tampered raw signature must not.
bad := append([]byte{}, raw...)
bad[0] ^= 0x01
require.False(t, verifyECDSAReport(&key.PublicKey, report, bad))
// Neither must a signature over a different report.
require.False(t, verifyECDSAReport(&key.PublicKey, append(report, 0), raw))
// Nor one of the wrong length, in either direction.
require.False(t, verifyECDSAReport(&key.PublicKey, report, raw[:len(raw)-1]))
require.False(t, verifyECDSAReport(&key.PublicKey, report, append(raw, 0)))
}
// A key with no curve is refused rather than dereferenced.
require.False(t, verifyECDSAReport(&ecdsa.PublicKey{}, report, make([]byte, 64)))
}
func reportDigest(curve elliptic.Curve, report []byte) []byte {
if curve.Params().BitSize > 256 {
d := sha512.Sum384(report)
return d[:]
}
d := sha256.Sum256(report)
return d[:]
}
// TestVerifyReportSignature_RejectsUnknownKeyTypes proves the signature verifier fails
// closed on a key type it does not understand rather than defaulting to accept.
func TestVerifyReportSignature_RejectsUnknownKeyTypes(t *testing.T) {
report := make([]byte, teeReportLen)
require.False(t, verifyReportSignature("not a key", report, make([]byte, 64)))
require.False(t, verifyReportSignature(nil, report, make([]byte, 64)))
require.False(t, verifyReportSignature(struct{}{}, report, nil))
}
// TestTeeRootPool_IsNeverNilAndNeverTheSystemStore pins the fail-closed property: the pool
// is built once, is never nil, and repeated calls return the same pool.
func TestTeeRootPool_IsNeverNilAndNeverTheSystemStore(t *testing.T) {
pool := teeRootPool()
require.NotNil(t, pool)
require.Same(t, pool, teeRootPool(), "the pool is built once")
system, err := x509.SystemCertPool()
if err == nil {
require.NotEqual(t, len(system.Subjects()), len(pool.Subjects()), //nolint:staticcheck
"the TEE pool must not be the system root store")
}
}
// TestComputeWorkId_BindsEveryInput proves the work id changes when any of its three inputs
// changes — if it did not, two distinct claims would collide in the spent set and one would
// be unmintable, or one claim could be replayed as another.
func TestComputeWorkId_BindsEveryInput(t *testing.T) {
var device, nonce [32]byte
device[0], nonce[0] = 1, 2
base := ComputeWorkId(device, nonce, 96369)
altDevice := device
altDevice[31] ^= 1
altNonce := nonce
altNonce[31] ^= 1
require.NotEqual(t, base, ComputeWorkId(altDevice, nonce, 96369), "device must bind")
require.NotEqual(t, base, ComputeWorkId(device, altNonce, 96369), "nonce must bind")
require.NotEqual(t, base, ComputeWorkId(device, nonce, 96368), "chain must bind")
require.Equal(t, base, ComputeWorkId(device, nonce, 96369), "and it is deterministic")
}
// TestBlake3DeviceBinding documents the binding the attestation gate checks: the attested
// device id is BLAKE3 of the public key, so a miner cannot present someone else's quote
// alongside its own key.
func TestBlake3DeviceBinding(t *testing.T) {
keyA := []byte("public key A")
keyB := []byte("public key B")
require.NotEqual(t, blake3.Sum256(keyA), blake3.Sum256(keyB))
require.Equal(t, blake3.Sum256(keyA), blake3.Sum256(keyA))
}
// TestVerifyReportSignature_AllKeyTypes covers each key type the verifier accepts, and
// proves each rejects a signature over a different report.
func TestVerifyReportSignature_AllKeyTypes(t *testing.T) {
report := make([]byte, teeReportLen)
other := append(append([]byte{}, report...), 1)
edPub, edPriv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
edSig := ed25519.Sign(edPriv, report)
require.True(t, verifyReportSignature(edPub, report, edSig))
require.False(t, verifyReportSignature(edPub, other, edSig))
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
digest := sha256.Sum256(report)
rsaSig, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, digest[:])
require.NoError(t, err)
require.True(t, verifyReportSignature(&rsaKey.PublicKey, report, rsaSig))
require.False(t, verifyReportSignature(&rsaKey.PublicKey, other, rsaSig))
}
// TestRunVerifyTEE_Refusals covers the dispatch guards on the TEE selector: out of gas at
// the boundary, malformed framing, and a receipt that cannot be verified.
func TestRunVerifyTEE_Refusals(t *testing.T) {
c := &AIMiningContract{}
_, _, err := c.runVerifyTEE(newTestAS(), make([]byte, 64), GasVerifyTEE-1)
require.ErrorContains(t, err, "out of gas")
_, _, err = c.runVerifyTEE(newTestAS(), []byte{0, 0, 0}, 1_000_000)
require.ErrorIs(t, err, ErrInputTooShort)
// A well-framed but empty receipt is refused by VerifyTEE itself.
body := make([]byte, 8) // two zero-length blobs
_, _, err = c.runVerifyTEE(newTestAS(), body, 1_000_000)
require.Error(t, err, "an empty TEE receipt must not verify")
}
// TestRunCalculateReward_RefusesUnpriceableProof proves a proof the reward schedule cannot
// price is surfaced as an error rather than paying zero.
func TestRunCalculateReward_RefusesUnpriceableProof(t *testing.T) {
c := &AIMiningContract{}
input := append(workProof(1, 2, 0xBEEF, 5), make([]byte, 8)...)
_, _, err := c.runCalculateReward(newTestAS(), input, 1_000_000)
require.ErrorIs(t, err, ErrInvalidPrivacyLevel)
_, _, err = c.runCalculateReward(newTestAS(), make([]byte, 85), 1_000_000)
require.ErrorContains(t, err, "too short")
_, _, err = c.runCalculateReward(newTestAS(), make([]byte, 86), GasCalculateReward-1)
require.ErrorContains(t, err, "out of gas")
}
// TestBatchVerifyTEE_ScoresMatchVerdicts proves the batch TEE helper reports a zero score
// exactly when it reports invalid — a score that survived a failed verification would let a
// caller weight a rejected attestation.
func TestBatchVerifyTEE_ScoresMatchVerdicts(t *testing.T) {
atts := [][]byte{
nil,
make([]byte, NVTrustMinQuoteSize-1), // below the minimum quote size
make([]byte, NVTrustMinQuoteSize), // long enough, but not a real quote
}
ok, scores, err := BatchVerifyTEE(atts)
require.NoError(t, err)
require.Len(t, ok, len(atts))
require.Len(t, scores, len(atts))
for i := range atts {
require.Falsef(t, ok[i], "attestation %d must not verify", i)
require.Zerof(t, scores[i], "a rejected attestation must score zero, got %d", scores[i])
}
require.Empty(t, mustBatchTEE(t, nil), "an empty batch is an empty result")
}
func mustBatchTEE(t *testing.T, atts [][]byte) []bool {
t.Helper()
ok, _, err := BatchVerifyTEE(atts)
require.NoError(t, err)
return ok
}
// TestVerifyDeviceBinding_RefusesAnotherDevicesQuote closes a hole the mutation check
// found: deleting the device check inside verifyDeviceBinding left the whole suite green.
//
// The reason is that two DIFFERENT bindings both compare against BLAKE3(pubkey), and only
// one of them was exercised. verifyAndMintWork checks that the WORK PROOF's deviceID field
// matches the signing key (covered by the e2e forge test), while verifyDeviceBinding checks
// that the deviceID inside the ATTESTATION RECEIPT matches it. Only the second stops a
// miner presenting a genuine, correctly-signed quote belonging to somebody else's device
// alongside its own key — every certificate verifies, the report signature verifies, and
// the work proof is internally consistent. Without this check that mints.
func TestVerifyDeviceBinding_RefusesAnotherDevicesQuote(t *testing.T) {
tc := newTEEChain(t, caNotAfter)
victimPub, _ := genMLDSA(t)
attackerPub, attackerSK := genMLDSA(t)
// A real, fully valid quote for the victim's device.
victimEnv, victimDev := attestedQuote(t, tc, victimPub)
require.NoError(t, verifyDeviceBinding(victimPub, victimEnv, tc.roots),
"the victim's own quote must verify for the victim's key")
require.NotEqual(t, victimDev, blake3.Sum256(attackerPub))
// The attacker replays that quote under its own key. Nothing about the
// certificate chain or the report signature is wrong — only the identity is.
require.ErrorIs(t, verifyDeviceBinding(attackerPub, victimEnv, tc.roots),
ErrDeviceKeyMismatch,
"a quote attesting another device must not vouch for this key")
// And it must not mint, even with the work proof made internally consistent with
// the attacker's own key so the second (work-proof) binding cannot be what refuses it.
var nonce [32]byte
nonce[31] = 0x77
proof := BuildWorkProof(blake3.Sum256(attackerPub), nonce, uint64(testAttestTime), 3, 60, victimEnv)
cd := tripleCalldata(SelectorVerifyAndMintWork, [][]byte{proof, attackerPub,
signMLDSA(t, attackerSK, proof)}, 420420)
acc := ccAcc{s: newCCDB()}
_, _, err := AIMiningPrecompile.verifyAndMintWork(acc, cd[4:],
AIMiningPrecompile.RequiredGas(cd), false, tc.roots)
require.ErrorIs(t, err, ErrDeviceKeyMismatch, "a replayed quote must never mint")
}
// TestRun_ReadOnlyRefusesEveryStateWriter closes a hole the mutation check found: nothing
// asserted that markSpent is refused under a static call. STATICCALL promises the callee
// mutates nothing, and markSpent writes the spent set — so a static call could burn a work
// id, permanently denying a legitimate miner its reward.
//
// Every selector is driven, so a writer added later without a guard is caught here rather
// than in production.
func TestRun_ReadOnlyRefusesEveryStateWriter(t *testing.T) {
writers := map[uint32][]byte{
SelectorMarkSpent: make([]byte, 32),
SelectorVerifyAndMintWork: make([]byte, 128),
SelectorVerifyAndMintData: make([]byte, 128),
}
readers := map[uint32][]byte{
SelectorVerifyMLDSA: make([]byte, 128),
SelectorCalculateReward: make([]byte, 128),
SelectorVerifyTEE: make([]byte, 128),
SelectorIsSpent: make([]byte, 32),
SelectorComputeWorkId: make([]byte, 72),
}
c := &AIMiningContract{}
run := func(sel uint32, body []byte, readOnly bool) error {
input := make([]byte, 4+len(body))
binary.BigEndian.PutUint32(input[:4], sel)
copy(input[4:], body)
_, _, err := c.Run(newTestAS(), common.Address{}, ContractAddress, input, 10_000_000, readOnly)
return err
}
for sel, body := range writers {
require.ErrorContainsf(t, run(sel, body, true), "read-only",
"selector %#x writes state and must be refused under a static call", sel)
// The same call outside a static context gets past the guard and fails, if at
// all, for its own reasons — proving the refusal is the readOnly flag and not
// the body.
require.NotErrorIsf(t, run(sel, body, false), errReadOnlySentinel, "selector %#x", sel)
}
for sel, body := range readers {
err := run(sel, body, true)
if err != nil {
require.NotContainsf(t, err.Error(), "read-only",
"selector %#x only reads and must not be refused as a writer", sel)
}
}
}
// errReadOnlySentinel is never returned by anything; it exists so the assertion above reads
// as a type check rather than another string match.
var errReadOnlySentinel = errStr("read-only sentinel")
type errStr string
func (e errStr) Error() string { return string(e) }
// TestVerifyAndMintData_ForgedSignatureIsRefused closes a hole the mutation check found:
// deleting the ML-DSA validity gate on the data path left the whole suite green, meaning a
// forged signature would have minted a reward.
//
// The existing forge test perturbs the descriptor, so a later gate (device binding, or the
// attestation itself) refuses it and the signature check is never the thing that fires.
// Here EVERYTHING else is valid — a genuine quote for this key, a descriptor bound to it —
// and only the signature is wrong, so nothing but the signature gate can refuse it.
func TestVerifyAndMintData_ForgedSignatureIsRefused(t *testing.T) {
const chainID = uint64(420420)
tc := newTEEChain(t, caNotAfter)
pub, sk := genMLDSA(t)
env, _ := attestedQuote(t, tc, pub)
desc := make([]byte, DataContributionSize)
desc[31] = 0xDD
binary.BigEndian.PutUint64(desc[32:40], 1000)
binary.BigEndian.PutUint16(desc[40:42], PrivacyConfidential)
desc = append(desc, env...)
// The control: the genuine signature mints, so every other gate is satisfied.
good := signMLDSA(t, sk, desc)
cd := tripleCalldata(SelectorVerifyAndMintData, [][]byte{desc, pub, good}, chainID)
ret, _, err := AIMiningPrecompile.verifyAndMintData(ccAcc{s: newCCDB()}, cd[4:],
AIMiningPrecompile.RequiredGas(cd), false, tc.roots)
require.NoError(t, err, "the control must mint, or this test proves nothing")
require.Positive(t, new(big.Int).SetBytes(ret).Sign())
// Now the same claim with the signature broken, one forgery at a time.
forgeries := map[string][]byte{
"one bit flipped": flip(good, len(good)/2, 0x01),
"first byte flipped": flip(good, 0, 0x80),
"last byte flipped": flip(good, len(good)-1, 0x01),
"all zero": make([]byte, len(good)),
"signature over another": signMLDSA(t, sk, append(append([]byte{}, desc...), 0)),
}
for name, bad := range forgeries {
t.Run(name, func(t *testing.T) {
cd := tripleCalldata(SelectorVerifyAndMintData, [][]byte{desc, pub, bad}, chainID)
_, _, err := AIMiningPrecompile.verifyAndMintData(ccAcc{s: newCCDB()}, cd[4:],
AIMiningPrecompile.RequiredGas(cd), false, tc.roots)
require.Error(t, err, "a forged signature must never mint")
require.Contains(t, err.Error(), "ml-dsa",
"the signature gate must be what refuses it, not a later check")
})
}
// A signature from a DIFFERENT key of the same parameter set must not mint either.
_, otherSK := genMLDSA(t)
cd = tripleCalldata(SelectorVerifyAndMintData,
[][]byte{desc, pub, signMLDSA(t, otherSK, desc)}, chainID)
_, _, err = AIMiningPrecompile.verifyAndMintData(ccAcc{s: newCCDB()}, cd[4:],
AIMiningPrecompile.RequiredGas(cd), false, tc.roots)
require.ErrorContains(t, err, "ml-dsa", "a signature from another key must not mint")
}
func flip(b []byte, i int, mask byte) []byte {
out := append([]byte{}, b...)
out[i] ^= mask
return out
}
+272
View File
@@ -0,0 +1,272 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package ai
import (
"crypto/rand"
"encoding/binary"
"testing"
"github.com/cloudflare/circl/sign/mldsa/mldsa44"
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// signer is one ML-DSA parameter set reduced to what the verification path needs, so the
// three levels are exercised by identical assertions rather than three copies of them.
type signer struct {
name string
pub []byte
sign func(msg []byte) []byte
pubSize int
sigSize int
}
func signers(t *testing.T) []signer {
t.Helper()
pub44, priv44, err := mldsa44.GenerateKey(rand.Reader)
require.NoError(t, err)
pub65, priv65, err := mldsa65.GenerateKey(rand.Reader)
require.NoError(t, err)
pub87, priv87, err := mldsa87.GenerateKey(rand.Reader)
require.NoError(t, err)
must := func(b []byte, err error) []byte { require.NoError(t, err); return b }
return []signer{
{
name: "ML-DSA-44", pub: must(pub44.MarshalBinary()),
pubSize: MLDSA44PublicKeySize, sigSize: MLDSA44SignatureSize,
sign: func(msg []byte) []byte {
sig := make([]byte, MLDSA44SignatureSize)
require.NoError(t, mldsa44.SignTo(priv44, msg, nil, true, sig))
return sig
},
},
{
name: "ML-DSA-65", pub: must(pub65.MarshalBinary()),
pubSize: MLDSA65PublicKeySize, sigSize: MLDSA65SignatureSize,
sign: func(msg []byte) []byte {
sig := make([]byte, MLDSA65SignatureSize)
require.NoError(t, mldsa65.SignTo(priv65, msg, nil, true, sig))
return sig
},
},
{
name: "ML-DSA-87", pub: must(pub87.MarshalBinary()),
pubSize: MLDSA87PublicKeySize, sigSize: MLDSA87SignatureSize,
sign: func(msg []byte) []byte {
sig := make([]byte, MLDSA87SignatureSize)
require.NoError(t, mldsa87.SignTo(priv87, msg, nil, true, sig))
return sig
},
},
}
}
// TestVerifyMLDSA_AcceptsOnlyGenuineSignatures is the core soundness property of the
// mining precompile: a reward is minted against an ML-DSA signature, so anything short of
// a genuine signature over the exact message under the exact key must fail. Each parameter
// set is checked, because a level whose length check passed but whose verifier was wired to
// the wrong curve would still return true.
func TestVerifyMLDSA_AcceptsOnlyGenuineSignatures(t *testing.T) {
msgA := []byte("work proof alpha")
msgB := []byte("work proof beta")
for _, s := range signers(t) {
t.Run(s.name, func(t *testing.T) {
require.Len(t, s.pub, s.pubSize)
sigA := s.sign(msgA)
require.Len(t, sigA, s.sigSize)
ok, err := VerifyMLDSA(s.pub, msgA, sigA)
require.NoError(t, err)
require.True(t, ok, "a genuine signature must verify")
// A signature is bound to its message: A's signature must not verify B.
ok, err = VerifyMLDSA(s.pub, msgB, sigA)
require.NoError(t, err)
require.False(t, ok, "a signature over message A must not verify message B")
// Nor to a message that merely shares a prefix, or differs in length only.
for _, other := range [][]byte{
nil, {}, msgA[:len(msgA)-1], append(append([]byte{}, msgA...), 0),
} {
ok, err := VerifyMLDSA(s.pub, other, sigA)
require.NoError(t, err)
require.False(t, ok, "signature verified a different message %q", other)
}
// Every single-bit flip in the signature must break it. Sampling the whole
// signature would be slow, so byte positions are spread across it — including
// the first and last, where a truncating or short-circuiting verifier hides.
for _, pos := range []int{0, 1, 7, s.sigSize / 3, s.sigSize / 2, s.sigSize - 2, s.sigSize - 1} {
for _, bit := range []uint{0, 3, 7} {
forged := append([]byte{}, sigA...)
forged[pos] ^= 1 << bit
ok, err := VerifyMLDSA(s.pub, msgA, forged)
require.NoError(t, err)
require.Falsef(t, ok, "signature verified after flipping bit %d of byte %d", bit, pos)
}
}
// A signature under a different key of the same level must not verify.
for _, other := range signers(t) {
if other.name != s.name {
continue
}
ok, err := VerifyMLDSA(s.pub, msgA, other.sign(msgA))
require.NoError(t, err)
require.False(t, ok, "a signature from a different key verified")
}
// An all-zero signature of the right length must not verify.
ok, err = VerifyMLDSA(s.pub, msgA, make([]byte, s.sigSize))
require.NoError(t, err)
require.False(t, ok, "an all-zero signature verified")
// A signature of the wrong length is refused outright, one short and one long.
for _, n := range []int{s.sigSize - 1, s.sigSize + 1, 0} {
_, err := VerifyMLDSA(s.pub, msgA, make([]byte, n))
require.ErrorIs(t, err, ErrInvalidSignatureSize, "signature length %d", n)
}
})
}
}
// TestVerifyMLDSA_LevelIsSelectedByKeyLength proves the parameter set is chosen by public
// key length and that a key of any other length is refused rather than silently routed to
// a verifier it does not belong to. A key of level X with a signature of level Y is refused
// on the length check, so the pairing can never be mixed.
func TestVerifyMLDSA_LevelIsSelectedByKeyLength(t *testing.T) {
msg := []byte("level routing")
ss := signers(t)
for _, a := range ss {
for _, b := range ss {
if a.name == b.name {
continue
}
// a's key with b's signature: the length check refuses it.
_, err := VerifyMLDSA(a.pub, msg, b.sign(msg))
require.ErrorIs(t, err, ErrInvalidSignatureSize,
"%s key accepted a %s signature", a.name, b.name)
}
}
// Any public key length that is not one of the three is refused, at each boundary.
for _, n := range []int{
0, 1, 31,
MLDSA44PublicKeySize - 1, MLDSA44PublicKeySize + 1,
MLDSA65PublicKeySize - 1, MLDSA65PublicKeySize + 1,
MLDSA87PublicKeySize - 1, MLDSA87PublicKeySize + 1,
} {
_, err := VerifyMLDSA(make([]byte, n), msg, make([]byte, MLDSA44SignatureSize))
require.ErrorIs(t, err, ErrInvalidPublicKeySize, "public key length %d", n)
}
}
// TestVerifyMLDSA_MalformedKeyIsRefused proves a public key of the correct LENGTH but
// invalid encoding is reported as an error rather than being treated as a verification
// failure or, worse, accepted.
func TestVerifyMLDSA_MalformedKeyIsRefused(t *testing.T) {
s := signers(t)[0]
msg := []byte("malformed key")
sig := s.sign(msg)
// An all-0xFF key of the right length is not a valid encoding.
junk := make([]byte, s.pubSize)
for i := range junk {
junk[i] = 0xFF
}
ok, err := VerifyMLDSA(junk, msg, sig)
require.False(t, ok)
_ = err // circl may reject at unmarshal or at verify; either way it must not verify.
// A key that is all zeroes must not verify a genuine signature either.
ok, _ = VerifyMLDSA(make([]byte, s.pubSize), msg, sig)
require.False(t, ok, "an all-zero public key verified a signature")
}
// TestRunVerifyMLDSA_ForgeryRejectedThroughDispatch drives the same forgery through the
// registered precompile so the wiring is covered too: the precompile must answer 0 for a
// tampered signature and 1 only for the genuine one, and must never error on well-formed
// framing.
func TestRunVerifyMLDSA_ForgeryRejectedThroughDispatch(t *testing.T) {
c := &AIMiningContract{}
s := signers(t)[0]
msg := []byte("dispatch forgery")
sig := s.sign(msg)
call := func(pub, m, sg []byte) []byte {
body := encodeMLDSAInput(pub, m, sg)
input := make([]byte, 4+len(body))
binary.BigEndian.PutUint32(input[:4], SelectorVerifyMLDSA)
copy(input[4:], body)
out, _, err := c.Run(newTestAS(), common.Address{}, ContractAddress, input, 1_000_000, false)
require.NoError(t, err)
require.Len(t, out, 32)
return out
}
require.Equal(t, byte(1), call(s.pub, msg, sig)[31], "genuine signature must report 1")
forged := append([]byte{}, sig...)
forged[len(forged)/2] ^= 0x01
out := call(s.pub, msg, forged)
require.Equal(t, byte(0), out[31], "a bit-flipped signature must report 0")
require.Equal(t, make([]byte, 32), out, "a rejection must be a clean zero word")
require.Equal(t, byte(0), call(s.pub, []byte("different message"), sig)[31],
"a signature over another message must report 0")
}
// TestVerifyTEE_RefusesMalformedReceipts covers the receipt length guards: an empty
// receipt, and receipts one byte short of each documented minimum, must all be refused.
func TestVerifyTEE_RefusesMalformedReceipts(t *testing.T) {
for _, n := range []int{0, 1, 47} {
ok, err := VerifyTEE(make([]byte, n), make([]byte, 64))
require.False(t, ok, "receipt of %d bytes verified", n)
require.Error(t, err, "receipt of %d bytes must be refused", n)
}
}
// TestBatchVerify_MatchesSingleVerify proves the batch helpers agree with the single-shot
// verifier element by element, including on the mixed case where only some entries are
// genuine — a batch that returned one aggregate answer would mint rewards for forgeries
// riding alongside a valid signature.
func TestBatchVerify_MatchesSingleVerify(t *testing.T) {
s := signers(t)[0]
msgs := [][]byte{[]byte("one"), []byte("two"), []byte("three")}
pubs := make([][]byte, len(msgs))
sigs := make([][]byte, len(msgs))
for i, m := range msgs {
pubs[i] = s.pub
sigs[i] = s.sign(m)
}
// Corrupt exactly the middle one.
sigs[1][0] ^= 0x80
got, err := BatchVerifyMLDSA(pubs, msgs, sigs)
require.NoError(t, err)
require.Len(t, got, len(msgs))
for i := range msgs {
want, err := VerifyMLDSA(pubs[i], msgs[i], sigs[i])
require.NoError(t, err)
require.Equalf(t, want, got[i], "batch disagreed with single verify at %d", i)
}
require.True(t, got[0])
require.False(t, got[1], "the corrupted entry must not be reported valid")
require.True(t, got[2])
// Mismatched slice lengths are refused rather than silently truncated to the shortest.
_, err = BatchVerifyMLDSA(pubs[:2], msgs, sigs)
require.Error(t, err)
_, err = BatchVerifyMLDSA(pubs, msgs[:1], sigs)
require.Error(t, err)
}
+11 -40
View File
@@ -149,32 +149,14 @@ func (c *AIMiningContract) runVerifyMLDSA(
return nil, 0, fmt.Errorf("out of gas")
}
// Parse input: pubkey (32 bytes length prefix), message, signature
if len(input) < 96 {
return nil, suppliedGas - GasVerifyMLDSA, fmt.Errorf("input too short")
// pubkey | message | signature, each length-prefixed. blobs is the one decoder for
// this framing; see its comment for why the lengths may not be handled in uint32.
parts, _, err := blobs(input, 3)
if err != nil {
return nil, suppliedGas - GasVerifyMLDSA, err
}
pubkeyLen := binary.BigEndian.Uint32(input[0:4])
if uint32(len(input)) < 4+pubkeyLen+4 {
return nil, suppliedGas - GasVerifyMLDSA, fmt.Errorf("invalid pubkey length")
}
pubkey := input[4 : 4+pubkeyLen]
offset := 4 + pubkeyLen
msgLen := binary.BigEndian.Uint32(input[offset : offset+4])
if uint32(len(input)) < offset+4+msgLen+4 {
return nil, suppliedGas - GasVerifyMLDSA, fmt.Errorf("invalid message length")
}
message := input[offset+4 : offset+4+msgLen]
offset = offset + 4 + msgLen
sigLen := binary.BigEndian.Uint32(input[offset : offset+4])
if uint32(len(input)) < offset+4+sigLen {
return nil, suppliedGas - GasVerifyMLDSA, fmt.Errorf("invalid signature length")
}
signature := input[offset+4 : offset+4+sigLen]
valid, err := VerifyMLDSA(pubkey, message, signature)
valid, err := VerifyMLDSA(parts[0], parts[1], parts[2])
if err != nil {
return nil, suppliedGas - GasVerifyMLDSA, err
}
@@ -221,24 +203,13 @@ func (c *AIMiningContract) runVerifyTEE(
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 8 {
return nil, suppliedGas - GasVerifyTEE, fmt.Errorf("input too short")
// receipt | signature, each length-prefixed.
parts, _, err := blobs(input, 2)
if err != nil {
return nil, suppliedGas - GasVerifyTEE, err
}
receiptLen := binary.BigEndian.Uint32(input[0:4])
if uint32(len(input)) < 4+receiptLen+4 {
return nil, suppliedGas - GasVerifyTEE, fmt.Errorf("invalid receipt length")
}
receipt := input[4 : 4+receiptLen]
offset := 4 + receiptLen
sigLen := binary.BigEndian.Uint32(input[offset : offset+4])
if uint32(len(input)) < offset+4+sigLen {
return nil, suppliedGas - GasVerifyTEE, fmt.Errorf("invalid signature length")
}
signature := input[offset+4 : offset+4+sigLen]
valid, err := VerifyTEE(receipt, signature)
valid, err := VerifyTEE(parts[0], parts[1])
if err != nil {
return nil, suppliedGas - GasVerifyTEE, err
}
+10 -21
View File
@@ -370,44 +370,33 @@ func TestRunVerifyMLDSARealSig(t *testing.T) {
require.NoError(t, err)
require.Equal(t, byte(1), ret[31])
// Invalid pubkey length overflow
badPK := make([]byte, 4+10)
binary.BigEndian.PutUint32(badPK[:4], 0xFFFFFFFF) // huge length
fullBad := make([]byte, 4+len(badPK))
binary.BigEndian.PutUint32(fullBad[:4], SelectorVerifyMLDSA)
copy(fullBad[4:], badPK)
// But input is short, so "input too short" first -- need 96+ bytes
// Pad to 96 to get past first check, then hit pubkey length check
// A declared length that overruns the calldata is refused, at each of the three
// positions. The assertion is the behaviour -- refusal, and specifically
// ErrInputTooShort -- not the wording, so the three positions share one rule.
padded := make([]byte, 4+100)
binary.BigEndian.PutUint32(padded[:4], SelectorVerifyMLDSA)
binary.BigEndian.PutUint32(padded[4:8], 0xFFFF) // pubkeyLen = huge
binary.BigEndian.PutUint32(padded[4:8], 0xFFFF) // pubkeyLen overruns
_, _, err = c.Run(newTestAS(), common.Address{}, ContractAddress, padded, 100000, false)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid pubkey length")
require.ErrorIs(t, err, ErrInputTooShort)
// Invalid message length
fakeData := make([]byte, 200)
binary.BigEndian.PutUint32(fakeData[:4], 10) // pkLen=10
// pk at 4:14, then msgLen at 14:18
binary.BigEndian.PutUint32(fakeData[14:18], 0xFFFF) // msgLen = huge
binary.BigEndian.PutUint32(fakeData[:4], 10) // pkLen=10
binary.BigEndian.PutUint32(fakeData[14:18], 0xFFFF) // msgLen overruns
fullFake := make([]byte, 4+len(fakeData))
binary.BigEndian.PutUint32(fullFake[:4], SelectorVerifyMLDSA)
copy(fullFake[4:], fakeData)
_, _, err = c.Run(newTestAS(), common.Address{}, ContractAddress, fullFake, 100000, false)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid message length")
require.ErrorIs(t, err, ErrInputTooShort)
// Invalid signature length
fakeData2 := make([]byte, 200)
binary.BigEndian.PutUint32(fakeData2[:4], 10) // pkLen=10
binary.BigEndian.PutUint32(fakeData2[14:18], 10) // msgLen=10
binary.BigEndian.PutUint32(fakeData2[28:32], 0xFFFF) // sigLen = huge
binary.BigEndian.PutUint32(fakeData2[28:32], 0xFFFF) // sigLen overruns
fullFake2 := make([]byte, 4+len(fakeData2))
binary.BigEndian.PutUint32(fullFake2[:4], SelectorVerifyMLDSA)
copy(fullFake2[4:], fakeData2)
_, _, err = c.Run(newTestAS(), common.Address{}, ContractAddress, fullFake2, 100000, false)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid signature length")
require.ErrorIs(t, err, ErrInputTooShort)
}
// --- ML-DSA 44 and 87 ---
+998
View File
@@ -0,0 +1,998 @@
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// module_config_test.go — the module/config surface, the calldata refusal paths, and
// the PRICE-vs-CHARGE relationship between RequiredGas (what the precompile advertises
// a call will cost) and Run (what it actually consumes).
//
// The gas tests here assert a RELATIONSHIP, never a number: gas constants are the
// operator's dial, and a test that pins them fails on a re-price instead of on a bug.
// The relationship that matters is
//
// RequiredGas(input) >= gas Run consumes for that same input (always)
// RequiredGas(input) == gas Run consumes, for a WELL-FORMED frame (tightness)
//
// Under-reporting is the unsafe direction — a host that deducts the advertised price
// up front would hand out the difference as free work. Over-reporting only wastes a
// caller's budget on a frame that was going to revert anyway.
package aivmbridge
import (
"context"
"encoding/binary"
"errors"
"reflect"
"strconv"
"testing"
"github.com/luxfi/crypto/poi"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
)
// --- fakes -------------------------------------------------------------------
// countingIntentStore is an IntentStore that COUNTS writes, so a refusal can be shown
// to have written nothing at all — not merely to have returned an error.
type countingIntentStore struct {
status map[[32]byte]OutboxStatus
writes int
}
func newCountingIntentStore() *countingIntentStore {
return &countingIntentStore{status: make(map[[32]byte]OutboxStatus)}
}
func (s *countingIntentStore) IntentStatus(id [32]byte) OutboxStatus { return s.status[id] }
func (s *countingIntentStore) PutPendingIntent(id [32]byte, _ OutboxIntent) {
s.writes++
s.status[id] = OutboxPending
}
// scriptedVerifierState is a ReceiptVerifierState whose outbox record the test sets
// directly, so the verify core can be driven to states the real store never produces
// (an out-of-range status) and its single effect can be counted.
type scriptedVerifierState struct {
roots map[[32]byte]bool
intent OutboxIntent
consumed int
}
func (v *scriptedVerifierState) ReceiptRootCommitted(r [32]byte) bool { return v.roots[r] }
func (v *scriptedVerifierState) LoadIntent([32]byte) OutboxIntent { return v.intent }
func (v *scriptedVerifierState) MarkIntentConsumed([32]byte, uint64) {
v.consumed++
v.intent.Status = OutboxConsumed
}
// foreignConfig is another precompile's config type — a valid precompileconfig.Config
// that is not *aivmbridge.Config.
type foreignConfig struct{}
func (foreignConfig) Key() string { return "someOtherPrecompileConfig" }
func (foreignConfig) Timestamp() *uint64 { return nil }
func (foreignConfig) IsDisabled() bool { return false }
func (foreignConfig) Verify(precompileconfig.ChainConfig) error { return nil }
func (foreignConfig) Equal(precompileconfig.Config) bool { return false }
var (
_ IntentStore = (*countingIntentStore)(nil)
_ ReceiptVerifierState = (*scriptedVerifierState)(nil)
_ precompileconfig.Config = foreignConfig{}
)
// --- shared Pattern-B fixture (no Run layer) ----------------------------------
// committedReceipt returns a receipt, a GENUINE inclusion proof for it, and a factory
// for a verifier state that has the proof's root committed and the bound intent pending.
// It is the smallest fully-consistent Pattern-B input, independent of the Run fixture.
func committedReceipt() (AInferenceReceipt, AInferenceProof, func() *scriptedVerifierState) {
r := AInferenceReceipt{
Version: ReceiptVersion,
IntentID: h32(0x1D),
TaskID: h32(0x7A),
CChainID: h32(0xCC),
AChainID: h32(0xA0),
Requester: common.Address{0x42},
ModelSpecHash: h32(0x5E),
PromptHash: h32(0x9D),
CanonicalOutputHash: h32(0x0F),
Status: StatusCompleted,
N: 3,
Threshold: 2,
}
tree := buildMerkleTree([][32]byte{h32(0x01), ReceiptHash(r), h32(0x03)})
p := tree.proof(1)
newState := func() *scriptedVerifierState {
return &scriptedVerifierState{
roots: map[[32]byte]bool{p.ReceiptRoot: true},
intent: OutboxIntent{
Status: OutboxPending,
Caller: r.Requester,
CChainID: r.CChainID,
AChainID: r.AChainID,
ModelSpecHash: r.ModelSpecHash,
PromptHash: r.PromptHash,
N: r.N,
Threshold: r.Threshold,
BlockNumber: 77,
},
}
}
return r, p, newState
}
// --- calldata builders used only here -----------------------------------------
// proofBytesOfDepth encodes a WELL-FORMED proof frame of the given path depth: the
// header's declared pathLen and the bytes that follow agree.
func proofBytesOfDepth(root [32]byte, depth int) []byte {
p := AInferenceProof{ReceiptRoot: root, Path: make([][32]byte, depth)}
for i := range p.Path {
p.Path[i] = h32(byte(i + 1))
}
return encodeProof(p)
}
// proofBytesClaiming builds a path-FREE (42-byte) proof whose header DECLARES `declared`
// path nodes that are not present. This is the frame whose declared depth and actual
// length disagree.
func proofBytesClaiming(root [32]byte, declared uint16) []byte {
b := proofBytesOfDepth(root, 0)
binary.BigEndian.PutUint16(b[40:42], declared)
return b
}
// verifyFrameDeclaring builds a verify frame whose DECLARED lengths differ from the
// bytes actually carried, so each framing position can be corrupted on its own.
func verifyFrameDeclaring(receipt, proof []byte, declRcpt, declProof uint16) []byte {
out := encodeVerify(receipt, proof)
binary.BigEndian.PutUint16(out[4:6], declRcpt)
binary.BigEndian.PutUint16(out[6:8], declProof)
return out
}
func receiptBytesWithVersion(r AInferenceReceipt, v uint16) []byte {
b := EncodeReceipt(r)
binary.BigEndian.PutUint16(b[0:2], v)
return b
}
func computeFrame(opening []byte) []byte {
in := make([]byte, 4+len(opening))
putU32(in[0:4], SelectorVerifyComputeProof)
copy(in[4:], opening)
return in
}
// --- module registration + configurator ---------------------------------------
// The bridge must occupy its OWN slot in the AI range and must store its state under
// that same identity. A copy-pasted address would silently squat the AI-mining or the
// deterministic-inference precompile.
func TestModule_RegisteredAtItsOwnDisjointAddress(t *testing.T) {
got, ok := modules.GetPrecompileModuleByAddress(ContractAddress)
if !ok {
t.Fatalf("init() left the bridge unregistered at %s", ContractAddress)
}
if got.ConfigKey != ConfigKey {
t.Fatalf("registered under config key %q, want %q", got.ConfigKey, ConfigKey)
}
if got.Contract != BridgePrecompile {
t.Fatal("the registered contract is not the bridge singleton")
}
if got.Configurator == nil {
t.Fatal("the registered module carries no configurator")
}
if bridgeStateAddr != ContractAddress {
t.Fatal("bridge state must live under the precompile's own address")
}
for _, taken := range []common.Address{
common.HexToAddress("0x0300000000000000000000000000000000000000"), // AI mining
common.HexToAddress("0x0300000000000000000000000000000000000003"), // deterministic inference
} {
if ContractAddress == taken {
t.Fatalf("the bridge squats %s, an address another AI precompile owns", taken)
}
}
}
// MakeConfig must hand back a FRESH config each call — the host builds one per network
// upgrade entry and then fills it in; a shared instance would let one chain's upgrade
// timestamp leak into another's. Configure initializes no genesis state.
func TestConfigurator_MakeConfigIsFreshAndConfigureWritesNoState(t *testing.T) {
cfgr := Module.Configurator
a, ok := cfgr.MakeConfig().(*Config)
if !ok || a == nil {
t.Fatal("MakeConfig must return a non-nil *Config")
}
b, ok := cfgr.MakeConfig().(*Config)
if !ok || b == nil {
t.Fatal("MakeConfig must return a non-nil *Config")
}
if a == b {
t.Fatal("MakeConfig handed back the same instance twice; two upgrade entries would alias")
}
if a.Key() != ConfigKey || a.IsDisabled() || a.Timestamp() != nil {
t.Fatalf("a fresh config must be the zero upgrade under %q, got disabled=%v ts=%v",
ConfigKey, a.IsDisabled(), a.Timestamp())
}
ts := uint64(99)
a.Upgrade.BlockTimestamp = &ts
a.Upgrade.Disable = true
if b.Timestamp() != nil || b.IsDisabled() {
t.Fatal("configs from MakeConfig share backing state")
}
db := newMemStateDB()
if err := cfgr.Configure(nil, a, db, nil); err != nil {
t.Fatalf("Configure: %v", err)
}
if n := len(db.slots); n != 0 {
t.Fatalf("Configure wrote %d storage accounts; the bridge has no genesis state", n)
}
}
// The config's read surface is a pass-through onto the Upgrade it holds — a host that
// edits the upgrade sees the change, and Verify accepts every well-formed shape (the
// bridge takes no per-network parameters, so there is nothing to reject).
func TestConfig_SurfaceTracksTheUpgradeItHolds(t *testing.T) {
var c Config
if c.Key() != ConfigKey || c.Key() != Module.ConfigKey {
t.Fatalf("Key() = %q, want %q (and it must match the registered module)", c.Key(), ConfigKey)
}
if c.Timestamp() != nil {
t.Fatal("an unset upgrade must report no activation timestamp (never enabled)")
}
if c.IsDisabled() {
t.Fatal("an unset upgrade is not a disable")
}
if err := c.Verify(nil); err != nil {
t.Fatalf("Verify rejected the zero config: %v", err)
}
for _, want := range []uint64{0, 1, 1 << 40} {
v := want
c.Upgrade.BlockTimestamp = &v
got := c.Timestamp()
if got == nil || *got != want {
t.Fatalf("Timestamp() = %v, want %d", got, want)
}
}
c.Upgrade.Disable = true
if !c.IsDisabled() {
t.Fatal("IsDisabled() must follow Upgrade.Disable")
}
if err := c.Verify(nil); err != nil {
t.Fatalf("Verify must accept a disabling config too: %v", err)
}
}
// Equal must react to EVERY field it is supposed to compare, in both directions. The
// reflection guards make a newly-added field a loud failure here instead of a silently
// ignored one at the comparison site.
func TestConfig_EqualComparesEveryField(t *testing.T) {
if n := reflect.TypeOf(Config{}).NumField(); n != 1 {
t.Fatalf("Config has %d fields but Equal only forwards Upgrade — extend Equal and this test", n)
}
if n := reflect.TypeOf(precompileconfig.Upgrade{}).NumField(); n != 2 {
t.Fatalf("Upgrade has %d fields but this test mutates 2 — extend the mutator list", n)
}
ptr := func(v uint64) *uint64 { return &v }
mutators := []struct {
field string
mutate func(*Config)
}{
{"Upgrade.BlockTimestamp", func(c *Config) { c.Upgrade.BlockTimestamp = ptr(7) }},
{"Upgrade.Disable", func(c *Config) { c.Upgrade.Disable = true }},
}
for _, m := range mutators {
t.Run(m.field, func(t *testing.T) {
a, b := &Config{}, &Config{}
if !a.Equal(b) || !b.Equal(a) {
t.Fatal("two zero configs must compare equal")
}
m.mutate(b)
if a.Equal(b) {
t.Fatalf("Equal ignored %s", m.field)
}
if b.Equal(a) {
t.Fatalf("Equal ignored %s in the reverse direction", m.field)
}
// Applying the SAME mutation to both restores equality — so Equal reacted to
// the difference, not merely to the mutated value.
m.mutate(a)
if !a.Equal(b) || !b.Equal(a) {
t.Fatalf("Equal is not symmetric after both sides took %s", m.field)
}
})
}
t.Run("timestamp compares by value not pointer", func(t *testing.T) {
a := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr(9)}}
b := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr(9)}}
if !a.Equal(b) || !b.Equal(a) {
t.Fatal("distinct pointers holding the same timestamp must compare equal")
}
c := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr(10)}}
if a.Equal(c) || c.Equal(a) {
t.Fatal("different timestamps must not compare equal")
}
// nil and set are distinguishable in both directions.
z := &Config{}
if a.Equal(z) || z.Equal(a) {
t.Fatal("an unset timestamp must not equal a set one")
}
})
t.Run("foreign type and nil", func(t *testing.T) {
a := &Config{}
if a.Equal(foreignConfig{}) {
t.Fatal("Equal accepted another precompile's config type")
}
if a.Equal(&foreignConfig{}) {
t.Fatal("Equal accepted a pointer to another precompile's config type")
}
if a.Equal(nil) {
t.Fatal("Equal accepted a nil config")
}
})
}
// --- RequiredGas vs what Run charges ------------------------------------------
// The price the precompile advertises must never be BELOW what Run consumes for the
// same bytes (that direction hands out free work), and for a frame Run actually
// decodes it must be EXACT (drift either way is an accounting bug).
func TestRequiredGas_NeverBelowWhatRunCharges(t *testing.T) {
seed := newVerifyFixture(t)
root := seed.proof.ReceiptRoot
receipt := EncodeReceipt(seed.receipt)
type frame struct {
name string
in []byte
exact bool // Run decodes this frame, so the advertised price must match exactly
}
frames := []frame{
{"submit", encodeSubmit(h32(0x11), h32(0x22), 3, 2, feeWord(5), [32]byte{}), true},
{"compute proof, honest opening", computeFrame(buildOpening(false)), true},
{"compute proof, undecodable", computeFrame([]byte{1, 2, 3, 4, 5}), true},
// A frame Run refuses before it can meter anything: it returns the whole budget,
// so charged is 0 and only the >= half of the invariant is meaningful.
{"unknown selector", []byte{0xFF, 0xFF, 0xFF, 0xFF}, false},
{"below the selector", []byte{0x11, 0x00}, false},
// A verify frame whose declared proof length is under the 42-byte proof header.
{"proof shorter than its header", encodeVerify(receipt, make([]byte, 10)), true},
// A verify frame whose receipt does not decode: the proof is well formed, so the
// advertised price includes the merkle walk that Run never performs.
{"undecodable receipt", encodeVerify(receiptBytesWithVersion(seed.receipt, ReceiptVersion+1), proofBytesOfDepth(root, 2)), false},
// The frame LIES about its depth: 42 proof bytes, header claiming MaxProofDepth.
{"proof header lies about depth", encodeVerify(receipt, proofBytesClaiming(root, MaxProofDepth)), false},
{"proof header lies by one node", encodeVerify(receipt, proofBytesClaiming(root, 1)), false},
{"declared depth over the cap", encodeVerify(receipt, proofBytesClaiming(root, MaxProofDepth+1)), true},
}
// Every WELL-FORMED depth, including both sides of the cap boundary.
for _, d := range []int{0, 1, 2, 3, 17, MaxProofDepth - 1, MaxProofDepth} {
frames = append(frames, frame{
name: "well-formed proof depth " + strconv.Itoa(d),
in: encodeVerify(receipt, proofBytesOfDepth(root, d)),
exact: true,
})
}
for _, f := range frames {
t.Run(f.name, func(t *testing.T) {
fx := newVerifyFixture(t)
charged, advertised, _ := chargedGas(t, fx.st, fx.caller, f.in)
if advertised < charged {
t.Fatalf("UNDER-PRICED: RequiredGas advertised %d, Run consumed %d — %d gas of work for free",
advertised, charged, charged-advertised)
}
if f.exact && advertised != charged {
t.Fatalf("price drift on a frame Run decodes: advertised %d, charged %d", advertised, charged)
}
if advertised != charged {
t.Logf("over-priced by %d (advertised %d, charged %d) — safe direction", advertised-charged, advertised, charged)
}
})
}
}
// REGRESSION. module.go prices verifyInferenceReceipt from the pathLen the CALLER
// DECLARES in the proof header (module.go:124) without checking it against the proof's
// actual byte length; bridge.go prices from the DERIVED len(p.Path) after DecodeProof
// has enforced len(proof) == 42 + pathLen*32 (proof.go:118-126). A 42-byte proof whose
// header claims MaxProofDepth therefore advertises the deepest possible walk while Run
// bails at decode and charges only the base.
//
// The direction is what matters. Over-pricing (advertised > charged) only wastes the
// caller's own budget on a frame that reverts anyway. UNDER-pricing would be the bug:
// a host that deducts the advertised price up front would execute the difference for
// free. This test pins the direction, not the size of the gap, so it keeps holding if
// the cross-check is added and the gap closes to zero.
func TestRequiredGas_DeclaredDepthIsNotCheckedAgainstProofLength(t *testing.T) {
fx := newVerifyFixture(t)
receipt := EncodeReceipt(fx.receipt)
root := fx.proof.ReceiptRoot
lying := encodeVerify(receipt, proofBytesClaiming(root, MaxProofDepth))
charged, advertised, err := chargedGas(t, fx.st, fx.caller, lying)
if !errors.Is(err, ErrProofDecode) {
t.Fatalf("a proof whose header contradicts its length must be refused, got %v", err)
}
if advertised < charged {
t.Fatalf("UNDER-PRICED: advertised %d < charged %d", advertised, charged)
}
t.Logf("declared-depth frame: advertised %d, charged %d, slack %d", advertised, charged, advertised-charged)
// The honest frame at the same declared depth prices identically to what Run
// charges — so the slack above comes from the missing cross-check, not from the
// depth pricing itself.
honestFx := newVerifyFixture(t)
honest := encodeVerify(receipt, proofBytesOfDepth(root, MaxProofDepth))
honestCharged, honestAdvertised, err := chargedGas(t, honestFx.st, honestFx.caller, honest)
if !errors.Is(err, ErrMerkleVerify) {
t.Fatalf("the honest deep frame must reach the merkle check, got %v", err)
}
if honestAdvertised != honestCharged {
t.Fatalf("a well-formed deep frame must be priced exactly: advertised %d, charged %d",
honestAdvertised, honestCharged)
}
if honestAdvertised != advertised {
t.Fatalf("the lying frame and the honest frame of the same declared depth priced differently (%d vs %d)",
advertised, honestAdvertised)
}
t.Logf("honest frame at the same declared depth: advertised == charged == %d", honestAdvertised)
}
// --- selector routing ----------------------------------------------------------
// The compute-proof selector routes to the native proof-of-inference check, and that
// check is PURE: it writes no C state, so it is legal inside a static call.
func TestRun_RoutesComputeProofAndStaysPureUnderStaticCall(t *testing.T) {
st := newMockState(common.HexToHash("0xC0DE"))
caller := common.Address{0x1}
for _, tc := range []struct {
name string
in []byte
verdict byte
}{
{"honest opening", computeFrame(buildOpening(false)), resultIncluded | resultFreivaldsOK},
{"fabricated output", computeFrame(buildOpening(true)), resultIncluded},
} {
advertised := BridgePrecompile.RequiredGas(tc.in)
if advertised <= GasVerifyComputeProofBase {
t.Fatalf("%s: a real opening must price above the base, got %d", tc.name, advertised)
}
for _, readOnly := range []bool{false, true} {
ret, rem, err := BridgePrecompile.Run(st, caller, ContractAddress, tc.in, advertised, readOnly)
if err != nil {
t.Fatalf("%s readOnly=%v: %v", tc.name, readOnly, err)
}
if len(ret) != 32 {
t.Fatalf("%s: verdict must be one word, got %d bytes", tc.name, len(ret))
}
if ret[31] != tc.verdict {
t.Fatalf("%s: verdict = %d, want %d", tc.name, ret[31], tc.verdict)
}
if rem != 0 {
t.Fatalf("%s: the advertised price must be exactly consumed, %d left", tc.name, rem)
}
}
}
if n := len(st.db.slots); n != 0 {
t.Fatalf("compute-proof verification wrote %d storage accounts; it must touch no state", n)
}
// An undecodable opening prices at the base and reverts there.
garbage := computeFrame([]byte{1, 2, 3})
if g := BridgePrecompile.RequiredGas(garbage); g != GasVerifyComputeProofBase {
t.Fatalf("an undecodable opening must price at the base, got %d", g)
}
if _, _, err := BridgePrecompile.Run(st, caller, ContractAddress, garbage, GasVerifyComputeProofBase, false); err == nil {
t.Fatal("an undecodable opening must revert")
}
}
// --- the fixed-width word decoder ----------------------------------------------
// readUint16Word takes the value from the low two bytes and refuses anything smuggled
// above the declared width. Every high byte is checked one at a time, so a loop that
// stops early is caught.
func TestReadUint16Word_RefusesShortAndDirtyWords(t *testing.T) {
for _, n := range []int{0, 1, 30, 31} {
if _, err := readUint16Word(make([]byte, n)); !errors.Is(err, ErrInputTooShort) {
t.Fatalf("a %d-byte word must be refused, not zero-extended: %v", n, err)
}
}
for _, want := range []uint16{0, 1, 255, 256, 0xFF00, 0xFFFF} {
w := make([]byte, 32)
binary.BigEndian.PutUint16(w[30:32], want)
got, err := readUint16Word(w)
if err != nil || got != want {
t.Fatalf("value %d: got %d, err %v", want, got, err)
}
}
for i := 0; i < 30; i++ {
w := make([]byte, 32)
binary.BigEndian.PutUint16(w[30:32], 0x2A)
w[i] = 0x01
if _, err := readUint16Word(w); !errors.Is(err, ErrDirtyWord) {
t.Fatalf("a non-zero byte at high position %d was not refused: %v", i, err)
}
}
// The decoder is bounded to the first word: bytes past 32 are neither read as value
// nor treated as dirt.
long := make([]byte, 64)
binary.BigEndian.PutUint16(long[30:32], 7)
for i := 32; i < 64; i++ {
long[i] = 0xFF
}
got, err := readUint16Word(long)
if err != nil || got != 7 {
t.Fatalf("a longer slice must read only its first word: got %d, err %v", got, err)
}
}
// The threshold word is hardened exactly like the N word: a caller cannot smuggle bits
// above the declared uint16, and the refusal happens before any state is written.
func TestSubmit_RefusesDirtyThresholdWord(t *testing.T) {
installNative(t, h32(0xA0), nil)
good := encodeSubmit(h32(0x11), h32(0x22), 3, 2, feeWord(5), [32]byte{})
caller := common.Address{0x1}
// The threshold word is args[96:128], i.e. input[100:132]; its high bytes are
// input[100:130].
for _, off := range []int{100, 115, 129} {
in := append([]byte(nil), good...)
in[off] = 0x01
st := newMockState(common.HexToHash("0x02"))
_, _, err := BridgePrecompile.Run(st, caller, ContractAddress, in, BridgePrecompile.RequiredGas(in), false)
if !errors.Is(err, ErrDirtyWord) {
t.Fatalf("dirty byte at input[%d]: err = %v, want %v", off, err, ErrDirtyWord)
}
if n := len(st.db.slots); n != 0 {
t.Fatalf("dirty byte at input[%d]: a refused submit wrote %d storage accounts", off, n)
}
}
// Control: the clean frame still submits, so the refusals above are not vacuous.
st := newMockState(common.HexToHash("0x03"))
if _, _, err := BridgePrecompile.Run(st, caller, ContractAddress, good, BridgePrecompile.RequiredGas(good), false); err != nil {
t.Fatalf("the clean frame must submit: %v", err)
}
}
// --- verify framing refusals ---------------------------------------------------
// Every framing position is exact: a declared length that disagrees with the bytes
// carried is refused, and so is a payload that does not decode. No refusal may consume
// the pending intent.
func TestVerify_RefusesEveryFramingMismatch(t *testing.T) {
// Control first, on its own fixture, so "refused" below is a real distinction.
ctrl := newVerifyFixture(t)
if _, err := ctrl.run(t, ctrl.receipt, ctrl.proof); err != nil {
t.Fatalf("the well-formed frame must verify: %v", err)
}
f := newVerifyFixture(t)
receipt := EncodeReceipt(f.receipt)
proof := encodeProof(f.proof)
rl, pl := uint16(len(receipt)), uint16(len(proof))
root := f.proof.ReceiptRoot
cases := []struct {
name string
in []byte
want error
}{
{"args below the 4-byte header", encodeVerify(receipt, proof)[:4+3], ErrInputTooShort},
{"receiptLen one short", verifyFrameDeclaring(receipt, proof, rl-1, pl), ErrInputOversized},
{"receiptLen one long", verifyFrameDeclaring(receipt, proof, rl+1, pl), ErrInputOversized},
{"receiptLen zero", verifyFrameDeclaring(receipt, proof, 0, pl), ErrInputOversized},
{"proofLen one short", verifyFrameDeclaring(receipt, proof, rl, pl-1), ErrInputOversized},
{"proofLen one long", verifyFrameDeclaring(receipt, proof, rl, pl+1), ErrInputOversized},
{"trailing junk", append(encodeVerify(receipt, proof), 0xAA), ErrInputOversized},
{"receipt truncated", encodeVerify(receipt[:len(receipt)-1], proof), ErrReceiptDecode},
{"receipt one byte long", encodeVerify(append(append([]byte(nil), receipt...), 0x00), proof), ErrReceiptDecode},
{"receipt version unknown", encodeVerify(receiptBytesWithVersion(f.receipt, ReceiptVersion+1), proof), ErrReceiptDecode},
{"proof below its header", encodeVerify(receipt, proof[:41]), ErrProofDecode},
{"proof header lies about depth", encodeVerify(receipt, proofBytesClaiming(root, MaxProofDepth)), ErrProofDecode},
{"proof depth over the cap", encodeVerify(receipt, proofBytesClaiming(root, MaxProofDepth+1)), ErrProofDecode},
{"proof carries an extra node", encodeVerify(receipt, append(append([]byte(nil), proof...), make([]byte, 32)...)), ErrProofDecode},
}
store := newStateKVStore(f.st.db, f.st.blockNumber)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gas := BridgePrecompile.RequiredGas(tc.in)
_, rem, err := BridgePrecompile.Run(f.st, f.caller, ContractAddress, tc.in, gas, false)
if !errors.Is(err, tc.want) {
t.Fatalf("err = %v, want %v", err, tc.want)
}
if rem > gas {
t.Fatalf("a refusal returned more gas than it was given (%d > %d)", rem, gas)
}
if got := store.IntentStatus(f.intentID); got != OutboxPending {
t.Fatalf("a refused verify moved the intent to status %d; it must stay pending", got)
}
})
}
// After every refusal the intent still settles on a well-formed frame.
if _, err := f.run(t, f.receipt, f.proof); err != nil {
t.Fatalf("the intent must still be settleable after the refusals: %v", err)
}
}
// --- merkle ---------------------------------------------------------------------
// VerifyMerkle answers false — it never errors and never panics — for a path that does
// not reconstruct the root, and it refuses a path deeper than MaxProofDepth even when
// that path WOULD reconstruct the declared root.
func TestVerifyMerkle_RefusesOverDepthAndNonReconstructingPath(t *testing.T) {
leaves := [][32]byte{h32(0x01), h32(0x02), h32(0x03), h32(0x04), h32(0x05)}
tree := buildMerkleTree(leaves)
leaf := leafHash(h32(0x03))
p := tree.proof(2)
if !VerifyMerkle(leaf, p) {
t.Fatal("a genuine proof must verify")
}
for i := range p.Path {
bad := AInferenceProof{ReceiptRoot: p.ReceiptRoot, Index: p.Index, Path: append([][32]byte(nil), p.Path...)}
bad.Path[i][0] ^= 0xFF
if VerifyMerkle(leaf, bad) {
t.Fatalf("a corrupted sibling at level %d still reconstructed the root", i)
}
}
if VerifyMerkle(leafHash(h32(0x99)), p) {
t.Fatal("a leaf that is not in the tree must not verify under a genuine path")
}
if VerifyMerkle(leaf, AInferenceProof{ReceiptRoot: h32(0xEE), Index: p.Index, Path: p.Path}) {
t.Fatal("a genuine path must not verify against a different root")
}
// fold builds a path of n siblings and the root it reconstructs to, with every index
// bit zero (leaf stays the LEFT child at every level).
fold := func(n int) AInferenceProof {
node := leaf
path := make([][32]byte, n)
for i := range path {
path[i] = h32(byte(i + 1))
node = hashPair(node, path[i])
}
return AInferenceProof{ReceiptRoot: node, Path: path, Index: 0}
}
if !VerifyMerkle(leaf, fold(MaxProofDepth)) {
t.Fatal("a path of exactly MaxProofDepth must be accepted — the cap is inclusive")
}
if VerifyMerkle(leaf, fold(MaxProofDepth+1)) {
t.Fatal("a path past MaxProofDepth must be refused even though it folds to the declared root")
}
}
// --- the A-Chain on-ramp handle -------------------------------------------------
// With no on-ramp wired, Pattern A refuses cleanly and writes nothing, while Pattern B
// keeps working — verifying a committed receipt against committed C state never needs A.
func TestAChainUnavailable_RefusesSubmitButStillVerifies(t *testing.T) {
resetAChainClientForTest()
t.Cleanup(resetAChainClientForTest)
c := currentAChainClient()
if c.Brand() == "" {
t.Fatal("even the closed on-ramp must name itself; InstallAChainClient refuses an empty brand")
}
if !isZero32(c.AChainID()) {
t.Fatal("the closed on-ramp declares no rail peer")
}
store := newCountingIntentStore()
id, err := c.SubmitInferenceIntent(context.Background(), store, InferenceIntent{ModelSpecHash: h32(0x11)})
if !errors.Is(err, ErrAChainUnavailable) {
t.Fatalf("submit with no on-ramp: err = %v, want %v", err, ErrAChainUnavailable)
}
if !isZero32(id) {
t.Fatal("a refused submit must not mint an id")
}
if store.writes != 0 {
t.Fatalf("a refused submit wrote the outbox %d times", store.writes)
}
r, p, newState := committedReceipt()
direct, indirect := newState(), newState()
wantV, wantErr := verifyInferenceReceipt(direct, r, p)
if wantErr != nil {
t.Fatalf("the fixture receipt must verify: %v", wantErr)
}
gotV, gotErr := c.VerifyInferenceReceipt(context.Background(), indirect, r, p)
if gotErr != nil || gotV != wantV {
t.Fatalf("the closed on-ramp's verify diverged from the state core: (%v, %v) vs (%v, %v)",
gotV, gotErr, wantV, wantErr)
}
if direct.consumed != 1 || indirect.consumed != 1 {
t.Fatalf("a verified receipt consumes its intent exactly once (direct %d, via client %d)",
direct.consumed, indirect.consumed)
}
}
// The singleton is never dereferenced blind: an unset pointer resolves to the closed
// on-ramp, so a Run() in flight reverts instead of panicking.
func TestCurrentAChainClient_UnsetPointerYieldsTheClosedOnRamp(t *testing.T) {
resetAChainClientForTest()
t.Cleanup(resetAChainClientForTest)
aChainClient.Store(nil)
c := currentAChainClient()
if c == nil {
t.Fatal("currentAChainClient returned nil")
}
if c.Brand() == "" || !isZero32(c.AChainID()) {
t.Fatal("an unset singleton must resolve to the closed on-ramp")
}
if _, err := c.SubmitInferenceIntent(context.Background(), newCountingIntentStore(), InferenceIntent{}); !errors.Is(err, ErrAChainUnavailable) {
t.Fatalf("an unset singleton must refuse Pattern A: %v", err)
}
st := newMockState(common.HexToHash("0xFEED"))
in := encodeSubmit(h32(0x11), h32(0x22), 1, 1, feeWord(1), [32]byte{})
if _, _, err := BridgePrecompile.Run(st, common.Address{0x1}, ContractAddress, in, BridgePrecompile.RequiredGas(in), false); !errors.Is(err, ErrAChainUnavailable) {
t.Fatalf("Run against an unset singleton: %v", err)
}
if n := len(st.db.slots); n != 0 {
t.Fatalf("a refused submit wrote %d storage accounts", n)
}
}
// --- the staged native client ---------------------------------------------------
// A host that passes no white-label name still gets an INSTALLABLE client, and an
// explicit brand survives verbatim.
func TestNativeClient_BrandDefaultsToSomethingInstallable(t *testing.T) {
resetAChainClientForTest()
t.Cleanup(resetAChainClientForTest)
def := NewNativeAChainClient("", h32(0xA0), nil)
if def.Brand() == "" {
t.Fatal("an empty brand must fall back to a non-empty default")
}
if def.Brand() == (achainUnavailable{}).Brand() {
t.Fatal("a wired client must be distinguishable from the closed on-ramp in a log line")
}
if err := InstallAChainClient(def); err != nil {
t.Fatalf("a default-branded client must be installable: %v", err)
}
named := NewNativeAChainClient("Hanzo Inference", h32(0xA1), nil)
if named.Brand() != "Hanzo Inference" {
t.Fatalf("an explicit brand must survive verbatim, got %q", named.Brand())
}
if named.AChainID() != h32(0xA1) {
t.Fatal("the client must report the rail peer it was constructed with")
}
}
// The client binds its OWN rail peer into the derived id (a caller cannot mint an id
// for another rail), and the outbox is append-once.
func TestNativeClient_BindsItsRailAndWritesOnce(t *testing.T) {
c := NewNativeAChainClient("Lux Inference", h32(0xA0), nil)
store := newCountingIntentStore()
in := InferenceIntent{
CChainID: h32(0xCC),
AChainID: h32(0xBB), // a rail the caller asked for; the client overrides it
CTxHash: h32(0x7A),
CallIndex: 1,
Caller: common.Address{0x9},
ModelSpecHash: h32(0x5E),
ModelPromptHash: h32(0x9D),
N: 3,
Threshold: 2,
Fee: feeWord(5),
}
bound := in
bound.AChainID = h32(0xA0)
id, err := c.SubmitInferenceIntent(context.Background(), store, in)
if err != nil {
t.Fatalf("submit: %v", err)
}
if id != DeriveIntentID(bound) {
t.Fatal("the client must bind its own rail peer into the id, not the caller's")
}
if store.writes != 1 || store.IntentStatus(id) != OutboxPending {
t.Fatalf("one committed pending record expected, got %d writes / status %d", store.writes, store.IntentStatus(id))
}
id2, err := c.SubmitInferenceIntent(context.Background(), store, in)
if !errors.Is(err, ErrIntentReplay) {
t.Fatalf("a second submit of the same intent: err = %v, want %v", err, ErrIntentReplay)
}
if !isZero32(id2) {
t.Fatal("a refused replay must not return an id")
}
if store.writes != 1 {
t.Fatalf("the replay wrote the outbox again (%d writes)", store.writes)
}
// A client on a different rail derives a different id from the same intent, so the
// first rail's record does not block it.
other := NewNativeAChainClient("Lux Inference", h32(0xA2), nil)
id3, err := other.SubmitInferenceIntent(context.Background(), store, in)
if err != nil {
t.Fatalf("submit on a second rail: %v", err)
}
if id3 == id {
t.Fatal("two rails must not share an intent id")
}
if store.writes != 2 {
t.Fatalf("the second rail must write its own record, got %d writes", store.writes)
}
}
// Pattern B on the native client is the package's state core, nothing more — same
// result, same single effect, same double-consume refusal.
func TestNativeClient_VerifyIsTheStateCore(t *testing.T) {
r, p, newState := committedReceipt()
c := NewNativeAChainClient("Lux Inference", h32(0xA0), nil)
direct, viaClient := newState(), newState()
wantV, wantErr := verifyInferenceReceipt(direct, r, p)
if wantErr != nil {
t.Fatalf("the fixture receipt must verify: %v", wantErr)
}
gotV, gotErr := c.VerifyInferenceReceipt(context.Background(), viaClient, r, p)
if gotErr != nil || gotV != wantV {
t.Fatalf("client verify diverged from the core: (%v, %v) vs (%v, %v)", gotV, gotErr, wantV, wantErr)
}
if viaClient.consumed != 1 {
t.Fatalf("verify must consume the intent exactly once, got %d", viaClient.consumed)
}
if _, err := c.VerifyInferenceReceipt(context.Background(), viaClient, r, p); !errors.Is(err, ErrIntentConsumed) {
t.Fatalf("a second verify: err = %v, want %v", err, ErrIntentConsumed)
}
if viaClient.consumed != 1 {
t.Fatalf("a refused second verify consumed again (%d)", viaClient.consumed)
}
}
// --- the verify core's outbox status switch --------------------------------------
// Only a PENDING outbox record settles. Anything else — absent, already consumed, or a
// value the store should never hold — refuses and leaves the record alone.
func TestVerify_OnlyPendingSettles(t *testing.T) {
r, p, newState := committedReceipt()
for _, tc := range []struct {
status OutboxStatus
want error
}{
{OutboxNone, ErrNoMatchingIntent},
{OutboxConsumed, ErrIntentConsumed},
{OutboxStatus(3), ErrNoMatchingIntent},
{OutboxStatus(255), ErrNoMatchingIntent},
} {
s := newState()
s.intent.Status = tc.status
v, err := verifyInferenceReceipt(s, r, p)
if !errors.Is(err, tc.want) {
t.Fatalf("outbox status %d: err = %v, want %v", tc.status, err, tc.want)
}
if v != (VerifiedAInferenceReceipt{}) {
t.Fatalf("outbox status %d: a refusal must return the zero result", tc.status)
}
if s.consumed != 0 {
t.Fatalf("outbox status %d: a refusal consumed the intent", tc.status)
}
}
s := newState()
if _, err := verifyInferenceReceipt(s, r, p); err != nil {
t.Fatalf("a pending record must settle: %v", err)
}
if s.consumed != 1 {
t.Fatalf("a settled record must be consumed once, got %d", s.consumed)
}
}
// --- the A->C receipt-root checkpoint --------------------------------------------
// A zero root is never committed (the empty slot already reads as zero, so accepting it
// would make an unwritten root indistinguishable from a trusted one), and a root
// committed at height ZERO must still read as committed.
func TestCommitReceiptRoot_RefusesZeroAndRecordsHeightZero(t *testing.T) {
db := newMemStateDB()
s := newStateKVStore(db, 5)
CommitReceiptRoot(db, [32]byte{}, 9)
if n := len(db.slots); n != 0 {
t.Fatalf("committing the zero root wrote %d storage accounts", n)
}
if s.ReceiptRootCommitted([32]byte{}) {
t.Fatal("the zero root must never read as committed")
}
root := h32(0x77)
if s.ReceiptRootCommitted(root) {
t.Fatal("an unwritten root must not read as committed")
}
CommitReceiptRoot(db, root, 0)
if !s.ReceiptRootCommitted(root) {
t.Fatal("a root committed at height 0 must read as committed")
}
if s.ReceiptRootCommitted(h32(0x78)) {
t.Fatal("a root that was never committed must not read as committed")
}
// Re-committing at a later height keeps it committed (the seam may see the same root
// twice across an import replay).
CommitReceiptRoot(db, root, 1_000)
if !s.ReceiptRootCommitted(root) {
t.Fatal("re-committing a root must not un-commit it")
}
}
// --- the native proof-of-inference check ------------------------------------------
// An opening that decodes is priced from its decoded dimensions; one gas below that
// price is refused outright rather than half-executed, and an opening whose matrices
// cannot be multiplied is refused instead of yielding a verdict.
func TestComputeProof_RefusesUnderfundedAndUnmultipliableOpenings(t *testing.T) {
honest := buildOpening(false)
cost := computeProofRequiredGas(honest)
if cost <= GasVerifyComputeProofBase {
t.Fatalf("a real opening must price above the base, got %d", cost)
}
if _, rem, err := verifyComputeProof(honest, cost-1); err == nil {
t.Fatal("an opening priced above the supplied gas must be refused")
} else if rem != 0 {
t.Fatalf("an out-of-gas refusal must leave no gas, got %d", rem)
}
if _, rem, err := verifyComputeProof(honest, cost); err != nil || rem != 0 {
t.Fatalf("exactly the priced cost must succeed and consume all of it: rem %d, err %v", rem, err)
}
// A.Cols(3) != B.Rows(4): every matrix is individually well formed, so the frame
// DECODES, but the product is undefined.
mismatched := poi.EncodeOpening([32]byte{0x11}, []byte("beacon"), poi.Opening{
Index: 0,
A: poi.NewMat(2, 3, make([]int64, 6)),
B: poi.NewMat(4, 5, make([]int64, 20)),
C: poi.NewMat(2, 5, make([]int64, 10)),
})
misCost := computeProofRequiredGas(mismatched)
if misCost <= GasVerifyComputeProofBase {
t.Fatalf("a decodable opening must price above the base, got %d", misCost)
}
ret, rem, err := verifyComputeProof(mismatched, misCost)
if err == nil {
t.Fatal("an opening whose matrices cannot be multiplied must error, not return a verdict")
}
if ret != nil {
t.Fatal("a refusal must return no verdict word")
}
if rem != 0 {
t.Fatalf("a decoded-then-refused opening consumes its priced cost, %d left", rem)
}
if g := computeProofRequiredGas([]byte{1, 2, 3}); g != GasVerifyComputeProofBase {
t.Fatalf("an undecodable frame must price at the base, got %d", g)
}
}
+28
View File
@@ -341,3 +341,31 @@ func TestStorageKeyDeterminism(t *testing.T) {
t.Fatal("rootKey and latestKey collide")
}
}
// TestRun_ShortInputRefused covers calldata too short to carry a selector at every length
// below four bytes, and the boundary above it. A four-byte input carries a selector and no
// body, so it is refused as an unknown selector rather than as a short input — the two are
// different failures and must not be conflated.
func TestRun_ShortInputRefused(t *testing.T) {
state := &mockAccessibleState{db: newMockStateDB()}
for n := 0; n < 4; n++ {
ret, remaining, err := AnchorPrecompile.Run(state, common.Address{}, ContractAddress,
make([]byte, n), 1_000_000, false)
if err != ErrInvalidInput {
t.Fatalf("input of %d bytes: got %v, want ErrInvalidInput", n, err)
}
if ret != nil {
t.Fatalf("input of %d bytes returned %x, want no output", n, ret)
}
if remaining != 1_000_000 {
t.Fatalf("input of %d bytes charged %d gas; a call refused before any work must be free",
n, 1_000_000-remaining)
}
}
if _, _, err := AnchorPrecompile.Run(state, common.Address{}, ContractAddress,
make([]byte, 4), 1_000_000, false); err != ErrInvalidSelector {
t.Fatalf("four bytes is a selector, not a short input: got %v", err)
}
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhe
import (
"reflect"
"strings"
"testing"
"github.com/luxfi/precompile/precompileconfig"
"github.com/stretchr/testify/require"
)
// TestConfig_ConstructorsCarryTheirUpgrade proves the two constructors differ in exactly
// the field that names them, and that neither invents a schedule of its own.
func TestConfig_ConstructorsCarryTheirUpgrade(t *testing.T) {
ts := uint64(1_700_000_000)
enable := NewConfig(&ts)
require.Equal(t, &ts, enable.Timestamp())
require.False(t, enable.IsDisabled(), "NewConfig must schedule an activation")
disable := NewDisableConfig(&ts)
require.Equal(t, &ts, disable.Timestamp())
require.True(t, disable.IsDisabled(), "NewDisableConfig must schedule a deactivation")
// Same instant, opposite intent: the two are not equal.
require.False(t, enable.Equal(disable))
require.False(t, disable.Equal(enable))
// An unscheduled config has no timestamp, so it is never enabled.
require.Nil(t, NewConfig(nil).Timestamp())
}
// TestConfig_KeyMatchesTheModule proves the config answers to the key the module is
// registered under. If they drifted, an upgrade.json entry would parse and then never be
// found — the precompile would silently never activate.
func TestConfig_KeyMatchesTheModule(t *testing.T) {
require.Equal(t, ConfigKey, (&Config{}).Key())
require.Equal(t, Module.ConfigKey, (&Config{}).Key())
require.Equal(t, ConfigKey, (&configurator{}).MakeConfig().Key())
}
// TestConfig_MakeConfigIsFreshEachTime proves MakeConfig hands back a distinct zero value.
// A shared instance would let one upgrade entry's fields leak into the next as the chain
// config is unmarshalled.
func TestConfig_MakeConfigIsFreshEachTime(t *testing.T) {
c := &configurator{}
first, ok := c.MakeConfig().(*Config)
require.True(t, ok, "MakeConfig must produce the type Configure asserts on")
require.Equal(t, Config{}, *first, "MakeConfig must not pre-populate anything")
first.NetworkKeyPath = "/tmp/leaked"
second, ok := c.MakeConfig().(*Config)
require.True(t, ok)
require.Empty(t, second.NetworkKeyPath, "MakeConfig must not share state between calls")
require.NotSame(t, first, second)
}
// TestConfig_VerifyAcceptsEveryWellFormedShape pins that Verify imposes no extra
// requirements: the activation constraint lives in Configure, where the chain's gas limit
// is actually known, and duplicating it here would be a second place to keep in step.
func TestConfig_VerifyAcceptsEveryWellFormedShape(t *testing.T) {
ts := uint64(1)
for _, c := range []*Config{
{},
NewConfig(nil),
NewConfig(&ts),
NewDisableConfig(&ts),
{NetworkKeyPath: "/keys/net", CoprocessorEndpoint: "https://f-chain"},
} {
require.NoError(t, c.Verify(nil))
}
}
// TestConfig_EqualComparesEveryField mutates one field at a time and asserts equality
// breaks in both directions. The reflection guard fails the day a field is added without
// Equal being taught about it — the way an upgrade comparison silently goes blind.
func TestConfig_EqualComparesEveryField(t *testing.T) {
ts := uint64(42)
base := func() *Config {
return &Config{
Upgrade: precompileconfig.Upgrade{BlockTimestamp: &ts},
NetworkKeyPath: "/keys/net",
CoprocessorEndpoint: "https://f-chain",
}
}
require.True(t, base().Equal(base()), "identical configs must compare equal")
mutations := map[string]func(*Config){
"NetworkKeyPath": func(c *Config) { c.NetworkKeyPath = "/keys/other" },
"CoprocessorEndpoint": func(c *Config) { c.CoprocessorEndpoint = "https://elsewhere" },
"Upgrade.Disable": func(c *Config) { c.Disable = true },
"Upgrade.BlockTimestamp": func(c *Config) {
other := ts + 1
c.BlockTimestamp = &other
},
"Upgrade.BlockTimestamp unset": func(c *Config) { c.BlockTimestamp = nil },
}
// Every field of Config and of the Upgrade it embeds must be represented above, or
// Equal has a dimension nothing tests. Counting distinct field names rather than
// mutations lets one field carry several mutations (a timestamp can change or be
// unset) without loosening the guard.
covered := map[string]bool{}
for name := range mutations {
covered[strings.Fields(name)[0]] = true
}
own := reflect.TypeOf(Config{}).NumField() - 1 // minus the embedded Upgrade
require.Equal(t, own+reflect.TypeOf(precompileconfig.Upgrade{}).NumField(), len(covered),
"Config or Upgrade gained a field; extend the mutation table and check Equal covers it")
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
changed := base()
mutate(changed)
require.False(t, base().Equal(changed), "%s must break equality", name)
require.False(t, changed.Equal(base()), "%s must break equality both ways", name)
// Applying the same mutation to both sides restores equality, proving Equal
// reacted to the difference and not merely to the mutation happening.
both := base()
mutate(both)
require.True(t, both.Equal(changed))
})
}
// Timestamps compare by value, not pointer identity.
a, b := base(), base()
sameInstant := ts
b.BlockTimestamp = &sameInstant
require.True(t, a.Equal(b), "equal instants held in different pointers must compare equal")
}
// TestConfig_EqualRejectsForeignConfigs proves a config of another type is never equal,
// however identical its upgrade schedule.
func TestConfig_EqualRejectsForeignConfigs(t *testing.T) {
ts := uint64(7)
c := NewConfig(&ts)
require.False(t, c.Equal(nil), "an untyped nil is not a *Config")
require.False(t, c.Equal(foreignConfig{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &ts}}),
"only the type separates two configs with identical schedules")
}
// foreignConfig is a different precompile's config carrying the same upgrade.
type foreignConfig struct {
precompileconfig.Upgrade
}
func (foreignConfig) Key() string { return "notFHE" }
func (foreignConfig) Verify(precompileconfig.ChainConfig) error { return nil }
func (foreignConfig) Equal(cfg precompileconfig.Config) bool { return false }
func (f foreignConfig) Timestamp() *uint64 { return f.Upgrade.Timestamp() }
func (f foreignConfig) IsDisabled() bool { return f.Upgrade.Disable }
var _ precompileconfig.Config = foreignConfig{}
// TestModule_RegistersTheSingletonAtItsOwnAddress proves init actually registered the
// module, that the registered contract is the singleton the package exports, and that the
// address is not one of the sibling addresses declared alongside it.
func TestModule_RegistersTheSingletonAtItsOwnAddress(t *testing.T) {
require.Equal(t, ContractAddress, Module.Address)
require.Same(t, FHEPrecompile, Module.Contract)
require.NotNil(t, Module.Configurator)
for name, addr := range map[string]interface{ Hex() string }{
"ACL": ACLContractAddress,
"InputVerifier": InputVerifierAddress,
"Gateway": GatewayContractAddress,
} {
require.NotEqualf(t, ContractAddress.Hex(), addr.Hex(),
"the %s address must not collide with the precompile's own", name)
}
}
// TestDoSAuditRow_PredicateAtTheBudgetBoundary pins the audit predicate itself rather than
// the numbers in the table: a row is safe exactly while (gasLimit / gasPerOp) x perOpMs
// stays inside the per-block compute budget, and it flips at the boundary.
func TestDoSAuditRow_PredicateAtTheBudgetBoundary(t *testing.T) {
// One op per block, costing exactly the budget: safe. One millisecond more: not.
row := DoSAuditRow{GasPerOp: 1_000, PerOpMs: BlockComputeBudgetMs}
require.True(t, row.IsSafeAtGasLimit(1_000), "exactly the budget is within it")
require.False(t, row.IsSafeAtGasLimit(2_000), "two ops at the full budget exceed it")
// Cheaper ops fit more per block, so raising the gas limit can flip a row unsafe.
cheap := DoSAuditRow{GasPerOp: 100, PerOpMs: 1}
require.True(t, cheap.IsSafeAtGasLimit(100*BlockComputeBudgetMs))
require.False(t, cheap.IsSafeAtGasLimit(100*BlockComputeBudgetMs+100))
// An unconfigured row (no gas declared) is EXCLUDED from the audit rather than failing
// it — the predicate has no opinion where there is no price. That is fail-open: a row
// added to the table without a gas figure passes silently.
require.True(t, DoSAuditRow{}.IsSafeAtGasLimit(1),
"a row with no declared gas is excluded from the audit")
// Every row actually in the table does declare a price, so none of them is riding
// that exclusion today.
for _, r := range DoSAuditTable {
require.NotZerof(t, r.GasPerOp, "%s has no declared gas and would skip the audit", r.Precompile)
}
}
+81 -42
View File
@@ -4,6 +4,7 @@
package fhe
import (
"bytes"
"errors"
"fmt"
"math/big"
@@ -179,6 +180,10 @@ var (
ErrInsufficientGas = fmt.Errorf("insufficient gas for FHE operation: %w", contract.ErrOutOfGas)
ErrInvalidCiphertext = errors.New("invalid ciphertext handle")
// ErrReadOnly is returned when a state-writing operation is invoked through a
// static call.
ErrReadOnly = errors.New("FHE write attempted in read-only mode")
// ErrACLUnauthorized is returned when a caller requests an operation on a
// ciphertext handle it is not authorized for (it is not the owner). The
// caller is rejected outright — it is never handed any plaintext-shaped
@@ -198,6 +203,37 @@ var (
// FHEContract implements the main FHE precompile
type FHEContract struct{}
// selectorDecrypt and selectorSealOutput are the only two operations that read state
// without writing any: both resolve a handle's owner and then fail closed pending the
// F-Chain threshold ceremony.
const (
selectorDecrypt = "\x12\x3d\x4c\x87" // decrypt(bytes32)
selectorSealOutput = "\x56\x7a\x11\x98" // sealOutput(bytes32,bytes)
)
// writesState reports whether a selector persists anything. Everything that is not one of
// the two pure reads stores a ciphertext, so an unrecognised selector counts as a writer
// and is refused under a static call before it can reach a handler.
func writesState(selector string) bool {
return selector != selectorDecrypt && selector != selectorSealOutput
}
// zeroHandle is what every perform* helper returns when it cannot complete: no evaluator
// installed, a handle that resolves to nothing, an operand type mismatch, a ciphertext that
// will not deserialize. That convention is fine inside the package, where the caller
// inspects it — but handed back through Run it became a 32-byte zero word with a nil error,
// so an EVM caller could not tell a completed operation from a failed one and would go on
// to use a handle that references nothing. Failure is surfaced as an error at the one
// boundary where it stops being a package-internal convention.
var zeroHandle = make([]byte, common.HashLength)
// failedResult reports whether a handler's return value is the failure sentinel. Only
// operations that answer with a handle are considered: decrypt and sealOutput return
// errors, never handles.
func failedResult(selector string, ret []byte) bool {
return writesState(selector) && bytes.Equal(ret, zeroHandle)
}
// Run executes the FHE precompile
func (c *FHEContract) Run(
accessibleState contract.AccessibleState,
@@ -233,8 +269,33 @@ func (c *FHEContract) Run(
selector := input[:4]
data := input[4:]
// Route to appropriate handler based on selector
switch string(selector) {
// STATICCALL promises the callee mutates nothing. Every operation here except
// decrypt and sealOutput persists a ciphertext to the state trie, and readOnly was
// threaded through all forty-two handlers without a single one reading it — so a
// static call could create handles, write ciphertext chunks and record ownership.
// The property belongs to the selector, so it is decided once here rather than
// repeated in every handler.
if readOnly && writesState(string(selector)) {
return nil, suppliedGas, ErrReadOnly
}
ret, remainingGas, err = c.dispatch(accessibleState, caller, string(selector), data, suppliedGas, readOnly)
if err == nil && failedResult(string(selector), ret) {
return nil, remainingGas, ErrOperationFailed
}
return ret, remainingGas, err
}
// dispatch routes a decoded selector to its handler.
func (c *FHEContract) dispatch(
accessibleState contract.AccessibleState,
caller common.Address,
selector string,
data []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
switch selector {
// Arithmetic operations
case "\x23\xb8\x72\xdd": // add(bytes32,bytes32)
return c.handleAdd(accessibleState, caller, data, suppliedGas, readOnly)
@@ -340,42 +401,6 @@ func (c *FHEContract) Run(
}
}
// Gas returns the gas required for the FHE operation
func (c *FHEContract) Gas(input []byte) uint64 {
if len(input) < 4 {
return 0
}
selector := string(input[:4])
switch selector {
case "\x23\xb8\x72\xdd": // add
return GasAdd
case "\x51\xca\xb0\x91": // sub
return GasSub
case "\xc8\xa4\xac\x9c": // mul
return GasMul
case "\xa9\x05\x9c\xbb", "\x4b\x64\xe4\x92": // lt, gt
return GasLt
case "\x1c\xf4\x86\x63": // eq
return GasEq
case "\x2e\x17\xde\x78": // select
return GasSelect
case "\xa5\x17\x5c\x89", "\xd4\x3f\x02\x80": // asEuint64, asEaddress
return GasEncrypt
case "\x6e\x32\x91\x28", "\x7a\x8f\x63\xb8": // max, min
return GasMax
case "\xcd\x30\x32\x00", "\x5a\x6b\x26\xba": // and, or
return GasAnd
case "\x6b\x3a\x00\x11": // not
return GasNot
case "\xe4\x7e\xf3\xfc": // neg
return GasNeg
case "\x71\x5a\xd3\x11": // rand
return GasRand
default:
return 100000 // Default high gas for unknown operations
}
}
// Handler implementations
func (c *FHEContract) handleAdd(state contract.AccessibleState, caller common.Address, data []byte, gas uint64, readOnly bool) ([]byte, uint64, error) {
@@ -1044,20 +1069,30 @@ func (c *FHEContract) handleDecrypt(state contract.AccessibleState, caller commo
return nil, gasLeft, ErrThresholdDecryptionRequired
}
// GasVerifyPerByte prices the caller-supplied ciphertext that verify deserializes and then
// persists. verify is the one operation whose work is set by the length of a blob the
// caller chooses: it walks the whole encoding and then writes it to the state trie 32 bytes
// at a time, one keccak and one SetState per chunk. Charged as a flat fee it was the
// cheapest way to grow the state permanently, and the only op in the schedule whose cost
// was independent of the work it did.
const GasVerifyPerByte uint64 = 32
func (c *FHEContract) handleVerify(state contract.AccessibleState, caller common.Address, data []byte, gas uint64, readOnly bool) ([]byte, uint64, error) {
if len(data) < 33 {
return nil, gas, ErrInvalidInput
}
if gas < GasEncrypt {
return nil, gas, ErrInsufficientGas
}
ctType := data[0]
inputHandle := data[1:]
cost := GasEncrypt + uint64(len(inputHandle))*GasVerifyPerByte
if gas < cost {
return nil, gas, ErrInsufficientGas
}
result := performFHEVerify(state.GetStateDB(), inputHandle, ctType, caller)
return result.Bytes(), gas - GasEncrypt, nil
return result.Bytes(), gas - cost, nil
}
// handleSealOutput re-encrypts (seals) a ciphertext to a caller-provided public
@@ -1192,6 +1227,10 @@ func performFHEOperation(stateDB contract.StateDB, op string, handle1, handle2 c
result = tfheSub(lhs, rhs, lhsType)
case "mul":
result = tfheMul(lhs, rhs, lhsType)
case "div":
result = tfheDiv(lhs, rhs, lhsType)
case "rem":
result = tfheRem(lhs, rhs, lhsType)
case "lt":
result = tfheLt(lhs, rhs, lhsType)
case "gt":
+436
View File
@@ -0,0 +1,436 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhe
import (
"encoding/binary"
"runtime"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
"github.com/stretchr/testify/require"
)
// allSelectors is every selector the precompile routes, paired with a body long enough to
// reach the handler body rather than bouncing off its length check.
var allSelectors = []struct {
name string
sel string
body []byte
}{
{"add", "\x23\xb8\x72\xdd", make([]byte, 64)},
{"sub", "\x51\xca\xb0\x91", make([]byte, 64)},
{"mul", "\xc8\xa4\xac\x9c", make([]byte, 64)},
{"div", "\x0f\x5e\x1b\x2a", make([]byte, 64)},
{"rem", "\x1e\x19\x1a\x96", make([]byte, 64)},
{"neg", "\xe4\x7e\xf3\xfc", make([]byte, 32)},
{"scalarAdd", "\xf5\xa7\x96\xfb", make([]byte, 64)},
{"scalarSub", "\xb6\x3a\x9e\x11", make([]byte, 64)},
{"scalarMul", "\x3c\x96\x47\x95", make([]byte, 64)},
{"scalarDiv", "\x7b\x8f\x4a\x2d", make([]byte, 64)},
{"scalarRem", "\x52\x91\xa3\x21", make([]byte, 64)},
{"lt", "\xa9\x05\x9c\xbb", make([]byte, 64)},
{"le", "\x26\xa3\x31\x9e", make([]byte, 64)},
{"gt", "\x4b\x64\xe4\x92", make([]byte, 64)},
{"ge", "\x53\x1c\x19\xea", make([]byte, 64)},
{"eq", "\x1c\xf4\x86\x63", make([]byte, 64)},
{"ne", "\x14\x6e\x3a\x7e", make([]byte, 64)},
{"min", "\x7a\x8f\x63\xb8", make([]byte, 64)},
{"max", "\x6e\x32\x91\x28", make([]byte, 64)},
{"and", "\xcd\x30\x32\x00", make([]byte, 64)},
{"or", "\x5a\x6b\x26\xba", make([]byte, 64)},
{"xor", "\xf6\x74\x70\x22", make([]byte, 64)},
{"not", "\x6b\x3a\x00\x11", make([]byte, 32)},
{"shl", "\x3e\x8c\x6c\x10", make([]byte, 33)},
{"shr", "\x5f\x46\xe5\x15", make([]byte, 33)},
{"rotl", "\x89\xa1\x9e\x6b", make([]byte, 33)},
{"rotr", "\xd7\x25\x1c\xb9", make([]byte, 33)},
{"select", "\x2e\x17\xde\x78", make([]byte, 96)},
{"cast", "\xae\xd2\x44\x6b", make([]byte, 33)},
{"asEuint64", "\xa5\x17\x5c\x89", make([]byte, 32)},
{"asEaddress", "\xd4\x3f\x02\x80", make([]byte, 32)},
{"asEbool", "\x8c\x3f\x5a\x42", make([]byte, 32)},
{"asEuint4", "\x2d\xfa\x48\x63", make([]byte, 32)},
{"asEuint8", "\x64\xc1\x51\x81", make([]byte, 32)},
{"asEuint16", "\xf8\x91\x08\x50", make([]byte, 32)},
{"asEuint32", "\x6c\xa9\xea\xe9", make([]byte, 32)},
{"asEuint128", "\x7d\x6d\x81\x95", make([]byte, 32)},
{"asEuint256", "\x9e\x5b\x2e\xf3", make([]byte, 32)},
{"rand", "\x71\x5a\xd3\x11", make([]byte, 32)},
{"verify", "\x45\xa9\x32\x18", make([]byte, 64)},
}
func call(sel string, body []byte) []byte {
return append([]byte(sel), body...)
}
// countingDB is a StateDB that records whether anything was written. The embedded
// interface is nil: the FHE handlers touch only GetState and SetState, so any other method
// being reached would be a finding, and a nil-method panic reports it loudly.
type countingDB struct {
contract.StateDB
slots map[common.Hash]common.Hash
writes int
}
func newCountingDB() *countingDB {
return &countingDB{slots: make(map[common.Hash]common.Hash)}
}
func (d *countingDB) GetState(_ common.Address, k common.Hash) common.Hash { return d.slots[k] }
func (d *countingDB) SetState(_ common.Address, k, v common.Hash) common.Hash {
prev := d.slots[k]
d.slots[k] = v
d.writes++
return prev
}
// stateOf is a minimal AccessibleState; the handlers only ask for the StateDB.
type stateOf struct {
contract.AccessibleState
db contract.StateDB
}
func (s *stateOf) GetStateDB() contract.StateDB { return s.db }
// TestRun_ReadOnlyRefusesEveryStateWriter is the STATICCALL regression.
//
// readOnly was threaded through Run and all forty-two handlers and read by none of them:
// `grep -c "if readOnly" fhe/*.go` over the non-test sources returned 0. Every operation
// except decrypt and sealOutput persists a ciphertext — a handle, a metadata word, one
// state slot per 32 bytes of ciphertext, and an ownership record — so a static call, which
// promises the callee mutates nothing, could grow the state trie.
func TestRun_ReadOnlyRefusesEveryStateWriter(t *testing.T) {
c := &FHEContract{}
for _, op := range allSelectors {
t.Run(op.name, func(t *testing.T) {
db := newCountingDB()
state := &stateOf{db: db}
_, _, err := c.Run(state, common.Address{1}, ContractAddress,
call(op.sel, op.body), 1<<62, true)
require.ErrorIs(t, err, ErrReadOnly,
"%s writes state and must be refused under a static call", op.name)
require.Zero(t, db.writes, "%s wrote state under a static call", op.name)
})
}
}
// TestRun_ReadOnlyPermitsPureReads proves the refusal is targeted, not a blanket ban: the
// two operations that only read are still reachable under a static call and fail for their
// own reason (the threshold ceremony), never with ErrReadOnly.
func TestRun_ReadOnlyPermitsPureReads(t *testing.T) {
c := &FHEContract{}
for name, sel := range map[string]string{
"decrypt": selectorDecrypt,
"sealOutput": selectorSealOutput,
} {
t.Run(name, func(t *testing.T) {
db := newCountingDB()
_, _, err := c.Run(&stateOf{db: db}, common.Address{1}, ContractAddress,
call(sel, make([]byte, 96)), 1<<62, true)
require.NotErrorIs(t, err, ErrReadOnly,
"%s only reads and must not be refused as a writer", name)
require.Zero(t, db.writes)
})
}
}
// TestRun_UnknownSelectorIsTreatedAsAWriter proves the refusal fails closed: a selector
// nobody recognises is assumed to write, so a future handler added without revisiting this
// rule cannot silently become STATICCALL-reachable.
func TestRun_UnknownSelectorIsTreatedAsAWriter(t *testing.T) {
c := &FHEContract{}
_, _, err := c.Run(&stateOf{db: newCountingDB()}, common.Address{1},
ContractAddress, call("\xde\xad\xbe\xef", make([]byte, 64)), 1<<62, true)
require.ErrorIs(t, err, ErrReadOnly)
// And outside a static call it is still refused, as not implemented.
_, _, err = c.Run(&stateOf{db: newCountingDB()}, common.Address{1},
ContractAddress, call("\xde\xad\xbe\xef", make([]byte, 64)), 1<<62, false)
require.ErrorIs(t, err, ErrNotImplemented)
}
// =========================================================================
// Ciphertext framing
// =========================================================================
// frame builds a BitCiphertext encoding with the given declared numBits and per-bit
// declared lengths, writing only the bytes named. It is deliberately able to produce
// inconsistent encodings — that is the point.
func frame(numBits uint32, bits []uint32, payload []byte) []byte {
out := make([]byte, 5)
binary.LittleEndian.PutUint32(out[:4], numBits)
out[4] = 1 // fheType
for _, n := range bits {
hdr := make([]byte, 4)
binary.LittleEndian.PutUint32(hdr, n)
out = append(out, hdr...)
}
return append(out, payload...)
}
// TestWellFramed_RefusesAllocationBombs is the remote memory-exhaustion regression.
//
// BitCiphertext.UnmarshalBinary reads each declared count off the wire and allocates for it
// BEFORE checking that the input contains that many bytes:
//
// bc.bits = make([]*Ciphertext, bc.numBits) // numBits is a wire uint32
// bitData := make([]byte, bitLen) // bitLen is a wire uint32
//
// So five bytes of calldata request a 34 GB slice and nine request 4 GB — behind a flat
// 50,000 gas fee on verify, which is roughly 240 attempts per block. The framing is now
// validated against the bytes actually present before any of it reaches the library.
func TestWellFramed_RefusesAllocationBombs(t *testing.T) {
// numBits alone: 4.29e9 pointers = ~34 GB.
require.False(t, wellFramed(frame(0xFFFFFFFF, nil, nil)))
require.False(t, wellFramed(frame(1<<31, nil, nil)))
// A single bit declaring a 4 GB payload it does not carry.
require.False(t, wellFramed(frame(1, []uint32{0xFFFFFFFF}, nil)))
require.False(t, wellFramed(frame(1, []uint32{1 << 31}, []byte{1, 2, 3})))
// A later bit overruns even though earlier ones are honest.
require.False(t, wellFramed(frame(3, []uint32{0, 0, 0xFFFFFFFF}, nil)))
// Anything past the widest supported type is refused before it is walked at all.
require.False(t, wellFramed(frame(MaxCiphertextBits+1, nil, nil)))
require.True(t, wellFramed(frame(0, nil, nil)), "zero bits is a complete encoding")
// Truncated headers.
for n := 0; n < 5; n++ {
require.Falsef(t, wellFramed(make([]byte, n)), "a %d-byte frame is incomplete", n)
}
// The whole path refuses them too, rather than allocating.
for _, bad := range [][]byte{
frame(0xFFFFFFFF, nil, nil),
frame(1, []uint32{0xFFFFFFFF}, nil),
frame(MaxCiphertextBits+1, nil, nil),
} {
require.NotPanics(t, func() {
require.Nil(t, deserializeBitCiphertext(bad))
require.False(t, tfheVerify(bad, TypeEuint8))
})
}
}
// TestWellFramed_AcceptsHonestFrames proves the validator is not refusing everything: a
// frame whose declared lengths match the bytes present is accepted, at the boundary in both
// directions.
func TestWellFramed_AcceptsHonestFrames(t *testing.T) {
require.True(t, wellFramed(frame(1, []uint32{3}, []byte{1, 2, 3})))
require.True(t, wellFramed(frame(2, []uint32{1, 1}, nil)[:5+4+1+4+1]),
"two one-byte bits, exactly the bytes required")
// One byte short of the declared payload is refused; exactly enough is accepted.
require.False(t, wellFramed(frame(1, []uint32{4}, []byte{1, 2, 3})))
require.True(t, wellFramed(frame(1, []uint32{4}, []byte{1, 2, 3, 4})))
// Trailing bytes beyond the declared frame are tolerated (the reader stops early).
require.True(t, wellFramed(frame(1, []uint32{1}, []byte{9, 9, 9})))
// The widest supported ciphertext is accepted.
bits := make([]uint32, MaxCiphertextBits)
require.True(t, wellFramed(frame(MaxCiphertextBits, bits, nil)))
}
// =========================================================================
// verify pricing
// =========================================================================
// TestVerify_GasScalesWithInputSize is the flat-fee regression for the one operation whose
// work is set by a caller-chosen length. verify deserializes the blob and then persists it
// to the state trie, one keccak and one SetState per 32 bytes — all for a constant 50,000
// gas. A longer blob must now cost more.
func TestVerify_GasScalesWithInputSize(t *testing.T) {
c := &FHEContract{}
const supplied = uint64(1) << 40
charged := func(n int) uint64 {
body := append([]byte{TypeEuint8}, make([]byte, n)...)
db := newCountingDB()
_, remaining, _ := c.Run(&stateOf{db: db}, common.Address{1},
ContractAddress, call("\x45\xa9\x32\x18", body), supplied, false)
require.LessOrEqual(t, remaining, supplied)
return supplied - remaining
}
small := charged(32)
large := charged(64 * 1024)
require.Greater(t, large, small, "a larger verify payload must cost more gas")
require.GreaterOrEqual(t, large-small, uint64(64*1024-32)*GasVerifyPerByte)
// Monotonic across the range, not just at the ends.
prev := uint64(0)
for _, n := range []int{32, 64, 256, 1024, 4096, 16384} {
got := charged(n)
require.Greaterf(t, got, prev, "verify gas must increase with payload size at n=%d", n)
prev = got
}
}
// TestVerify_InsufficientGasRefusedBeforeWork proves the size-aware price is enforced up
// front: a caller that cannot cover it is refused and nothing is written.
func TestVerify_InsufficientGasRefusedBeforeWork(t *testing.T) {
c := &FHEContract{}
body := append([]byte{TypeEuint8}, make([]byte, 4096)...)
need := GasEncrypt + uint64(4096)*GasVerifyPerByte
db := newCountingDB()
_, _, err := c.Run(&stateOf{db: db}, common.Address{1}, ContractAddress,
call("\x45\xa9\x32\x18", body), need-1, false)
require.ErrorIs(t, err, ErrInsufficientGas)
require.Zero(t, db.writes, "a refused verify must not write state")
}
// =========================================================================
// silent failure
// =========================================================================
// TestRun_FailureIsAnErrorNotAZeroHandle is the silent-failure regression. Every perform*
// helper answers a failure with the zero handle; handlers returned that verbatim with a nil
// error, so an EVM caller received a 32-byte zero word that looked like a successful result
// and referred to no ciphertext at all. div and rem were the sharpest case: neither had a
// case in performFHEOperation's switch, so both fell to its default and charged 655,200,000
// gas for a guaranteed no-op that reported success.
func TestRun_FailureIsAnErrorNotAZeroHandle(t *testing.T) {
c := &FHEContract{}
for _, op := range allSelectors {
t.Run(op.name, func(t *testing.T) {
// Handles that resolve to nothing: every operation must either succeed with a
// non-zero handle or report an error. It may never answer zero with nil.
ret, _, err := c.Run(&stateOf{db: newCountingDB()}, common.Address{1},
ContractAddress, call(op.sel, op.body), 1<<62, false)
if err != nil {
return
}
require.NotEqual(t, make([]byte, 32), ret,
"%s reported success with the failure sentinel", op.name)
})
}
}
// allocBytes reports how many bytes f caused to be allocated.
func allocBytes(f func()) uint64 {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
f()
runtime.ReadMemStats(&after)
return after.TotalAlloc - before.TotalAlloc
}
// TestWellFramed_BoundsAllocationNotJustTheResult closes a hole the mutation check found.
//
// Asserting that a bomb frame deserializes to nil is not enough: UnmarshalBinary ALSO
// returns nil for these inputs — after allocating for the declared counts and only then
// discovering the bytes are not there. The damage is the allocation, so the allocation is
// what has to be measured. Removing the call to wellFramed left every result-shaped
// assertion green.
//
// The declared counts here are large enough to dwarf the input by six orders of magnitude
// but small enough that an unguarded run allocates rather than killing the test process.
func TestWellFramed_BoundsAllocationNotJustTheResult(t *testing.T) {
const budget = 1 << 20 // a 13-byte input has no business allocating a megabyte
// 16.7M declared bits => 134 MB of pointers from 5 bytes of input.
bits := frame(1<<24, nil, nil)
require.Less(t, allocBytes(func() { _ = deserializeBitCiphertext(bits) }), uint64(budget),
"a %d-byte frame declaring 2^24 bits must not allocate for them", len(bits))
// One bit declaring a 64 MB payload from 9 bytes of input.
payload := frame(1, []uint32{1 << 26}, nil)
require.Less(t, allocBytes(func() { _ = deserializeBitCiphertext(payload) }), uint64(budget),
"a %d-byte frame declaring a 64MB bit must not allocate for it", len(payload))
// And through the operation that actually accepts caller bytes.
require.Less(t, allocBytes(func() { _ = tfheVerify(payload, TypeEuint8) }), uint64(budget))
}
// TestWellFramed_RefusesOverWideEvenWhenByteConsistent closes the second hole. Every other
// over-wide frame in these tests is also byte-inconsistent, so the overrun check refused it
// and deleting the width cap changed nothing. This frame is internally consistent — every
// declared header is present — so only the cap can refuse it.
func TestWellFramed_RefusesOverWideEvenWhenByteConsistent(t *testing.T) {
atCap := frame(MaxCiphertextBits, make([]uint32, MaxCiphertextBits), nil)
require.True(t, wellFramed(atCap), "the widest supported ciphertext must be accepted")
overCap := frame(MaxCiphertextBits+1, make([]uint32, MaxCiphertextBits+1), nil)
require.True(t, len(overCap) == 5+4*(MaxCiphertextBits+1),
"the over-wide frame must be byte-consistent, or the cap is not what refuses it")
require.False(t, wellFramed(overCap),
"a ciphertext wider than any supported type must be refused on width alone")
// Far over the cap, still byte-consistent.
require.False(t, wellFramed(frame(1024, make([]uint32, 1024), nil)))
}
// TestWellFramed_RefusesTruncatedBitHeaders closes a third hole the mutation check found.
// Once the width cap short-circuits the wildly over-wide frames, nothing else exercised the
// per-bit header bound: a frame declaring a plausible number of bits but carrying fewer
// headers than that walks straight off the end of the buffer. Every truncation point is
// swept, and the requirement is refusal without a panic — reading past the slice would
// panic inside a precompile, which no caller recovers.
func TestWellFramed_RefusesTruncatedBitHeaders(t *testing.T) {
for _, declared := range []uint32{1, 2, 3, 17, MaxCiphertextBits} {
full := frame(declared, make([]uint32, declared), nil)
require.True(t, wellFramed(full), "the complete frame must be accepted")
// Every proper prefix is refused, and none of them panics. The prefix is taken
// with an exact capacity (full[:n:n]), because a sub-slice that keeps the parent's
// spare capacity lets an out-of-bounds read succeed against the bytes that were
// supposedly cut off — which hides exactly the defect this is testing for. Real
// calldata has no slack beyond its length.
for n := 0; n < len(full); n++ {
trunc := full[:n:n]
require.NotPanicsf(t, func() {
require.Falsef(t, wellFramed(trunc),
"a frame declaring %d bits was accepted with only %d bytes", declared, n)
}, "wellFramed panicked on a %d-byte prefix of a %d-bit frame", n, declared)
// The same truncation must not reach the library either.
require.NotPanics(t, func() { require.Nil(t, deserializeBitCiphertext(trunc)) })
}
}
}
// TestBoolBit_AcceptsOnlySingleBitValues closes a hole the mutation check found: nothing
// asserted that a multi-bit ciphertext is refused as a boolean.
//
// boolBit reaches into the wire format to pull the one encrypted bit out of a TypeEbool
// value, because the pinned luxfi/fhe exposes no accessor for it. Without the width check
// it would happily return the FIRST bit of an eight-bit integer, so select(someEuint8, a, b)
// would silently branch on the low bit of an integer instead of refusing a value that is
// not a boolean at all — type confusion between an integer handle and a condition.
func TestBoolBit_AcceptsOnlySingleBitValues(t *testing.T) {
payload := []byte{0xAA, 0xBB, 0xCC}
single := frame(1, []uint32{uint32(len(payload))}, payload)
require.Equal(t, payload, boolBit(single), "a one-bit value yields its single bit verbatim")
// Every other width is refused, at the boundaries either side of one.
for _, width := range []uint32{0, 2, 4, 8, 16, 32, MaxCiphertextBits} {
bits := make([]uint32, width)
require.Nilf(t, boolBit(frame(width, bits, nil)),
"a %d-bit value is not a boolean and must be refused as a condition", width)
}
// And malformed framing is refused rather than indexed into.
for _, bad := range [][]byte{
nil,
make([]byte, 4),
make([]byte, 8),
frame(1, []uint32{99}, []byte{1, 2}), // declares more than it carries
frame(0xFFFFFFFF, nil, nil),
} {
require.NotPanics(t, func() { require.Nil(t, boolBit(bad)) })
}
// A zero-length bit is well-framed and yields an empty slice, not a panic.
require.NotPanics(t, func() { require.Empty(t, boolBit(frame(1, []uint32{0}, nil))) })
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
//go:build cgo
// See the file LICENSE for licensing terms.
package fhe
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// The two ways every FHE operation can fail without doing any cryptography: the network
// keys are not installed, or an operand handle resolves to bytes that are not a ciphertext.
// Both must fail CLOSED — an error, never a result — and neither costs real FHE work, so
// they are cheap to exercise across the whole operation set.
// withoutKeys clears the installed network key material for the duration of f and restores
// it afterwards. Clearing is the documented fail-closed path of SetNetworkKeys.
func withoutKeys(t *testing.T, f func()) {
t.Helper()
require.NoError(t, initTFHE())
keysMu.RLock()
pk, bsk := publicKey, bootstrapKey
keysMu.RUnlock()
require.NotNil(t, pk, "the test harness must have installed keys before they can be cleared")
require.NoError(t, SetNetworkKeys(nil, nil))
require.False(t, HasNetworkKeys(), "clearing must actually disarm the precompile")
defer func() {
require.NoError(t, SetNetworkKeys(pk, bsk))
require.True(t, HasNetworkKeys(), "keys must be restored for the rest of the suite")
}()
f()
}
// TestOps_FailClosedWithoutKeys proves that with no key material installed, every operation
// refuses rather than fabricating a key or returning an empty-but-successful result. This is
// the state a validator is in before the F-Chain DKG output is loaded, and the state it
// returns to if keys are withdrawn — an op that "succeeded" there would write a handle
// referring to nothing.
//
// Operands are stored while keys are present, so the refusal comes from the missing
// evaluator and not from an unresolvable handle.
func TestOps_FailClosedWithoutKeys(t *testing.T) {
require.NoError(t, initTFHE())
db := newTestStateDB()
a := encryptValue(db, 5, TypeEuint8, ownerAddr)
b := encryptValue(db, 3, TypeEuint8, ownerAddr)
cond := encryptValue(db, 1, TypeEbool, ownerAddr)
require.NotEqual(t, common.Hash{}, a)
c := &FHEContract{}
state := &aclTestState{db: db}
withoutKeys(t, func() {
for _, op := range allSelectors {
body := make([]byte, len(op.body))
copy(body, op.body)
// Point the operand words at handles that DO resolve, so the only thing
// missing is the key material.
if len(body) >= 32 {
copy(body[0:32], a.Bytes())
}
if len(body) >= 64 {
copy(body[32:64], b.Bytes())
}
if len(body) >= 96 {
copy(body[0:32], cond.Bytes())
copy(body[32:64], a.Bytes())
copy(body[64:96], b.Bytes())
}
_, _, err := c.Run(state, ownerAddr, ContractAddress,
append([]byte(op.sel), body...), 1<<62, false)
require.Errorf(t, err, "%s must fail closed with no keys installed", op.name)
}
// The primitives report the same thing directly.
require.Nil(t, tfheAdd(nil, nil, TypeEuint8))
require.Nil(t, tfheTrivialEncrypt(nil, TypeEuint8))
require.Nil(t, tfheRandom(TypeEuint8, 42))
require.Nil(t, tfheGetNetworkPublicKey())
require.False(t, HasNetworkKeys())
})
// And once keys are back, the same call succeeds — proving the refusal was the keys.
_, _, err := c.Run(state, ownerAddr, ContractAddress,
append([]byte("\x23\xb8\x72\xdd"), append(a.Bytes(), b.Bytes()...)...), 1<<62, false)
require.NoError(t, err)
}
// plantCorrupt stores a handle whose metadata claims a ciphertext of the given length while
// the data slots hold bytes that are not a valid encoding.
func plantCorrupt(db *testStateDB, ctType uint8, n int) common.Hash {
var handle common.Hash
handle[0], handle[31] = ctType, byte(n)
var meta common.Hash
meta[0] = ctType
meta[1] = byte(n >> 24)
meta[2] = byte(n >> 16)
meta[3] = byte(n >> 8)
meta[4] = byte(n)
db.SetState(ContractAddress, handle, meta)
for i := 0; i < n; i += 32 {
slot := keccak256Hash(handle.Bytes(), common.BigToHash(big.NewInt(int64(i/32))).Bytes())
var chunk common.Hash
for j := range chunk {
chunk[j] = 0xEE // not a ciphertext under any encoding
}
db.SetState(ContractAddress, slot, chunk)
}
return handle
}
// takesPlaintext names the operations whose first argument is a public value from calldata
// rather than a handle to a stored ciphertext. They cannot meet a corrupt operand.
var takesPlaintext = map[string]bool{
"asEbool": true, "asEuint4": true, "asEuint8": true, "asEuint16": true,
"asEuint32": true, "asEuint64": true, "asEuint128": true, "asEuint256": true,
"asEaddress": true, "rand": true, "verify": true,
}
// TestOps_FailClosedOnCorruptCiphertext proves an operand that resolves to bytes which are
// not a ciphertext is refused, rather than being fed to the evaluator or silently treated
// as an empty value.
func TestOps_FailClosedOnCorruptCiphertext(t *testing.T) {
require.NoError(t, initTFHE())
db := newTestStateDB()
good := encryptValue(db, 5, TypeEuint8, ownerAddr)
bad := plantCorrupt(db, TypeEuint8, 64)
badBool := plantCorrupt(db, TypeEbool, 64)
c := &FHEContract{}
state := &aclTestState{db: db}
for _, op := range allSelectors {
if takesPlaintext[op.name] {
// These read a public value out of calldata rather than resolving a handle,
// so a corrupt stored ciphertext is not something they can encounter.
continue
}
body := make([]byte, len(op.body))
copy(body, op.body)
if len(body) >= 32 {
copy(body[0:32], bad.Bytes())
}
if len(body) >= 64 {
copy(body[32:64], bad.Bytes())
}
if len(body) >= 96 {
copy(body[0:32], badBool.Bytes())
copy(body[32:64], bad.Bytes())
copy(body[64:96], good.Bytes())
}
_, _, err := c.Run(state, ownerAddr, ContractAddress,
append([]byte(op.sel), body...), 1<<62, false)
require.Errorf(t, err, "%s must refuse an operand that is not a ciphertext", op.name)
}
// One good operand and one corrupt one is still a refusal, at either position.
for name, body := range map[string][]byte{
"left corrupt": append(bad.Bytes(), good.Bytes()...),
"right corrupt": append(good.Bytes(), bad.Bytes()...),
} {
_, _, err := c.Run(state, ownerAddr, ContractAddress,
append([]byte("\x23\xb8\x72\xdd"), body...), 1<<62, false)
require.Errorf(t, err, "add with %s must be refused", name)
}
// The control: two good operands succeed, so the refusals above are the corruption.
_, _, err := c.Run(state, ownerAddr, ContractAddress,
append([]byte("\x23\xb8\x72\xdd"), append(good.Bytes(), good.Bytes()...)...), 1<<62, false)
require.NoError(t, err)
}
// TestGetCiphertext_UnknownAndEmptyHandles covers the two ways a handle fails to resolve:
// nothing was ever stored, and metadata declaring a zero-length ciphertext.
func TestGetCiphertext_UnknownAndEmptyHandles(t *testing.T) {
db := newTestStateDB()
var missing common.Hash
missing[0] = 0x01
_, _, ok := getCiphertext(db, missing)
require.False(t, ok, "a handle nothing was stored at must not resolve")
// Metadata present but declaring zero length is not a ciphertext either.
var zeroLen common.Hash
zeroLen[0] = 0x02
var meta common.Hash
meta[0] = TypeEuint8 // type set, length left at zero
db.SetState(ContractAddress, zeroLen, meta)
_, _, ok = getCiphertext(db, zeroLen)
require.False(t, ok, "a zero-length ciphertext must not resolve")
}
+62 -4
View File
@@ -153,9 +153,62 @@ func serializeBitCiphertext(ct *fhe.BitCiphertext) []byte {
return data
}
// deserializeBitCiphertext converts bytes to BitCiphertext
// MaxCiphertextBits is the widest supported ciphertext. euint256 is the largest FHE type
// this precompile offers, so nothing legitimate carries more bits than this.
const MaxCiphertextBits = 256
// wellFramed reports whether data is a complete, self-consistent BitCiphertext encoding:
//
// [4]numBits | [1]fheType | numBits x ( [4]bitLen | bitLen bytes )
//
// It must run before the bytes reach UnmarshalBinary, which reads each declared length and
// allocates for it BEFORE checking that the input actually contains that many bytes:
// `make([]*Ciphertext, numBits)` then `make([]byte, bitLen)`. Both counts are little-endian
// uint32 read straight off the wire, so five bytes of calldata ask for a 34 GB slice and
// nine ask for 4 GB — a remote memory-exhaustion primitive behind a flat 50,000 gas fee.
// Every declared length is checked against the bytes actually present, in int arithmetic,
// before any of them is used to allocate.
func wellFramed(data []byte) bool {
if len(data) < 5 {
return false
}
numBits := int(binary.LittleEndian.Uint32(data[:4]))
if numBits < 0 || numBits > MaxCiphertextBits {
return false
}
off := 5 // numBits + fheType
for range numBits {
if off+4 > len(data) {
return false
}
bitLen := int(binary.LittleEndian.Uint32(data[off : off+4]))
off += 4
if bitLen < 0 || off+bitLen > len(data) {
return false
}
off += bitLen
}
return true
}
// boolBit extracts the single encrypted bit out of a serialized boolean BitCiphertext.
//
// Every TypeEbool value this precompile stores is a BitCiphertext of width one, but the
// evaluator's Select takes a bare *Ciphertext, and the pinned luxfi/fhe exposes no accessor
// for the bit inside (the field is unexported). The wire format does expose it: after the
// 4-byte width and the 1-byte type, a one-bit value carries exactly one length-prefixed
// blob, and that blob IS the inner Ciphertext's own encoding. Returns nil for anything that
// is not a well-framed single-bit value.
func boolBit(data []byte) []byte {
if !wellFramed(data) || binary.LittleEndian.Uint32(data[:4]) != 1 {
return nil
}
n := int(binary.LittleEndian.Uint32(data[5:9]))
return data[9 : 9+n]
}
func deserializeBitCiphertext(data []byte) *fhe.BitCiphertext {
if len(data) == 0 {
if !wellFramed(data) {
return nil
}
ct := new(fhe.BitCiphertext)
@@ -549,8 +602,13 @@ func tfheSelect(control, ifTrue, ifFalse []byte, fheType uint8) []byte {
return nil
}
// Control is a single encrypted bit
ctControl := deserializeCiphertext(control)
// The control is a boolean HANDLE, and every TypeEbool value this precompile stores is
// a BitCiphertext: comparisons wrap their single-bit result with WrapBoolCiphertext
// before serializing, and asEbool encrypts through the same path. Decoding the control
// as a bare Ciphertext therefore matched nothing the precompile is able to produce, so
// select failed for every input it was ever given — silently, because a failed
// operation used to be returned as the zero handle with a nil error.
ctControl := deserializeCiphertext(boolBit(control))
ctTrue := deserializeBitCiphertext(ifTrue)
ctFalse := deserializeBitCiphertext(ifFalse)
if ctControl == nil || ctTrue == nil || ctFalse == nil {
+464
View File
@@ -0,0 +1,464 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
//go:build cgo
// See the file LICENSE for licensing terms.
package fhe
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// These tests drive every FHE operation through the registered precompile with real network
// keys installed, and check the HOMOMORPHIC PROPERTY: encrypt the operands, run the op,
// decrypt the result, and compare against the same computation in the clear. That is the
// assertion an incorrect implementation cannot satisfy — pinning a returned handle would
// pass against any evaluator that is merely self-consistent.
//
// The secret key lives only in the test process (see keys_test.go); the precompile holds
// public material and cannot decrypt.
const opGas = uint64(1) << 62 // above every entry in the schedule
// opEnv is a keyed precompile plus the state its handles live in.
type opEnv struct {
t *testing.T
c *FHEContract
db *testStateDB
state *aclTestState
}
func newOpEnv(t *testing.T) *opEnv {
t.Helper()
require.NoError(t, initTFHE())
db := newTestStateDB()
return &opEnv{t: t, c: &FHEContract{}, db: db, state: &aclTestState{db: db}}
}
// enc encrypts a plaintext under the network key and returns its handle.
func (e *opEnv) enc(v uint64, ctType uint8) common.Hash {
e.t.Helper()
h := encryptValue(e.db, v, ctType, ownerAddr)
require.NotEqual(e.t, common.Hash{}, h, "encryptValue must produce a handle")
return h
}
// run invokes the precompile and requires success, returning the result handle.
func (e *opEnv) run(sel string, body []byte) common.Hash {
e.t.Helper()
ret, remaining, err := e.c.Run(e.state, ownerAddr, ContractAddress,
append([]byte(sel), body...), opGas, false)
require.NoError(e.t, err)
require.Less(e.t, remaining, opGas, "an operation that ran must charge something")
require.Len(e.t, ret, 32)
h := common.BytesToHash(ret)
require.NotEqual(e.t, common.Hash{}, h, "a successful operation must return a real handle")
return h
}
// plain decrypts a result handle back to its plaintext.
func (e *opEnv) plain(h common.Hash, ctType uint8) uint64 {
e.t.Helper()
ct, _, ok := getCiphertext(e.db, h)
require.True(e.t, ok, "the result handle must resolve to a stored ciphertext")
return tfheDecrypt(ct, ctType).Uint64()
}
func pair(a, b common.Hash) []byte { return append(a.Bytes(), b.Bytes()...) }
func minU64(a, b uint64) uint64 {
if a < b {
return a
}
return b
}
func maxU64(a, b uint64) uint64 {
if a > b {
return a
}
return b
}
// TestOps_BinaryArithmeticIsHomomorphic checks that each arithmetic operation on
// ciphertexts agrees with the same operation on the plaintexts.
func TestOps_BinaryArithmeticIsHomomorphic(t *testing.T) {
cases := []struct {
name string
sel string
want func(a, b uint64) uint64
}{
{"add", "\x23\xb8\x72\xdd", func(a, b uint64) uint64 { return (a + b) & 0xFF }},
{"sub", "\x51\xca\xb0\x91", func(a, b uint64) uint64 { return (a - b) & 0xFF }},
{"mul", "\xc8\xa4\xac\x9c", func(a, b uint64) uint64 { return (a * b) & 0xFF }},
{"min", "\x7a\x8f\x63\xb8", minU64},
{"max", "\x6e\x32\x91\x28", maxU64},
{"and", "\xcd\x30\x32\x00", func(a, b uint64) uint64 { return a & b }},
{"or", "\x5a\x6b\x26\xba", func(a, b uint64) uint64 { return a | b }},
{"xor", "\xf6\x74\x70\x22", func(a, b uint64) uint64 { return a ^ b }},
}
// Two pairs, chosen to discriminate rather than to enumerate: {5,3} has a>b and a
// non-commutative answer for sub, so it separates sub from its reverse and min from
// max; {200,100} wraps the 8-bit width for add and mul, so it catches an
// implementation that computes at the wrong width. Each FHE op costs seconds, so the
// matrix earns its size.
operands := [][2]uint64{{5, 3}, {200, 100}}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for _, ab := range operands {
e := newOpEnv(t)
a, b := ab[0], ab[1]
got := e.plain(e.run(c.sel, pair(e.enc(a, TypeEuint8), e.enc(b, TypeEuint8))), TypeEuint8)
require.Equalf(t, c.want(a, b), got, "%s(%d, %d)", c.name, a, b)
}
})
}
}
// TestOps_DivRemAreHomomorphic covers the two operations that were registered, priced and
// billed while having no case in performFHEOperation's switch — every call fell to the
// default arm and returned the zero handle with a nil error, reporting success for a
// guaranteed no-op. They now compute.
func TestOps_DivRemAreHomomorphic(t *testing.T) {
for _, c := range []struct {
name string
sel string
want func(a, b uint64) uint64
}{
{"div", "\x0f\x5e\x1b\x2a", func(a, b uint64) uint64 { return a / b }},
{"rem", "\x1e\x19\x1a\x96", func(a, b uint64) uint64 { return a % b }},
} {
t.Run(c.name, func(t *testing.T) {
for _, ab := range [][2]uint64{{13, 4}, {7, 9}} { // one exact, one with a remainder and a<b
e := newOpEnv(t)
a, b := ab[0], ab[1]
got := e.plain(e.run(c.sel, pair(e.enc(a, TypeEuint8), e.enc(b, TypeEuint8))), TypeEuint8)
require.Equalf(t, c.want(a, b), got, "%s(%d, %d)", c.name, a, b)
}
})
}
}
// TestOps_ComparisonsAreHomomorphic checks each comparison answers the encrypted boolean
// the plaintext comparison would. Every operand ordering is exercised, so an operation
// wired to its opposite (lt for gt) cannot pass.
func TestOps_ComparisonsAreHomomorphic(t *testing.T) {
cases := []struct {
name string
sel string
want func(a, b uint64) bool
}{
{"lt", "\xa9\x05\x9c\xbb", func(a, b uint64) bool { return a < b }},
{"le", "\x26\xa3\x31\x9e", func(a, b uint64) bool { return a <= b }},
{"gt", "\x4b\x64\xe4\x92", func(a, b uint64) bool { return a > b }},
{"ge", "\x53\x1c\x19\xea", func(a, b uint64) bool { return a >= b }},
{"eq", "\x1c\xf4\x86\x63", func(a, b uint64) bool { return a == b }},
{"ne", "\x14\x6e\x3a\x7e", func(a, b uint64) bool { return a != b }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for _, ab := range [][2]uint64{{3, 5}, {4, 4}} { // strictly-less and equal separate lt/le/eq/ne
e := newOpEnv(t)
a, b := ab[0], ab[1]
got := e.plain(e.run(c.sel, pair(e.enc(a, TypeEuint8), e.enc(b, TypeEuint8))), TypeEbool)
wantBit := uint64(0)
if c.want(a, b) {
wantBit = 1
}
require.Equalf(t, wantBit, got&1, "%s(%d, %d)", c.name, a, b)
}
})
}
}
// TestOps_ScalarArithmeticIsHomomorphic covers the operations that take one ciphertext and
// one public scalar. The scalar is read from the second 32-byte word.
func TestOps_ScalarArithmeticIsHomomorphic(t *testing.T) {
cases := []struct {
name string
sel string
want func(a, k uint64) uint64
}{
{"scalarAdd", "\xf5\xa7\x96\xfb", func(a, k uint64) uint64 { return (a + k) & 0xFF }},
{"scalarSub", "\xb6\x3a\x9e\x11", func(a, k uint64) uint64 { return (a - k) & 0xFF }},
{"scalarMul", "\x3c\x96\x47\x95", func(a, k uint64) uint64 { return (a * k) & 0xFF }},
{"scalarDiv", "\x7b\x8f\x4a\x2d", func(a, k uint64) uint64 { return a / k }},
{"scalarRem", "\x52\x91\xa3\x21", func(a, k uint64) uint64 { return a % k }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for _, ak := range [][2]uint64{{20, 3}} { // 20/3 has a remainder, so div and rem differ
e := newOpEnv(t)
a, k := ak[0], ak[1]
body := append(e.enc(a, TypeEuint8).Bytes(), common.BigToHash(new(big.Int).SetUint64(k)).Bytes()...)
got := e.plain(e.run(c.sel, body), TypeEuint8)
require.Equalf(t, c.want(a, k), got, "%s(%d, %d)", c.name, a, k)
}
})
}
}
// TestOps_UnaryAndShifts covers the single-operand operations and the shifts, whose second
// argument is a public shift amount in one byte.
func TestOps_UnaryAndShifts(t *testing.T) {
t.Run("not", func(t *testing.T) {
e := newOpEnv(t)
got := e.plain(e.run("\x6b\x3a\x00\x11", e.enc(0x0F, TypeEuint8).Bytes()), TypeEuint8)
require.Equal(t, uint64(0xF0), got&0xFF, "not must flip every bit of the width")
})
t.Run("neg", func(t *testing.T) {
e := newOpEnv(t)
got := e.plain(e.run("\xe4\x7e\xf3\xfc", e.enc(5, TypeEuint8).Bytes()), TypeEuint8)
require.Equal(t, uint64(251), got&0xFF, "neg must be two's-complement negation")
})
for _, c := range []struct {
name string
sel string
want func(a uint64, s uint) uint64
}{
{"shl", "\x3e\x8c\x6c\x10", func(a uint64, s uint) uint64 { return (a << s) & 0xFF }},
{"shr", "\x5f\x46\xe5\x15", func(a uint64, s uint) uint64 { return (a & 0xFF) >> s }},
} {
t.Run(c.name, func(t *testing.T) {
for _, s := range []uint{0, 3} { // identity and a real shift
e := newOpEnv(t)
body := append(e.enc(0b1001_0110, TypeEuint8).Bytes(), byte(s))
got := e.plain(e.run(c.sel, body), TypeEuint8)
require.Equalf(t, c.want(0b1001_0110, s), got&0xFF, "%s by %d", c.name, s)
}
})
}
// Rotation preserves the multiset of bits, so a full-width rotation is the identity.
for _, c := range []struct{ name, sel string }{
{"rotl", "\x89\xa1\x9e\x6b"},
{"rotr", "\xd7\x25\x1c\xb9"},
} {
t.Run(c.name, func(t *testing.T) {
e := newOpEnv(t)
const v = uint64(0b1001_0110)
body := append(e.enc(v, TypeEuint8).Bytes(), byte(8))
require.Equal(t, v, e.plain(e.run(c.sel, body), TypeEuint8)&0xFF,
"rotating by the full width must be the identity")
e2 := newOpEnv(t)
body2 := append(e2.enc(v, TypeEuint8).Bytes(), byte(0))
require.Equal(t, v, e2.plain(e2.run(c.sel, body2), TypeEuint8)&0xFF,
"rotating by zero must be the identity")
})
}
}
// TestOps_SelectChoosesByTheEncryptedCondition proves select is a real oblivious choice:
// the same operands with the condition flipped must yield the other branch.
func TestOps_SelectChoosesByTheEncryptedCondition(t *testing.T) {
for _, cond := range []uint64{0, 1} {
e := newOpEnv(t)
body := append(e.enc(cond, TypeEbool).Bytes(),
append(e.enc(11, TypeEuint8).Bytes(), e.enc(22, TypeEuint8).Bytes()...)...)
got := e.plain(e.run("\x2e\x17\xde\x78", body), TypeEuint8)
want := uint64(22)
if cond == 1 {
want = 11
}
require.Equalf(t, want, got, "select with condition %d", cond)
}
}
// TestOps_EncryptRoundTrips covers every asE* entry point: a public value lifted into the
// ciphertext domain must decrypt back to itself.
func TestOps_EncryptRoundTrips(t *testing.T) {
cases := []struct {
name string
sel string
ctType uint8
value uint64
}{
{"asEbool", "\x8c\x3f\x5a\x42", TypeEbool, 1},
{"asEuint4", "\x2d\xfa\x48\x63", TypeEuint4, 9},
{"asEuint8", "\x64\xc1\x51\x81", TypeEuint8, 200},
{"asEuint16", "\xf8\x91\x08\x50", TypeEuint16, 60000},
{"asEuint32", "\x6c\xa9\xea\xe9", TypeEuint32, 4_000_000_000},
{"asEaddress", "\xd4\x3f\x02\x80", TypeEaddress, 0xDEADBEEF},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newOpEnv(t)
body := common.BigToHash(new(big.Int).SetUint64(c.value)).Bytes()
require.Equal(t, c.value, e.plain(e.run(c.sel, body), c.ctType))
})
}
// The wide types decrypt through a uint64 shim, so they are checked on a value that
// fits rather than on their full width.
for _, c := range []struct{ name, sel string }{
{"asEuint128", "\x7d\x6d\x81\x95"},
{"asEuint256", "\x9e\x5b\x2e\xf3"},
} {
t.Run(c.name, func(t *testing.T) {
e := newOpEnv(t)
const v = uint64(123456789)
body := common.BigToHash(new(big.Int).SetUint64(v)).Bytes()
h := e.run(c.sel, body)
require.NotEqual(t, common.Hash{}, h)
})
}
// asEbool normalises: any non-zero word encrypts to one.
e := newOpEnv(t)
body := common.BigToHash(big.NewInt(0)).Bytes()
require.Zero(t, e.plain(e.run("\x8c\x3f\x5a\x42", body), TypeEbool)&1)
}
// TestOps_CastPreservesTheValue proves widening a ciphertext keeps its plaintext.
func TestOps_CastPreservesTheValue(t *testing.T) {
e := newOpEnv(t)
body := append(e.enc(37, TypeEuint8).Bytes(), TypeEuint16)
require.Equal(t, uint64(37), e.plain(e.run("\xae\xd2\x44\x6b", body), TypeEuint16),
"a widening cast must preserve the value")
}
// TestOps_RandIsDeterministicPerCaller pins the consensus property of rand: it is derived
// from the caller, not from entropy, so every validator computes the same ciphertext. That
// also means it is NOT unpredictable — the test records that, because a contract treating
// it as a secret would be relying on something this precompile does not provide.
func TestOps_RandIsDeterministicPerCaller(t *testing.T) {
e := newOpEnv(t)
body := []byte{TypeEuint8}
first := e.plain(e.run("\x71\x5a\xd3\x11", body), TypeEuint8)
for i := 0; i < 2; i++ {
require.Equal(t, first, e.plain(e.run("\x71\x5a\xd3\x11", body), TypeEuint8),
"rand must be reproducible for one caller, or validators diverge")
}
}
// TestOps_EqualPlaintextsDoNotShareAHandle pins a confidentiality property that follows
// from LWE encryption being randomised: two encryptions of the same value must NOT produce
// the same handle. If they did, handle equality would publicly reveal plaintext equality —
// anyone could test a ciphertext against a guess by encrypting the guess and comparing
// handles, which defeats the point of the scheme.
func TestOps_EqualPlaintextsDoNotShareAHandle(t *testing.T) {
e := newOpEnv(t)
seen := map[common.Hash]bool{}
for i := 0; i < 4; i++ {
h := e.enc(7, TypeEuint8)
require.Falsef(t, seen[h], "encryption %d reused a handle; plaintext equality would leak", i)
seen[h] = true
}
require.NotEqual(t, e.enc(7, TypeEuint8), e.enc(8, TypeEuint8))
}
// TestOps_AreDeterministicGivenFixedOperands pins the half of the determinism story that
// currently holds, and is the half a broken evaluator would break: for FIXED operand
// handles, every operation returns the identical result handle on every invocation. Two
// validators replaying the same transaction against the same operands therefore compute the
// same state.
//
// The other half does NOT hold: see the report accompanying this work. Producing an operand
// in the first place (asEuint8, verify, rand) draws fresh LWE randomness, so the operand
// handles themselves differ between validators. This test deliberately fixes the operands
// so it measures the evaluator and not the encryptor.
func TestOps_AreDeterministicGivenFixedOperands(t *testing.T) {
e := newOpEnv(t)
a, b := e.enc(9, TypeEuint8), e.enc(4, TypeEuint8)
for name, sel := range map[string]string{
"add": "\x23\xb8\x72\xdd",
"and": "\xcd\x30\x32\x00",
} {
t.Run(name, func(t *testing.T) {
first := e.run(sel, pair(a, b))
for i := 0; i < 2; i++ {
require.Equalf(t, first, e.run(sel, pair(a, b)),
"%s must return the same handle for the same operands", name)
}
})
}
}
// TestOps_UnknownHandleIsRefused proves an operation naming a handle that was never stored
// fails rather than computing over an empty ciphertext and reporting success.
func TestOps_UnknownHandleIsRefused(t *testing.T) {
e := newOpEnv(t)
known := e.enc(1, TypeEuint8)
var missing common.Hash
missing[31] = 0xFF
for name, body := range map[string][]byte{
"left operand missing": pair(missing, known),
"right operand missing": pair(known, missing),
"both missing": pair(missing, missing),
} {
_, _, err := e.c.Run(e.state, ownerAddr, ContractAddress,
append([]byte("\x23\xb8\x72\xdd"), body...), opGas, false)
require.Errorf(t, err, "add with %s must be refused", name)
}
}
// TestOps_GasBoundaryPerOperation proves each operation refuses one gas below its price and
// succeeds at exactly its price, and that what it charges equals that price.
func TestOps_GasBoundaryPerOperation(t *testing.T) {
cases := []struct {
name string
sel string
cost uint64
}{
{"add", "\x23\xb8\x72\xdd", GasAdd},
{"and", "\xcd\x30\x32\x00", GasAnd},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newOpEnv(t)
body := pair(e.enc(4, TypeEuint8), e.enc(6, TypeEuint8))
input := append([]byte(c.sel), body...)
_, _, err := e.c.Run(e.state, ownerAddr, ContractAddress, input, c.cost-1, false)
require.ErrorIs(t, err, ErrInsufficientGas, "one gas short must be refused")
_, remaining, err := e.c.Run(e.state, ownerAddr, ContractAddress, input, c.cost, false)
require.NoError(t, err, "exactly the price must be enough")
require.Zero(t, remaining, "and must consume exactly the price")
})
}
}
// TestOps_PriceOrderingMatchesWork pins the relative schedule rather than the literals:
// multiplication is the most expensive arithmetic, bitwise operations are cheaper than
// arithmetic, and nothing is free.
func TestOps_PriceOrderingMatchesWork(t *testing.T) {
require.Greater(t, GasMul, GasAdd, "multiplication does strictly more work than addition")
require.GreaterOrEqual(t, GasDiv, GasAdd)
require.Greater(t, GasAdd, GasAnd, "arithmetic costs more than a bitwise operation")
require.Greater(t, GasAnd, GasNot, "a binary bitwise op costs more than a unary one")
require.GreaterOrEqual(t, GasMin, GasAdd, "min is a comparison plus a select")
for name, g := range map[string]uint64{
"add": GasAdd, "sub": GasSub, "mul": GasMul, "div": GasDiv, "rem": GasRem,
"and": GasAnd, "or": GasOr, "xor": GasXor, "not": GasNot, "neg": GasNeg,
"eq": GasEq, "ne": GasNe, "lt": GasLt, "le": GasLe, "gt": GasGt, "ge": GasGe,
"min": GasMin, "max": GasMax, "select": GasSelect, "rand": GasRand,
"encrypt": GasEncrypt, "cast": GasCast, "decryptRequest": GasDecryptRequest,
} {
require.Positivef(t, g, "%s must not be free", name)
}
}
// TestBackendIsReported covers the backend accessor and pins that it names something.
func TestBackendIsReported(t *testing.T) {
require.NoError(t, initTFHE())
require.True(t, HasNetworkKeys(), "initTFHE installs the public key material")
require.NotEmpty(t, GetBackend(), "the evaluator backend must identify itself")
}
+4 -4
View File
@@ -30,10 +30,10 @@ import (
// the decrypt/seal paths.
type aclTestState struct{ db contract.StateDB }
func (s *aclTestState) GetStateDB() contract.StateDB { return s.db }
func (s *aclTestState) GetBlockContext() contract.BlockContext { return nil }
func (s *aclTestState) GetConsensusContext() context.Context { return nil }
func (s *aclTestState) GetChainConfig() precompileconfig.ChainConfig { return nil }
func (s *aclTestState) GetStateDB() contract.StateDB { return s.db }
func (s *aclTestState) GetBlockContext() contract.BlockContext { return nil }
func (s *aclTestState) GetConsensusContext() context.Context { return nil }
func (s *aclTestState) GetChainConfig() precompileconfig.ChainConfig { return nil }
func (s *aclTestState) GetPrecompileEnv() contract.PrecompileEnvironment { return nil }
var _ contract.AccessibleState = (*aclTestState)(nil)
+202 -160
View File
@@ -5,21 +5,12 @@ package graph
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"fmt"
"time"
"github.com/luxfi/geth/common"
)
// StateDB interface for accessing EVM state
type StateDB interface {
GetState(addr common.Address, key common.Hash) common.Hash
SetState(addr common.Address, key common.Hash, value common.Hash)
}
// GChainClient interface for communication with G-Chain
type GChainClient interface {
// Query executes a GraphQL query against G-Chain
@@ -29,19 +20,23 @@ type GChainClient interface {
QueryChain(ctx context.Context, chainID uint64, query string, variables map[string]any) ([]byte, error)
}
// Precompile address
var graphQLAddr = common.HexToAddress(GraphQLAddress)
// Storage key prefixes
var (
cachePrefix = []byte("gcache")
statsPrefix = []byte("gstats")
configPrefix = []byte("gconf")
// Method selectors.
const (
selectorQuery uint32 = 0x01 // query(bytes)
selectorPredefined uint32 = 0x02 // queryPredefined(uint16, bytes[])
selectorStats uint32 = 0x03 // getStats()
)
// complexQueryLength is the query length above which the complex tier applies.
const complexQueryLength = 500
// GraphQLPrecompile implements the G-Chain GraphQL query interface
// This precompile enables any EVM contract to execute GraphQL queries
// against the unified G-Chain query layer.
//
// It is a pure read: it touches no EVM state and does not consult the caller, which is why
// neither appears in the signatures below. There is nothing here for a readOnly context to
// forbid and nothing for an access check to gate.
type GraphQLPrecompile struct {
// client is the connection to G-Chain. It is set ONCE during VM init
// (SetGraphVMClient), before the precompile is ever invoked in consensus, and is
@@ -93,14 +88,73 @@ func NewGraphQLPrecompile(client GChainClient) *GraphQLPrecompile {
}
}
// makeStorageKey creates a storage key from prefix and data
func makeStorageKey(prefix []byte, data []byte) common.Hash {
h := sha256.New()
h.Write(prefix)
h.Write(data)
var key common.Hash
copy(key[:], h.Sum(nil))
return key
// =========================================================================
// Gas
// =========================================================================
// maxResponseGas is the price of the largest response the precompile may return. It is the
// allowance reserved against a response that does not exist yet; see gasForInput.
const maxResponseGas = uint64(MaxResponseSize) * GasPerByte
// requestGas prices everything the caller controls that is knowable BEFORE any work
// happens: the query bytes, the variable bytes, and the per-chain fan-out. Bytes cost
// GasPerByte wherever they appear, so there is one rule rather than one per field. It reads
// no state, no clock and no client, so every validator computes the same figure.
func requestGas(req QueryRequest) uint64 {
cost := GasQueryBase
if len(req.Query) > complexQueryLength {
cost += GasQueryComplex
} else {
cost += GasQuerySimple
}
// Each additional target chain is another round trip through the client; the fan-out
// is capped by validateQuery, which is what keeps this product bounded.
if len(req.TargetChains) > 1 {
cost += GasQueryCrossChain * uint64(len(req.TargetChains))
}
cost += uint64(len(req.Query)+len(req.Variables)) * GasPerByte
return cost
}
// gasForInput is the gas reserved before the precompile does anything, and the figure
// RequiredGas reports.
//
// Response size is only known once the work is done, so charging for it afterwards would
// mean the node had already performed work it might not be paid for. Instead the caller is
// charged the worst case — requestGas plus the largest response the precompile is allowed
// to return — before execution starts, and refunded down to the actual figure afterwards.
// A query therefore never runs unless its caller has already paid for the largest result it
// could produce, so there is no deduction that can come up short and no path on which work
// is performed for free. The refund is a pure function of a deterministic result.
//
// gasForInput reads only the calldata: no state, no client, no clock.
func gasForInput(input []byte) uint64 {
if len(input) < 4 {
return GasQueryBase
}
body := input[4:]
// Decoding the body is itself work proportional to its length, so it is priced even
// when it fails and nothing further is attempted.
decodeGas := GasQueryBase + uint64(len(body))*GasPerByte
switch binary.BigEndian.Uint32(input[:4]) {
case selectorQuery:
req, err := decodeQuery(body)
if err != nil {
return decodeGas
}
return requestGas(req) + maxResponseGas
case selectorPredefined:
req, err := decodePredefined(body)
if err != nil {
return decodeGas
}
return requestGas(req) + maxResponseGas
case selectorStats:
return GasQueryBase
default:
return decodeGas
}
}
// =========================================================================
@@ -117,12 +171,10 @@ func makeStorageKey(prefix []byte, data []byte) common.Hash {
// - a nil client returns a typed error instead of panicking the dispatch path.
//
// Every validator therefore computes the same bytes for the same block state.
func (p *GraphQLPrecompile) Query(
stateDB StateDB,
caller common.Address,
req QueryRequest,
gasLimit uint64,
) (QueryResponse, error) {
//
// gasLimit is the caller's budget. On the precompile dispatch path it is the reservation
// already taken by RequiredGas; a direct caller of this method supplies its own.
func (p *GraphQLPrecompile) Query(req QueryRequest, gasLimit uint64) (QueryResponse, error) {
// Validate query
if err := p.validateQuery(req); err != nil {
return QueryResponse{}, err
@@ -138,7 +190,7 @@ func (p *GraphQLPrecompile) Query(
// Gas cost is a pure function of the query (no cache-hit discount), so it is
// identical on every node.
gasCost := p.calculateGasCost(req)
gasCost := requestGas(req)
if gasCost > gasLimit {
return QueryResponse{}, ErrGasExceeded
}
@@ -176,57 +228,103 @@ func (p *GraphQLPrecompile) Query(
}, nil
}
// Validate response size
// Validate response size. This bound is what makes the upfront reservation total:
// the response term can never exceed the allowance already taken.
if len(result) > MaxResponseSize {
return QueryResponse{}, ErrQueryTooLarge
}
responseGas := uint64(len(result)) * GasPerByte
return QueryResponse{
Data: result,
GasUsed: gasCost + responseGas,
GasUsed: gasCost + uint64(len(result))*GasPerByte,
}, nil
}
// QueryPredefined executes a pre-defined query template
// This provides lower gas costs for common queries
func (p *GraphQLPrecompile) QueryPredefined(
stateDB StateDB,
caller common.Address,
queryID QueryID,
args [][]byte,
gasLimit uint64,
) (QueryResponse, error) {
func (p *GraphQLPrecompile) QueryPredefined(queryID QueryID, args [][]byte, gasLimit uint64) (QueryResponse, error) {
req, err := expandPredefined(queryID, args)
if err != nil {
return QueryResponse{}, err
}
return p.Query(req, gasLimit)
}
// =========================================================================
// Decoding
// =========================================================================
// decodeQuery decodes selector-0x01 calldata. Pricing and execution share it, so the gas
// reserved is always computed from the same request that is subsequently run.
func decodeQuery(body []byte) (QueryRequest, error) {
var req QueryRequest
if err := json.Unmarshal(body, &req); err != nil {
return QueryRequest{}, ErrInvalidQuery
}
return req, nil
}
// decodePredefined decodes selector-0x02 calldata into the request its template expands to.
// Pricing and execution share it for the same reason as decodeQuery.
func decodePredefined(body []byte) (QueryRequest, error) {
if len(body) < 2 {
return QueryRequest{}, ErrInvalidQuery
}
args, err := decodeArgs(body[2:])
if err != nil {
return QueryRequest{}, err
}
return expandPredefined(QueryID(binary.BigEndian.Uint16(body[:2])), args)
}
// decodeArgs parses length-prefixed byte arrays. A truncated tail is REFUSED rather than
// silently accepted as a shorter argument list: a caller must not be able to change which
// arguments a query is expanded with by cutting its calldata short.
func decodeArgs(body []byte) ([][]byte, error) {
var args [][]byte
for offset := 0; offset < len(body); {
if offset+4 > len(body) {
return nil, ErrInvalidQuery
}
argLen := int(binary.BigEndian.Uint32(body[offset : offset+4]))
offset += 4
if argLen < 0 || offset+argLen > len(body) {
return nil, ErrInvalidQuery
}
args = append(args, body[offset:offset+argLen])
offset += argLen
}
return args, nil
}
// argNames binds positional arguments to template variable names.
var argNames = []string{"address", "id", "first", "orderBy", "owner"}
// expandPredefined turns a template ID plus positional arguments into the request that will
// actually be executed. It is the single expander: gas is computed from its output and so
// is the query that runs, so the two cannot disagree.
func expandPredefined(queryID QueryID, args [][]byte) (QueryRequest, error) {
template, ok := PredefinedQueries[queryID]
if !ok {
return QueryResponse{}, ErrInvalidQuery
return QueryRequest{}, ErrInvalidQuery
}
if len(args) > template.MaxArgs {
return QueryResponse{}, ErrInvalidQuery
return QueryRequest{}, ErrInvalidQuery
}
if template.GasCost > gasLimit {
return QueryResponse{}, ErrGasExceeded
}
// Build variables from args
variables := make(map[string]any)
argNames := []string{"address", "id", "first", "orderBy", "owner"}
variables := make(map[string]any, len(args))
for i, arg := range args {
if i < len(argNames) {
variables[argNames[i]] = string(arg)
}
}
varsJSON, _ := json.Marshal(variables)
req := QueryRequest{
Query: template.Query,
Variables: varsJSON,
// json.Marshal sorts map keys, so the encoding is byte-identical on every validator.
varsJSON, err := json.Marshal(variables)
if err != nil {
return QueryRequest{}, ErrInvalidQuery
}
return p.Query(stateDB, caller, req, gasLimit)
return QueryRequest{Query: template.Query, Variables: varsJSON}, nil
}
// =========================================================================
@@ -241,33 +339,14 @@ func (p *GraphQLPrecompile) validateQuery(req QueryRequest) error {
if len(req.Query) > MaxQuerySize {
return ErrQueryTooLarge
}
// The fan-out drives one client round trip per entry and one GasQueryCrossChain term
// per entry. Capping the slice bounds both the loop and the arithmetic.
if len(req.TargetChains) > MaxTargetChains {
return ErrQueryTooLarge
}
return nil
}
// calculateGasCost estimates the gas cost for a query
func (p *GraphQLPrecompile) calculateGasCost(req QueryRequest) uint64 {
// Base cost
cost := GasQueryBase
// Add cost based on query complexity
queryLen := len(req.Query)
if queryLen > 500 {
cost += GasQueryComplex
} else {
cost += GasQuerySimple
}
// Add cost for cross-chain queries
if len(req.TargetChains) > 1 {
cost += GasQueryCrossChain * uint64(len(req.TargetChains))
}
// Add cost for variables
cost += uint64(len(req.Variables)) * GasPerByte
return cost
}
// executeMultiChainQuery executes a query across multiple chains SEQUENTIALLY. The
// consensus path must be free of goroutines: scheduler order is nondeterministic, and the
// previous concurrent version let whichever goroutine raced first win the "first error"
@@ -323,94 +402,57 @@ func (p *GraphQLPrecompile) GetConfig() *Config {
// EVM Precompile Interface
// =========================================================================
// RequiredGas returns the gas required for the input
// RequiredGas returns the gas that must be reserved before Run is allowed to execute.
func (p *GraphQLPrecompile) RequiredGas(input []byte) uint64 {
return gasForInput(input)
}
// Run executes the precompile and reports the gas actually consumed, which is never more
// than the RequiredGas reserved for the same input.
func (p *GraphQLPrecompile) Run(input []byte) ([]byte, uint64, error) {
if len(input) < 4 {
return GasQueryBase
return nil, GasQueryBase, ErrInvalidQuery
}
// Parse method selector
selector := binary.BigEndian.Uint32(input[:4])
body := input[4:]
decodeGas := GasQueryBase + uint64(len(body))*GasPerByte
switch selector {
case 0x01: // query(bytes)
return GasQueryComplex
case 0x02: // queryPredefined(uint16, bytes[])
return GasQuerySimple
case 0x03: // getStats()
return GasQueryBase
default:
return GasQueryComplex
}
}
// Run executes the precompile
func (p *GraphQLPrecompile) Run(input []byte) ([]byte, error) {
if len(input) < 4 {
return nil, ErrInvalidQuery
}
selector := binary.BigEndian.Uint32(input[:4])
switch selector {
case 0x01: // query(bytes)
return p.runQuery(input[4:])
case 0x02: // queryPredefined(uint16, bytes[])
return p.runQueryPredefined(input[4:])
case 0x03: // getStats()
return p.runGetStats()
default:
return nil, fmt.Errorf("unknown method: %x", selector)
}
}
// runQuery handles the query method call
func (p *GraphQLPrecompile) runQuery(input []byte) ([]byte, error) {
// Decode QueryRequest from input
var req QueryRequest
if err := json.Unmarshal(input, &req); err != nil {
return nil, ErrInvalidQuery
}
// Execute query (uses default gas limit)
resp, err := p.Query(nil, common.Address{}, req, 1_000_000)
if err != nil {
return nil, err
}
return resp.Data, nil
}
// runQueryPredefined handles the queryPredefined method call
func (p *GraphQLPrecompile) runQueryPredefined(input []byte) ([]byte, error) {
if len(input) < 2 {
return nil, ErrInvalidQuery
}
queryID := QueryID(binary.BigEndian.Uint16(input[:2]))
// Parse args (simple format: length-prefixed byte arrays)
var args [][]byte
offset := 2
for offset < len(input) {
if offset+4 > len(input) {
break
switch binary.BigEndian.Uint32(input[:4]) {
case selectorQuery:
req, err := decodeQuery(body)
if err != nil {
return nil, decodeGas, err
}
argLen := binary.BigEndian.Uint32(input[offset : offset+4])
offset += 4
if offset+int(argLen) > len(input) {
break
return p.run(req)
case selectorPredefined:
req, err := decodePredefined(body)
if err != nil {
return nil, decodeGas, err
}
args = append(args, input[offset:offset+int(argLen)])
offset += int(argLen)
return p.run(req)
case selectorStats:
out, err := p.runGetStats()
return out, GasQueryBase, err
default:
return nil, decodeGas, fmt.Errorf("unknown method: %x", input[:4])
}
}
resp, err := p.QueryPredefined(nil, common.Address{}, queryID, args, 1_000_000)
// run executes an already-decoded request under the reservation RequiredGas took for it.
//
// A response carrying GraphQL errors is surfaced as a Go error so the EVM call reverts:
// returning empty data with no error would leave a caller unable to tell a failed query
// from a genuinely empty result.
func (p *GraphQLPrecompile) run(req QueryRequest) ([]byte, uint64, error) {
reserved := requestGas(req) + maxResponseGas
resp, err := p.Query(req, reserved)
if err != nil {
return nil, err
return nil, reserved, err
}
return resp.Data, nil
if len(resp.Errors) > 0 {
return nil, resp.GasUsed, fmt.Errorf("graphql: %s", resp.Errors[0].Message)
}
return resp.Data, resp.GasUsed, nil
}
// runGetStats handles the getStats method call. It returns the deterministic zero stats
+5 -5
View File
@@ -47,7 +47,7 @@ func TestGraphQL_NilClientReturnsTypedErrorNotPanic(t *testing.T) {
p := NewGraphQLPrecompile(nil)
require.NotPanics(t, func() {
_, err := p.Query(nil, common.Address{}, QueryRequest{Query: "query { chainInfo }"}, 1_000_000)
_, err := p.Query(QueryRequest{Query: "query { chainInfo }"}, 1_000_000)
require.ErrorIs(t, err, ErrClientNotInitialized)
})
@@ -75,11 +75,11 @@ func TestGraphQL_DeterministicAcrossRuns(t *testing.T) {
p := NewGraphQLPrecompile(client)
req := QueryRequest{Query: "query { chainInfo { vmName } }"}
first, err := p.Query(nil, common.Address{}, req, 1_000_000)
first, err := p.Query(req, 1_000_000)
require.NoError(t, err)
for i := 0; i < 100; i++ {
got, err := p.Query(nil, common.Address{}, req, 1_000_000)
got, err := p.Query(req, 1_000_000)
require.NoError(t, err)
require.Equal(t, first.Data, got.Data, "data must be identical across runs")
require.Equal(t, first.GasUsed, got.GasUsed, "gas must be identical across runs (no cache discount)")
@@ -107,7 +107,7 @@ func TestGraphQL_MultiChainSequentialDeterministic(t *testing.T) {
}
p := NewGraphQLPrecompile(client)
resp, err := p.Query(nil, common.Address{}, req, 10_000_000)
resp, err := p.Query(req, 10_000_000)
require.NoError(t, err)
// Chains are visited in exactly the request order — proof of sequential execution.
@@ -141,7 +141,7 @@ func TestGraphQL_MultiChainFirstErrorDeterministic(t *testing.T) {
for i := 0; i < 20; i++ {
client := &stubClient{errOn: map[uint64]error{11: errA, 22: errB}}
p := NewGraphQLPrecompile(client)
resp, err := p.Query(nil, common.Address{}, req, 10_000_000)
resp, err := p.Query(req, 10_000_000)
// Both chains errored and none produced a result -> the FIRST error (chain 11) wins.
require.NoError(t, err) // surfaced as a QueryError, not a Go error
require.Len(t, resp.Errors, 1)
+222
View File
@@ -0,0 +1,222 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package graph
import (
"encoding/binary"
"encoding/json"
"strings"
"testing"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// queryInput encodes a QueryRequest as calldata for selector 0x01.
func queryInput(t *testing.T, req QueryRequest) []byte {
t.Helper()
body, err := json.Marshal(req)
require.NoError(t, err)
input := make([]byte, 4+len(body))
binary.BigEndian.PutUint32(input[:4], 0x01)
copy(input[4:], body)
return input
}
// charged runs the registered contract and reports what the caller actually paid.
func charged(t *testing.T, c *GraphQLContract, input []byte, supplied uint64) uint64 {
t.Helper()
_, remaining, err := c.Run(nil, common.Address{}, ContractGraphQLAddress, input, supplied, false)
require.NoError(t, err)
require.LessOrEqual(t, remaining, supplied, "a run may never hand back more gas than it was given")
return supplied - remaining
}
// TestGraphQL_GasScalesWithResponseSize is the flat-fee regression.
//
// The precompile computed a size-aware price (calculateGasCost + len(result)*GasPerByte)
// and returned it in QueryResponse.GasUsed, but the dispatch path discarded the whole
// response except .Data and charged only the flat per-selector RequiredGas. A query
// returning 1 byte and a query returning MaxResponseSize bytes therefore cost exactly the
// same: caller-controlled work at a constant price.
func TestGraphQL_GasScalesWithResponseSize(t *testing.T) {
req := QueryRequest{Query: "query { chainInfo { vmName } }"}
input := queryInput(t, req)
small := &GraphQLContract{precompile: NewGraphQLPrecompile(
&stubClient{result: []byte(`{"a":1}`)})}
big := &GraphQLContract{precompile: NewGraphQLPrecompile(
&stubClient{result: []byte(`{"a":"` + strings.Repeat("x", MaxResponseSize-10) + `"}`)})}
cheap := charged(t, small, input, 10_000_000)
dear := charged(t, big, input, 10_000_000)
require.Greater(t, dear, cheap,
"a larger response must cost more gas than a smaller one for the identical query")
// The gap must be the real per-byte price, not a rounding artefact.
require.GreaterOrEqual(t, dear-cheap, uint64(MaxResponseSize-32)*GasPerByte)
}
// TestGraphQL_GasScalesWithQuerySize proves the request side is priced too: a long query
// costs more than a short one against the same (fixed) response.
func TestGraphQL_GasScalesWithQuerySize(t *testing.T) {
c := &GraphQLContract{precompile: NewGraphQLPrecompile(&stubClient{result: []byte(`{"a":1}`)})}
short := charged(t, c, queryInput(t, QueryRequest{Query: "query{a}"}), 10_000_000)
long := charged(t, c, queryInput(t, QueryRequest{
Query: "query{" + strings.Repeat("a", 1000) + "}",
}), 10_000_000)
require.Greater(t, long, short, "a longer query must cost more gas")
require.GreaterOrEqual(t, long-short, GasQueryComplex-GasQuerySimple)
}
// TestGraphQL_GasScalesWithVariablesSize proves caller-supplied variables are priced per
// byte on the path that actually charges.
func TestGraphQL_GasScalesWithVariablesSize(t *testing.T) {
c := &GraphQLContract{precompile: NewGraphQLPrecompile(&stubClient{result: []byte(`{}`)})}
bare := charged(t, c, queryInput(t, QueryRequest{Query: "query{a}"}), 10_000_000)
vars, err := json.Marshal(map[string]string{"k": strings.Repeat("v", 2000)})
require.NoError(t, err)
loaded := charged(t, c, queryInput(t, QueryRequest{Query: "query{a}", Variables: vars}), 10_000_000)
require.Greater(t, loaded, bare, "caller-supplied variables must be priced")
require.GreaterOrEqual(t, loaded-bare, uint64(len(vars))*GasPerByte)
}
// TestGraphQL_MultiChainGasScalesWithTargetChains is the unbounded-work regression, and the
// sharpest form of the flat fee. TargetChains is decoded straight out of caller JSON and
// drives one client.QueryChain call per entry with no cap; the discarded formula priced each
// at GasQueryCrossChain while the charged fee was a constant. N chains of work for the price
// of one.
func TestGraphQL_MultiChainGasScalesWithTargetChains(t *testing.T) {
c := &GraphQLContract{precompile: NewGraphQLPrecompile(&stubClient{result: []byte(`{"tvl":1}`)})}
two := charged(t, c, queryInput(t, QueryRequest{
Query: "query{tvl}", TargetChains: []uint64{1, 2},
}), 100_000_000)
many := charged(t, c, queryInput(t, QueryRequest{
Query: "query{tvl}", TargetChains: []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
}), 100_000_000)
require.Greater(t, many, two, "each additional target chain is another DB query and must be paid for")
require.GreaterOrEqual(t, many-two, 8*GasQueryCrossChain)
}
// TestGraphQL_TargetChainsCapped proves the fan-out loop is bounded, not merely priced. An
// uncapped loop over caller JSON is a latency hazard on the consensus path even when the
// gas arithmetic is right, and it is what let the gas figure grow without bound.
func TestGraphQL_TargetChainsCapped(t *testing.T) {
stub := &stubClient{result: []byte(`{}`)}
p := NewGraphQLPrecompile(stub)
chains := make([]uint64, MaxTargetChains+1)
for i := range chains {
chains[i] = uint64(i)
}
_, err := p.Query(QueryRequest{Query: "query{a}", TargetChains: chains}, 1<<62)
require.ErrorIs(t, err, ErrQueryTooLarge)
require.Empty(t, stub.calls, "a rejected request must not perform any chain query")
// Exactly at the cap is accepted.
stub2 := &stubClient{result: []byte(`{}`)}
p2 := NewGraphQLPrecompile(stub2)
_, err = p2.Query(QueryRequest{Query: "query{a}", TargetChains: chains[:MaxTargetChains]}, 1<<62)
require.NoError(t, err)
require.Len(t, stub2.calls, MaxTargetChains)
}
// TestGraphQL_UpfrontBoundCoversActual is the property that makes charge-then-refund safe:
// for every input, the gas reserved before any work is performed is at least the gas
// finally charged. If it were ever less the caller would receive work it had not paid for.
func TestGraphQL_UpfrontBoundCoversActual(t *testing.T) {
// Single-chain shapes return the stub body verbatim, so the response term can be swept
// right up to the cap.
single := []QueryRequest{
{Query: "query{a}"},
{Query: strings.Repeat("q", MaxQuerySize)},
{Query: "query{a}", TargetChains: []uint64{7}},
{Query: "query{a}", Variables: []byte(`{"k":"v"}`)},
}
for _, n := range []int{0, 1, 7, 1024, MaxResponseSize - 1, MaxResponseSize} {
c := &GraphQLContract{precompile: NewGraphQLPrecompile(&stubClient{result: jsonBlob(n)})}
for _, req := range single {
input := queryInput(t, req)
bound := c.precompile.RequiredGas(input)
used := charged(t, c, input, 1<<40)
require.LessOrEqual(t, used, bound,
"charged %d exceeds the %d reserved upfront for response size %d", used, bound, n)
}
}
// The multi-chain shape wraps one body per chain, so its combined result must stay
// under the cap for the call to succeed at all.
for _, chains := range [][]uint64{{1, 2}, {1, 2, 3}, make([]uint64, MaxTargetChains)} {
for i := range chains {
chains[i] = uint64(i + 1)
}
c := &GraphQLContract{precompile: NewGraphQLPrecompile(&stubClient{result: jsonBlob(64)})}
input := queryInput(t, QueryRequest{Query: "query{a}", TargetChains: chains})
bound := c.precompile.RequiredGas(input)
used := charged(t, c, input, 1<<40)
require.LessOrEqual(t, used, bound, "charged %d exceeds the %d reserved for %d chains",
used, bound, len(chains))
}
}
// jsonBlob builds a JSON string literal of exactly n bytes (empty for n < 2).
func jsonBlob(n int) []byte {
if n < 2 {
return nil
}
return []byte(`"` + strings.Repeat("x", n-2) + `"`)
}
// TestGraphQL_OversizedResponseRefusedAndCharged proves the response cap is enforced and
// that overrunning it is not free. The oversized body is only discovered after the client
// has produced it, so the caller pays the full reservation it agreed to.
func TestGraphQL_OversizedResponseRefusedAndCharged(t *testing.T) {
c := &GraphQLContract{precompile: NewGraphQLPrecompile(
&stubClient{result: jsonBlob(MaxResponseSize + 1)})}
input := queryInput(t, QueryRequest{Query: "query{a}"})
reserved := c.precompile.RequiredGas(input)
const supplied = 10_000_000
out, remaining, err := c.Run(nil, common.Address{}, ContractGraphQLAddress, input, supplied, false)
require.ErrorIs(t, err, ErrQueryTooLarge)
require.Nil(t, out)
require.Equal(t, uint64(supplied)-reserved, remaining,
"a failed run consumes exactly the reservation — no refund, no over-charge")
}
// TestGraphQL_InsufficientGasDoesNoWork proves the reservation is enforced before the work,
// not after: a caller that cannot cover the upfront bound never reaches the client.
func TestGraphQL_InsufficientGasDoesNoWork(t *testing.T) {
stub := &stubClient{result: []byte(`{}`)}
c := &GraphQLContract{precompile: NewGraphQLPrecompile(stub)}
input := queryInput(t, QueryRequest{Query: "query{a}", TargetChains: []uint64{1, 2, 3}})
bound := c.precompile.RequiredGas(input)
_, remaining, err := c.Run(nil, common.Address{}, ContractGraphQLAddress, input, bound-1, false)
require.Error(t, err)
require.Contains(t, err.Error(), "out of gas")
require.Zero(t, remaining, "a refused call refunds nothing")
require.Empty(t, stub.calls, "no chain may be queried when the reservation is not met")
}
// TestGraphQL_RequiredGasIsPureFunctionOfInput proves the reservation depends only on the
// bytes the caller supplied — it never consults the client, the config or any state — so
// every validator reserves the same amount.
func TestGraphQL_RequiredGasIsPureFunctionOfInput(t *testing.T) {
input := queryInput(t, QueryRequest{Query: "query{a}", TargetChains: []uint64{9, 8}})
wired := NewGraphQLPrecompile(&stubClient{result: []byte(`{"big":true}`)})
unwired := NewGraphQLPrecompile(nil)
require.Equal(t, unwired.RequiredGas(input), wired.RequiredGas(input))
for i := 0; i < 50; i++ {
require.Equal(t, wired.RequiredGas(input), wired.RequiredGas(input))
}
}
+561
View File
@@ -0,0 +1,561 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package graph
import (
"context"
"encoding/binary"
"encoding/json"
"math/big"
"strings"
"testing"
"time"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/precompileconfig"
"github.com/stretchr/testify/require"
)
// selectorInput builds calldata for an arbitrary selector and body.
func selectorInput(sel uint32, body []byte) []byte {
input := make([]byte, 4+len(body))
binary.BigEndian.PutUint32(input[:4], sel)
copy(input[4:], body)
return input
}
// TestValidateQuery_Refusals covers every reason a request is turned away before any work
// happens, at both ends of each bound.
func TestValidateQuery_Refusals(t *testing.T) {
p := NewGraphQLPrecompile(&stubClient{result: []byte(`{}`)})
cases := []struct {
name string
req QueryRequest
want error
}{
{"empty query", QueryRequest{Query: ""}, ErrInvalidQuery},
{"one over the query cap", QueryRequest{Query: strings.Repeat("q", MaxQuerySize+1)}, ErrQueryTooLarge},
{"one over the chain cap", QueryRequest{
Query: "q{a}", TargetChains: make([]uint64, MaxTargetChains+1),
}, ErrQueryTooLarge},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := p.Query(c.req, 1<<62)
require.ErrorIs(t, err, c.want)
})
}
// One under each cap is accepted, so the bound is the boundary and not an accident.
for _, req := range []QueryRequest{
{Query: "q"},
{Query: strings.Repeat("q", MaxQuerySize)},
{Query: "q{a}", TargetChains: make([]uint64, MaxTargetChains)},
} {
_, err := p.Query(req, 1<<62)
require.NoError(t, err)
}
}
// TestQuery_MalformedVariablesRefused proves variables that are not JSON are refused
// instead of being handed to the client as a nil map, which would silently run the query
// with no arguments bound.
func TestQuery_MalformedVariablesRefused(t *testing.T) {
stub := &stubClient{result: []byte(`{}`)}
p := NewGraphQLPrecompile(stub)
_, err := p.Query(QueryRequest{Query: "q{a}", Variables: []byte(`{not json`)}, 1<<62)
require.ErrorIs(t, err, ErrInvalidQuery)
require.Empty(t, stub.calls)
// A JSON scalar is not a variables map either.
_, err = p.Query(QueryRequest{Query: "q{a}", Variables: []byte(`42`)}, 1<<62)
require.ErrorIs(t, err, ErrInvalidQuery)
}
// TestQuery_GasLimitRefusal proves the budget check refuses a request the caller cannot
// afford, and that one gas more is enough.
func TestQuery_GasLimitRefusal(t *testing.T) {
stub := &stubClient{result: []byte(`{}`)}
p := NewGraphQLPrecompile(stub)
req := QueryRequest{Query: "q{a}"}
need := requestGas(req)
_, err := p.Query(req, need-1)
require.ErrorIs(t, err, ErrGasExceeded)
require.Empty(t, stub.calls, "a request refused for gas must not reach the client")
_, err = p.Query(req, need)
require.NoError(t, err)
}
// TestRun_UnknownSelectorRefused proves an unregistered selector is refused rather than
// falling through to a query, and that the refusal still costs the caller.
func TestRun_UnknownSelectorRefused(t *testing.T) {
c := &GraphQLContract{precompile: NewGraphQLPrecompile(&stubClient{result: []byte(`{}`)})}
input := selectorInput(0xdeadbeef, []byte("body"))
_, _, err := c.precompile.Run(input)
require.ErrorContains(t, err, "unknown method")
reserved := c.precompile.RequiredGas(input)
require.Greater(t, reserved, uint64(0))
_, remaining, err := c.Run(nil, common.Address{}, ContractGraphQLAddress, input, 1_000_000, false)
require.Error(t, err)
require.Equal(t, uint64(1_000_000)-reserved, remaining)
}
// TestRun_ShortInputRefused covers calldata too short to carry a selector, at every length
// below four bytes and at the boundary.
func TestRun_ShortInputRefused(t *testing.T) {
p := NewGraphQLPrecompile(&stubClient{result: []byte(`{}`)})
for n := 0; n < 4; n++ {
_, gas, err := p.Run(make([]byte, n))
require.ErrorIs(t, err, ErrInvalidQuery, "length %d", n)
require.Equal(t, GasQueryBase, gas)
require.Equal(t, GasQueryBase, p.RequiredGas(make([]byte, n)))
}
// Exactly four bytes carries a selector and no body: refused as an unknown method,
// not as a short input.
_, _, err := p.Run(make([]byte, 4))
require.ErrorContains(t, err, "unknown method")
}
// TestRun_MalformedQueryBodyRefused proves a body that is not a QueryRequest is refused,
// and that the reservation for it prices the decode attempt rather than a full query.
func TestRun_MalformedQueryBodyRefused(t *testing.T) {
p := NewGraphQLPrecompile(&stubClient{result: []byte(`{}`)})
body := []byte(`{"Query": `)
input := selectorInput(selectorQuery, body)
_, gas, err := p.Run(input)
require.ErrorIs(t, err, ErrInvalidQuery)
want := GasQueryBase + uint64(len(body))*GasPerByte
require.Equal(t, want, gas)
require.Equal(t, want, p.RequiredGas(input),
"an undecodable body reserves only what the decode attempt costs")
require.Less(t, p.RequiredGas(input), maxResponseGas,
"nothing may be reserved for a response that cannot be produced")
}
// TestRun_GraphQLErrorReverts proves a query whose client fails surfaces an error rather
// than empty data. Returning nil bytes with no error would leave a caller unable to tell a
// failure from a genuinely empty result.
func TestRun_GraphQLErrorReverts(t *testing.T) {
stub := &stubClient{errOn: map[uint64]error{7: errClient}}
c := &GraphQLContract{precompile: NewGraphQLPrecompile(stub)}
input := queryInput(t, QueryRequest{Query: "q{a}", TargetChains: []uint64{7}})
out, _, err := c.precompile.Run(input)
require.ErrorContains(t, err, errClient.Error())
require.Nil(t, out)
_, remaining, err := c.Run(nil, common.Address{}, ContractGraphQLAddress, input, 1_000_000, false)
require.Error(t, err)
require.Equal(t, uint64(1_000_000)-c.precompile.RequiredGas(input), remaining)
}
// TestRun_GetStats covers the stats selector and pins the property that matters: the
// counters are always zero, because process-global telemetry diverges between validators
// and this value is consensus output.
func TestRun_GetStats(t *testing.T) {
p := NewGraphQLPrecompile(&stubClient{result: []byte(`{}`)})
input := selectorInput(selectorStats, nil)
out, gas, err := p.Run(input)
require.NoError(t, err)
require.Equal(t, GasQueryBase, gas)
require.Equal(t, GasQueryBase, p.RequiredGas(input))
var stats QueryStats
require.NoError(t, json.Unmarshal(out, &stats))
require.Equal(t, QueryStats{}, stats)
// A trailing body does not change the answer, and running it many times does not make
// a counter move.
for i := 0; i < 10; i++ {
again, _, err := p.Run(selectorInput(selectorStats, []byte("ignored")))
require.NoError(t, err)
require.Equal(t, out, again)
}
}
// =========================================================================
// Predefined templates
// =========================================================================
// TestPredefined_RoundTrip proves the predefined selector expands a template, binds its
// positional arguments to the documented names, and runs the template's query verbatim.
func TestPredefined_RoundTrip(t *testing.T) {
recorder := &recordingClient{result: []byte(`{"balance":"0"}`)}
p := NewGraphQLPrecompile(recorder)
body := append(idBytes(QueryIDBalance), lenPrefixed([]byte("0xabc"))...)
out, gas, err := p.Run(selectorInput(selectorPredefined, body))
require.NoError(t, err)
require.JSONEq(t, `{"balance":"0"}`, string(out))
require.Equal(t, PredefinedQueries[QueryIDBalance].Query, recorder.query)
require.Equal(t, map[string]any{"address": "0xabc"}, recorder.variables)
// The charged figure is the same rule as the plain query path.
expanded, err := expandPredefined(QueryIDBalance, [][]byte{[]byte("0xabc")})
require.NoError(t, err)
require.Equal(t, requestGas(expanded)+uint64(len(out))*GasPerByte, gas)
require.LessOrEqual(t, gas, p.RequiredGas(selectorInput(selectorPredefined, body)))
}
// TestPredefined_Refusals covers every way a predefined call is turned away.
func TestPredefined_Refusals(t *testing.T) {
p := NewGraphQLPrecompile(&stubClient{result: []byte(`{}`)})
cases := []struct {
name string
body []byte
}{
{"no template id", nil},
{"template id one byte short", []byte{0x01}},
{"unknown template id", idBytes(QueryID(0xBEEF))},
{"more args than the template accepts", append(idBytes(QueryIDChainInfo), lenPrefixed([]byte("x"))...)},
{"argument length header truncated", append(idBytes(QueryIDBalance), 0x00, 0x00, 0x00)},
{"argument body truncated", append(idBytes(QueryIDBalance), 0x00, 0x00, 0x00, 0x08, 'a', 'b')},
{"argument length overflows the body", append(idBytes(QueryIDBalance), 0xff, 0xff, 0xff, 0xff)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
input := selectorInput(selectorPredefined, c.body)
_, _, err := p.Run(input)
require.ErrorIs(t, err, ErrInvalidQuery)
require.Less(t, p.RequiredGas(input), maxResponseGas,
"a refused call reserves only its decode")
})
}
}
// TestDecodeArgs_TruncationIsRefusedNotTrimmed is the silent-acceptance regression. The
// parser used to `break` out of the loop on a short tail, so calldata cut mid-argument was
// accepted as a shorter argument list — letting a caller change which variables a query is
// expanded with by trimming bytes rather than by being refused.
func TestDecodeArgs_TruncationIsRefusedNotTrimmed(t *testing.T) {
full := append(lenPrefixed([]byte("first")), lenPrefixed([]byte("second"))...)
args, err := decodeArgs(full)
require.NoError(t, err)
require.Equal(t, [][]byte{[]byte("first"), []byte("second")}, args)
// Every proper prefix of a well-formed encoding is a refusal, never a shorter list.
for n := 1; n < len(full); n++ {
got, err := decodeArgs(full[:n])
if err == nil {
require.Equal(t, full[:n], reencode(got),
"prefix of length %d was accepted as a different argument list", n)
continue
}
require.ErrorIs(t, err, ErrInvalidQuery)
}
require.Empty(t, mustArgs(t, nil), "an empty body is an empty argument list")
require.Empty(t, mustArgs(t, lenPrefixed(nil)), "a zero-length argument is preserved as empty")
}
// TestExpandPredefined_ArgumentBinding proves positional arguments bind to the documented
// names in order and that the encoding is byte-stable.
func TestExpandPredefined_ArgumentBinding(t *testing.T) {
req, err := expandPredefined(QueryIDTokens, [][]byte{[]byte("10"), []byte("volume")})
require.NoError(t, err)
require.Equal(t, PredefinedQueries[QueryIDTokens].Query, req.Query)
var vars map[string]any
require.NoError(t, json.Unmarshal(req.Variables, &vars))
require.Equal(t, map[string]any{"address": "10", "id": "volume"}, vars)
// json.Marshal sorts map keys, so the same arguments encode to identical bytes every
// time — the property that keeps validators in agreement.
for i := 0; i < 50; i++ {
again, err := expandPredefined(QueryIDTokens, [][]byte{[]byte("10"), []byte("volume")})
require.NoError(t, err)
require.Equal(t, req.Variables, again.Variables)
}
}
// TestQueryPredefined_PublicAPI covers the exported wrapper, including its refusals.
func TestQueryPredefined_PublicAPI(t *testing.T) {
p := NewGraphQLPrecompile(&stubClient{result: []byte(`{"ok":1}`)})
resp, err := p.QueryPredefined(QueryIDChainInfo, nil, 1<<62)
require.NoError(t, err)
require.JSONEq(t, `{"ok":1}`, string(resp.Data))
_, err = p.QueryPredefined(QueryID(0xBEEF), nil, 1<<62)
require.ErrorIs(t, err, ErrInvalidQuery)
_, err = p.QueryPredefined(QueryIDChainInfo, [][]byte{[]byte("x")}, 1<<62)
require.ErrorIs(t, err, ErrInvalidQuery)
// The template still has to be affordable.
_, err = p.QueryPredefined(QueryIDChainInfo, nil, 1)
require.ErrorIs(t, err, ErrGasExceeded)
}
// TestPredefinedTemplates_MaxArgsCoversNames proves no template asks for more positional
// arguments than there are names to bind them to — an argument past the end of argNames
// would be silently dropped rather than bound.
func TestPredefinedTemplates_MaxArgsCoversNames(t *testing.T) {
for id, tmpl := range PredefinedQueries {
require.LessOrEqualf(t, tmpl.MaxArgs, len(argNames),
"template 0x%04x accepts %d args but only %d names exist", uint16(id), tmpl.MaxArgs, len(argNames))
require.NotEmptyf(t, tmpl.Query, "template 0x%04x has no query", uint16(id))
require.Equalf(t, id, tmpl.ID, "template 0x%04x is keyed under the wrong ID", uint16(id))
}
}
// =========================================================================
// Config / module wiring
// =========================================================================
// TestConfigure_AppliesOnlyNonZeroFields proves Configure overwrites a field only when the
// upgrade config actually sets it, so a partial config cannot blank the defaults.
func TestConfigure_AppliesOnlyNonZeroFields(t *testing.T) {
c := &configurator{}
require.IsType(t, &GraphConfig{}, c.MakeConfig())
orig := *GraphContractInstance.precompile.config
defer func() { *GraphContractInstance.precompile.config = orig }()
require.NoError(t, c.Configure(nil, &GraphConfig{}, nil, nil))
require.Equal(t, orig, *GraphContractInstance.precompile.config,
"an empty config leaves every default in place")
require.NoError(t, c.Configure(nil, &GraphConfig{
GChainEndpoint: "http://gchain:9650",
QueryTimeout: 30,
MaxCacheSize: 77,
}, nil, nil))
got := GraphContractInstance.precompile.GetConfig()
require.Equal(t, "http://gchain:9650", got.GChainEndpoint)
require.Equal(t, 30*time.Second, got.QueryTimeout)
require.Equal(t, 77, got.MaxCacheSize)
}
// TestConfigure_WrongTypeRefused proves a config of the wrong type is refused rather than
// silently ignored.
func TestConfigure_WrongTypeRefused(t *testing.T) {
err := (&configurator{}).Configure(nil, wrongConfig{}, nil, nil)
require.ErrorContains(t, err, "expected config type")
}
type wrongConfig struct{ precompileconfig.Config }
// TestGraphConfig_EqualIsSymmetricPerField proves Equal actually compares every field: a
// difference in any one of them must break equality.
func TestGraphConfig_EqualIsSymmetricPerField(t *testing.T) {
base := GraphConfig{GChainEndpoint: "e", QueryTimeout: 1, MaxCacheSize: 2}
require.True(t, base.Equal(&GraphConfig{GChainEndpoint: "e", QueryTimeout: 1, MaxCacheSize: 2}))
for name, mutate := range map[string]func(*GraphConfig){
"endpoint": func(c *GraphConfig) { c.GChainEndpoint = "other" },
"timeout": func(c *GraphConfig) { c.QueryTimeout = 99 },
"cache": func(c *GraphConfig) { c.MaxCacheSize = 99 },
"upgrade": func(c *GraphConfig) { c.Upgrade.Disable = true },
} {
t.Run(name, func(t *testing.T) {
other := base
mutate(&other)
require.False(t, base.Equal(&other))
require.False(t, other.Equal(&base))
})
}
}
// =========================================================================
// Response parsing helpers
// =========================================================================
// TestParsePriceResponse proves a well-formed price response is parsed, and — the
// regression — that a response omitting a price field is refused instead of panicking.
// big.Float.SetString returns nil on failure and multiplying through it dereferences nil.
func TestParsePriceResponse(t *testing.T) {
good := []byte(`{"data":{"token":{"derivedETH":"2","symbol":"WBTC","decimals":8},"bundle":{"ethPriceUSD":"3000"}}}`)
got, err := ParsePriceResponse(good)
require.NoError(t, err)
require.Equal(t, "WBTC", got.Symbol)
require.Equal(t, "pool", got.Source)
// 2 * 3000, scaled to 18 decimals.
want := new(big.Int).Mul(big.NewInt(6000), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil))
require.Equal(t, want, got.PriceUSD)
for _, bad := range []string{
`{}`,
`{"data":{}}`,
`{"data":{"token":{"derivedETH":"2"}}}`,
`{"data":{"bundle":{"ethPriceUSD":"3000"}}}`,
`{"data":{"token":{"derivedETH":"not a number"},"bundle":{"ethPriceUSD":"1"}}}`,
} {
require.NotPanics(t, func() {
_, err := ParsePriceResponse([]byte(bad))
require.ErrorIs(t, err, ErrInvalidQuery, "input %s", bad)
}, "input %s", bad)
}
_, err = ParsePriceResponse([]byte(`not json`))
require.Error(t, err)
}
// TestParsePoolInfoResponse covers the pool parser, including the fields it narrows.
func TestParsePoolInfoResponse(t *testing.T) {
good := []byte(`{"data":{"pool":{"id":"0xpool","token0":{"Symbol":"A"},"token1":{"Symbol":"B"},` +
`"feeTier":3000,"liquidity":"12345","sqrtPrice":"999","tick":-42}}}`)
got, err := ParsePoolInfoResponse(good)
require.NoError(t, err)
require.Equal(t, "0xpool", got.PoolAddress)
require.Equal(t, "A", got.Token0Symbol)
require.Equal(t, "B", got.Token1Symbol)
require.Equal(t, uint32(3000), got.FeeTier)
require.Equal(t, big.NewInt(12345), got.Liquidity)
require.Equal(t, big.NewInt(999), got.SqrtPriceX96)
require.Equal(t, int32(-42), got.Tick)
// Absent numeric fields decode to nil rather than panicking.
require.NotPanics(t, func() {
empty, err := ParsePoolInfoResponse([]byte(`{}`))
require.NoError(t, err)
require.Nil(t, empty.Liquidity)
})
_, err = ParsePoolInfoResponse([]byte(`not json`))
require.Error(t, err)
}
// TestOracleAndAMMQueriesRegistered proves the init in oracle.go folds both catalogues into
// PredefinedQueries, so every documented ID is reachable through the predefined selector.
func TestOracleAndAMMQueriesRegistered(t *testing.T) {
for id := range OracleQueries {
require.Containsf(t, PredefinedQueries, id, "oracle query 0x%04x not registered", uint16(id))
}
for id := range AMMQueries {
require.Containsf(t, PredefinedQueries, id, "AMM query 0x%04x not registered", uint16(id))
}
p := NewGraphQLPrecompile(&stubClient{result: []byte(`{"ok":1}`)})
for id := range PredefinedQueries {
_, err := p.QueryPredefined(id, nil, 1<<62)
require.NoErrorf(t, err, "template 0x%04x is registered but not runnable", uint16(id))
}
}
// =========================================================================
// helpers
// =========================================================================
var errClient = errStr("client refused")
type errStr string
func (e errStr) Error() string { return string(e) }
func idBytes(id QueryID) []byte {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, uint16(id))
return b
}
func lenPrefixed(arg []byte) []byte {
b := make([]byte, 4+len(arg))
binary.BigEndian.PutUint32(b[:4], uint32(len(arg)))
copy(b[4:], arg)
return b
}
func reencode(args [][]byte) []byte {
var out []byte
for _, a := range args {
out = append(out, lenPrefixed(a)...)
}
return out
}
func mustArgs(t *testing.T, body []byte) [][]byte {
t.Helper()
args, err := decodeArgs(body)
require.NoError(t, err)
if len(args) == 1 {
require.Empty(t, args[0])
return nil
}
return args
}
// recordingClient captures what the precompile actually asked the client for.
type recordingClient struct {
query string
variables map[string]any
result []byte
}
func (c *recordingClient) Query(_ context.Context, query string, variables map[string]any) ([]byte, error) {
c.query, c.variables = query, variables
return c.result, nil
}
func (c *recordingClient) QueryChain(ctx context.Context, _ uint64, query string, variables map[string]any) ([]byte, error) {
return c.Query(ctx, query, variables)
}
// TestGraphVMClient_UnmarshalableResultRefused proves a resolver result that cannot be
// encoded is reported as an error rather than returned as empty data.
func TestGraphVMClient_UnmarshalableResultRefused(t *testing.T) {
db := memdb.New()
defer db.Close()
client := NewGraphVMClient(db, nil)
client.RegisterResolver("alpha", func(context.Context, database.Database, map[string]any) (any, error) {
return make(chan int), nil // channels have no JSON encoding
})
out, err := client.Query(context.Background(), `query { alpha }`, nil)
require.ErrorContains(t, err, "failed to marshal response")
require.Nil(t, out)
}
// TestGraphVMClient_ChainRouting covers the routing rule: the local chain and chain 0 are
// served, anything else is refused rather than silently answered with local data.
func TestGraphVMClient_ChainRouting(t *testing.T) {
db := memdb.New()
defer db.Close()
client := NewGraphVMClientWithChainID(db, nil, ChainIDLuxMainnet)
require.Equal(t, ChainIDLuxMainnet, client.ChainID())
require.Equal(t, db, client.GetDB())
for _, id := range []uint64{0, ChainIDLuxMainnet} {
out, err := client.QueryChain(context.Background(), id, `query { chainInfo { vmName } }`, nil)
require.NoError(t, err)
require.Contains(t, string(out), "graphvm")
}
_, err := client.QueryChain(context.Background(), ChainIDZooMainnet, `query { chainInfo }`, nil)
require.ErrorContains(t, err, "cross-chain queries not yet supported")
client.SetChainID(ChainIDZooMainnet)
require.Equal(t, ChainIDZooMainnet, client.ChainID())
_, err = client.QueryChain(context.Background(), ChainIDZooMainnet, `query { chainInfo }`, nil)
require.NoError(t, err)
}
// TestGraphVMClient_UninitialisedFailsClosed proves a client built without a database
// refuses every call instead of dereferencing a nil executor.
func TestGraphVMClient_UninitialisedFailsClosed(t *testing.T) {
client := NewGraphVMClient(nil, nil)
require.Nil(t, client.GetDB())
require.NotPanics(t, func() { client.RegisterResolver("alpha", nil) })
_, err := client.Query(context.Background(), `query { chainInfo }`, nil)
require.ErrorContains(t, err, "not initialized")
_, err = client.QueryChain(context.Background(), 0, `query { chainInfo }`, nil)
require.ErrorContains(t, err, "not initialized")
}
+29 -21
View File
@@ -7,6 +7,9 @@ package graphvm
import (
"context"
"maps"
"slices"
"strings"
"github.com/luxfi/database"
)
@@ -75,10 +78,13 @@ func (e *QueryExecutor) Execute(ctx context.Context, req *GraphQLRequest) *Graph
query := req.Query
data := make(map[string]any)
// Check for custom resolvers first
for name, resolver := range e.resolvers {
// Check for custom resolvers first, in name order. The first match wins and returns,
// so ranging the map directly made the winner depend on Go's randomised map iteration:
// two registered resolvers both matching one query would resolve differently on
// different validators, and this result is consensus output.
for _, name := range slices.Sorted(maps.Keys(e.resolvers)) {
if containsField(query, name) {
result, err := resolver(ctx, e.db, req.Variables)
result, err := e.resolvers[name](ctx, e.db, req.Variables)
if err != nil {
return &GraphQLResponse{
Errors: []GraphQLError{{Message: err.Error()}},
@@ -124,26 +130,28 @@ func (e *QueryExecutor) Execute(ctx context.Context, req *GraphQLRequest) *Graph
}
}
// containsField checks if a GraphQL query contains a specific field
// fieldDelimiters are the characters that may follow a field name in a query. Requiring one
// is what stops "chain" matching the field "chainInfo".
const fieldDelimiters = " {}()\n\t"
// containsField reports whether a GraphQL query names a field. It is a substring match, not
// a parse: the field must appear followed by a delimiter.
func containsField(query, field string) bool {
// Simple string match - real implementation would parse AST
return len(query) > 0 && len(field) > 0 &&
(contains(query, field+" ") || contains(query, field+"{") ||
contains(query, field+"(") || contains(query, field+"}") ||
contains(query, field+"\n") || contains(query, field+"\t"))
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && findSubstring(s, substr) >= 0
}
func findSubstring(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
if query == "" || field == "" {
return false
}
return -1
for i := strings.Index(query, field); i >= 0; {
rest := query[i+len(field):]
if rest != "" && strings.ContainsAny(rest[:1], fieldDelimiters) {
return true
}
next := strings.Index(query[i+1:], field)
if next < 0 {
return false
}
i += 1 + next
}
return false
}
// GetDB returns the underlying database
+255
View File
@@ -0,0 +1,255 @@
// Copyright (C) 2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package graphvm
import (
"context"
"errors"
"testing"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/stretchr/testify/require"
)
func newExecutor(t *testing.T) *QueryExecutor {
t.Helper()
db := memdb.New()
t.Cleanup(func() { _ = db.Close() })
return NewQueryExecutor(db, &GConfig{MaxQueryDepth: 10, MaxResultSize: 1 << 20})
}
// TestExecute_ResolverOrderIsDeterministic is the consensus-fork regression. Execute
// returns on the FIRST resolver whose name the query mentions; it used to pick that
// resolver by ranging the resolver map, and Go randomises map iteration. Two registered
// resolvers that both match one query therefore resolved differently on different
// validators — and this value is consensus output.
//
// The assertion is not "some resolver wins" but "the same one wins every time", which is
// the only property that keeps validators in agreement.
func TestExecute_ResolverOrderIsDeterministic(t *testing.T) {
const query = `query { alpha beta gamma delta epsilon }`
first := ""
for run := 0; run < 200; run++ {
e := newExecutor(t)
// Registration order is varied so a map that happened to be insertion-ordered
// would not hide the defect either.
names := []string{"alpha", "beta", "gamma", "delta", "epsilon"}
if run%2 == 1 {
names = []string{"epsilon", "delta", "gamma", "beta", "alpha"}
}
for _, n := range names {
e.RegisterResolver(n, constResolver(n))
}
resp := e.Execute(context.Background(), &GraphQLRequest{Query: query})
require.Empty(t, resp.Errors)
data, ok := resp.Data.(map[string]any)
require.True(t, ok)
require.Len(t, data, 1, "the first matching resolver wins and returns immediately")
var winner string
for k := range data {
winner = k
}
if run == 0 {
first = winner
}
require.Equal(t, first, winner,
"the winning resolver must not depend on map iteration order")
}
require.Equal(t, "alpha", first, "ties resolve in name order")
}
func constResolver(v string) ResolverFunc {
return func(context.Context, database.Database, map[string]any) (any, error) {
return v, nil
}
}
// TestExecute_ResolverErrorSurfaces proves a resolver failure becomes a GraphQL error and
// does not leak a half-built data map.
func TestExecute_ResolverErrorSurfaces(t *testing.T) {
e := newExecutor(t)
boom := errors.New("resolver exploded")
e.RegisterResolver("alpha", func(context.Context, database.Database, map[string]any) (any, error) {
return nil, boom
})
resp := e.Execute(context.Background(), &GraphQLRequest{Query: `query { alpha }`})
require.Len(t, resp.Errors, 1)
require.Equal(t, boom.Error(), resp.Errors[0].Message)
require.Nil(t, resp.Data)
}
// TestExecute_ResolverReceivesVariablesAndDB proves the resolver is handed the executor's
// own database and the request's variables, not zero values.
func TestExecute_ResolverReceivesVariablesAndDB(t *testing.T) {
db := memdb.New()
defer db.Close()
e := NewQueryExecutor(db, nil)
var gotDB database.Database
var gotVars map[string]any
e.RegisterResolver("alpha", func(_ context.Context, d database.Database, v map[string]any) (any, error) {
gotDB, gotVars = d, v
return "ok", nil
})
vars := map[string]any{"id": "42"}
resp := e.Execute(context.Background(), &GraphQLRequest{Query: `query { alpha }`, Variables: vars})
require.Empty(t, resp.Errors)
require.Equal(t, db, gotDB)
require.Equal(t, vars, gotVars)
}
// TestExecute_UninitialisedFailsClosed proves neither a nil executor nor one without a
// database panics on the dispatch path — a panic there is unrecovered and halts the node.
func TestExecute_UninitialisedFailsClosed(t *testing.T) {
for name, e := range map[string]*QueryExecutor{
"nil executor": nil,
"nil database": NewQueryExecutor(nil, nil),
} {
t.Run(name, func(t *testing.T) {
var resp *GraphQLResponse
require.NotPanics(t, func() {
resp = e.Execute(context.Background(), &GraphQLRequest{Query: `query { chainInfo }`})
})
require.Len(t, resp.Errors, 1)
require.Equal(t, "executor not initialized", resp.Errors[0].Message)
require.Nil(t, resp.Data)
})
}
}
// TestExecute_BuiltinFields covers every built-in the executor answers, and proves an
// unknown field is refused rather than silently returning an empty result set.
func TestExecute_BuiltinFields(t *testing.T) {
e := newExecutor(t)
t.Run("chainInfo", func(t *testing.T) {
resp := e.Execute(context.Background(), &GraphQLRequest{Query: `query { chainInfo { vmName } }`})
require.Empty(t, resp.Errors)
info := resp.Data.(map[string]any)["chainInfo"].(map[string]any)
require.Equal(t, "graphvm", info["vmName"])
require.Equal(t, "1.0.0", info["version"])
require.Equal(t, true, info["readOnly"])
})
t.Run("balance", func(t *testing.T) {
resp := e.Execute(context.Background(), &GraphQLRequest{Query: `query { balance(address: "0x1") }`})
require.Empty(t, resp.Errors)
require.Equal(t, "0", resp.Data.(map[string]any)["balance"])
})
t.Run("chains", func(t *testing.T) {
resp := e.Execute(context.Background(), &GraphQLRequest{Query: `query { chains { id } }`})
require.Empty(t, resp.Errors)
require.Len(t, resp.Data.(map[string]any)["chains"], 1)
})
t.Run("several at once", func(t *testing.T) {
resp := e.Execute(context.Background(), &GraphQLRequest{Query: `query { chainInfo { vmName } balance chains }`})
require.Empty(t, resp.Errors)
require.Len(t, resp.Data.(map[string]any), 3)
})
t.Run("unknown field refused", func(t *testing.T) {
resp := e.Execute(context.Background(), &GraphQLRequest{Query: `query { nonsense }`})
require.Len(t, resp.Errors, 1)
require.Equal(t, "unknown field in query", resp.Errors[0].Message)
require.Nil(t, resp.Data)
})
t.Run("empty query refused", func(t *testing.T) {
resp := e.Execute(context.Background(), &GraphQLRequest{Query: ""})
require.Len(t, resp.Errors, 1)
})
}
// TestExecute_IsDeterministic proves repeated execution of one query yields an identical
// result — no clock, no counter, no iteration-order dependence anywhere in the path.
func TestExecute_IsDeterministic(t *testing.T) {
e := newExecutor(t)
req := &GraphQLRequest{Query: `query { chainInfo { vmName } balance chains }`}
golden := e.Execute(context.Background(), req)
for i := 0; i < 100; i++ {
require.Equal(t, golden, e.Execute(context.Background(), req))
}
}
// TestContainsField pins the matching rule, including the near-miss that a bare substring
// check would get wrong: a field name is only matched when a delimiter follows it, so
// "chain" must not match the field "chainInfo".
func TestContainsField(t *testing.T) {
cases := []struct {
query, field string
want bool
}{
{`query { chainInfo { vmName } }`, "chainInfo", true},
{`query { balance }`, "balance", true},
{`query{balance}`, "balance", true},
{"query {\n\tbalance\n}", "balance", true},
{`query { balance(address: "x") }`, "balance", true},
{`query { chainInfo }`, "chain", false}, // prefix of a longer field
{`query { chains }`, "chainInfo", false},
{`query { balance }`, "", false},
{``, "balance", false},
{`balance`, "balance", false}, // no delimiter follows: end of string
{`xbalance{}`, "balance", true},
// The first occurrence has no delimiter after it; a later one does. A scan that
// gave up on the first hit would answer false.
{`balanceX and balance }`, "balance", true},
}
for _, c := range cases {
require.Equalf(t, c.want, containsField(c.query, c.field),
"containsField(%q, %q)", c.query, c.field)
}
}
// TestGetDB proves the accessor reports the executor's database and tolerates a nil
// receiver.
func TestGetDB(t *testing.T) {
db := memdb.New()
defer db.Close()
require.Equal(t, db, NewQueryExecutor(db, nil).GetDB())
require.Nil(t, NewQueryExecutor(nil, nil).GetDB())
var nilExec *QueryExecutor
require.Nil(t, nilExec.GetDB())
}
// TestRegisterResolver proves registration replaces by name and tolerates a nil receiver.
func TestRegisterResolver(t *testing.T) {
e := newExecutor(t)
e.RegisterResolver("alpha", constResolver("first"))
e.RegisterResolver("alpha", constResolver("second"))
resp := e.Execute(context.Background(), &GraphQLRequest{Query: `query { alpha }`})
require.Equal(t, "second", resp.Data.(map[string]any)["alpha"])
var nilExec *QueryExecutor
require.NotPanics(t, func() { nilExec.RegisterResolver("alpha", constResolver("x")) })
// A zero-value executor has no resolver map; registering must not panic or allocate
// one behind the caller's back.
bare := &QueryExecutor{}
require.NotPanics(t, func() { bare.RegisterResolver("alpha", constResolver("x")) })
require.Nil(t, bare.resolvers)
}
// TestNewQueryExecutor proves the constructor wires exactly what it is given.
func TestNewQueryExecutor(t *testing.T) {
db := memdb.New()
defer db.Close()
cfg := &GConfig{MaxQueryDepth: 3, MaxCacheSize: 7, QueryTimeout: 11, MaxResultSize: 13, QueryTimeoutMs: 17}
e := NewQueryExecutor(db, cfg)
require.Equal(t, db, e.db)
require.Same(t, cfg, e.config)
require.NotNil(t, e.resolvers)
require.Empty(t, e.resolvers)
}
+4 -6
View File
@@ -62,13 +62,11 @@ func (c *GraphVMClient) Query(ctx context.Context, query string, variables map[s
// Check for errors
if len(resp.Errors) > 0 {
// Return first error as the main error
var errMsg strings.Builder
errMsg.WriteString(resp.Errors[0].Message)
for i := 1; i < len(resp.Errors); i++ {
errMsg.WriteString("; " + resp.Errors[i].Message)
msgs := make([]string, len(resp.Errors))
for i, e := range resp.Errors {
msgs[i] = e.Message
}
return nil, fmt.Errorf("GraphQL error: %s", errMsg.String())
return nil, fmt.Errorf("GraphQL error: %s", strings.Join(msgs, "; "))
}
// Marshal the data response to JSON
+22 -7
View File
@@ -134,7 +134,13 @@ func (c *GraphConfig) Verify(chainConfig precompileconfig.ChainConfig) error {
return nil
}
// Run implements StatefulPrecompiledContract
// Run implements StatefulPrecompiledContract.
//
// Gas is reserved before any work and refunded down to the actual figure afterwards. The
// reservation covers the largest response the precompile may return, so a query never
// executes unless its caller has already paid for the worst case — the alternative,
// charging for the response after producing it, would have the node perform caller-sized
// work before discovering it cannot be paid for.
func (c *GraphQLContract) Run(
accessibleState contract.AccessibleState,
caller common.Address,
@@ -143,15 +149,24 @@ func (c *GraphQLContract) Run(
suppliedGas uint64,
readOnly bool,
) (ret []byte, remainingGas uint64, err error) {
requiredGas := c.precompile.RequiredGas(input)
if suppliedGas < requiredGas {
return nil, 0, fmt.Errorf("out of gas: required %d, supplied %d", requiredGas, suppliedGas)
reserved := c.precompile.RequiredGas(input)
if suppliedGas < reserved {
return nil, 0, fmt.Errorf("out of gas: required %d, supplied %d", reserved, suppliedGas)
}
result, runErr := c.precompile.Run(input)
result, used, runErr := c.precompile.Run(input)
if runErr != nil {
return nil, suppliedGas - requiredGas, runErr
// A failed run consumes the whole reservation. How much work it managed before
// failing is not measurable — an oversized response is rejected only after it has
// been produced — so the caller pays the bound it agreed to, which is by
// construction at least the work performed. Refunding here would make a malformed
// request a cheap way to probe the precompile.
return nil, suppliedGas - reserved, runErr
}
return result, suppliedGas - requiredGas, nil
// min keeps the subtraction total. RequiredGas and Run price the same decoded request
// with the same functions, and the response term is bounded by the allowance the
// reservation already holds, so used <= reserved by construction —
// TestGraphQL_UpfrontBoundCoversActual asserts it directly rather than relying on it.
return result, suppliedGas - min(used, reserved), nil
}
+12 -3
View File
@@ -247,9 +247,18 @@ func ParsePriceResponse(data []byte) (*PriceResponse, error) {
return nil, err
}
// Calculate USD price from derivedETH * ethPriceUSD
derivedETH, _ := new(big.Float).SetString(resp.Data.Token.DerivedETH)
ethPrice, _ := new(big.Float).SetString(resp.Data.Bundle.EthPriceUSD)
// Calculate USD price from derivedETH * ethPriceUSD. A missing or malformed number
// makes SetString return nil, and multiplying through a nil *big.Float panics — so a
// response that simply omits a price field must be refused here rather than crashing
// the caller.
derivedETH, ok := new(big.Float).SetString(resp.Data.Token.DerivedETH)
if !ok {
return nil, ErrInvalidQuery
}
ethPrice, ok := new(big.Float).SetString(resp.Data.Bundle.EthPriceUSD)
if !ok {
return nil, ErrInvalidQuery
}
priceFloat := new(big.Float).Mul(derivedETH, ethPrice)
+15 -51
View File
@@ -10,75 +10,39 @@ import (
"errors"
)
// Precompile addresses for G-Chain components
// Reserved range: 0x0500 - 0x050F
// NOTE: Core addresses are also defined in registry.go for RegistryMap
const (
// G-Chain GraphQL precompile (alias for GraphQLQueryAddress in registry.go)
GraphQLAddress = "0x0500" // Main GraphQL query interface
// Future extensions
GraphIndexAddress = "0x0503" // Index management (future)
)
// Gas costs for GraphQL operations
// Gas costs for GraphQL operations.
//
// The address of this precompile has exactly one spelling: ContractGraphQLAddress in
// module.go, which is what the module registers.
const (
// Query operations
GasQueryBase uint64 = 5_000 // Base cost for any query
GasQuerySimple uint64 = 10_000 // Simple single-entity query
GasQueryComplex uint64 = 25_000 // Complex multi-entity query
GasQueryCrossChain uint64 = 50_000 // Cross-chain aggregation query
GasQueryCrossChain uint64 = 50_000 // Cross-chain aggregation query, per target chain
// Per-result costs
GasPerEntity uint64 = 1_000 // Per entity in result
GasPerField uint64 = 100 // Per field returned
GasPerByte uint64 = 3 // Per byte of response data
// Mutation operations (admin only)
GasMutationBase uint64 = 100_000 // Base cost for mutations
)
// Query types supported by the precompile
type QueryType uint8
const (
QueryTypeChainInfo QueryType = iota
QueryTypeBlock
QueryTypeAccount
QueryTypeBalance
QueryTypeFactory
QueryTypeBundle
QueryTypeToken
QueryTypeTokens
QueryTypePool
QueryTypePools
QueryTypePair
QueryTypePairs
QueryTypeTicks
QueryTypeSwaps
QueryTypePositions
QueryTypeTimeSeries
QueryTypeCustom
// GasPerByte prices every caller-supplied and every returned byte.
GasPerByte uint64 = 3
)
// Errors
var (
ErrInvalidQuery = errors.New("invalid GraphQL query")
ErrQueryTooLarge = errors.New("query exceeds maximum size")
ErrQueryTimeout = errors.New("query execution timeout")
ErrChainNotFound = errors.New("chain not found")
ErrUnauthorized = errors.New("unauthorized mutation")
ErrGasExceeded = errors.New("gas limit exceeded for query")
ErrInvalidResponse = errors.New("invalid response format")
ErrClientNotInitialized = errors.New("graph client not initialized")
)
// Maximum limits
const (
MaxQuerySize = 4096 // Max query string size in bytes
MaxResponseSize = 65536 // Max response size in bytes (64KB)
MaxEntitiesPerQuery = 1000 // Max entities in a single query
MaxQueryDepth = 10 // Max nesting depth
MaxQuerySize = 4096 // Max query string size in bytes
MaxResponseSize = 65536 // Max response size in bytes (64KB)
// MaxTargetChains bounds the multi-chain fan-out. TargetChains is decoded straight
// out of caller-supplied JSON and drives one client.QueryChain call per entry, so
// without a cap the loop count — and the gas arithmetic derived from it — are both
// caller-controlled and unbounded.
MaxTargetChains = 16
)
// ChainID constants for cross-chain queries
+121
View File
@@ -0,0 +1,121 @@
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// kernel_test.go — the integer kernels' edges: the two guards that stop a degenerate
// input from dividing by zero (which on the consensus path panics a validator), the
// causal mask itself, and the saturation that keeps requantisation from wrapping.
package inference
import (
"math"
"testing"
"github.com/stretchr/testify/require"
)
func TestMaxu32(t *testing.T) {
for _, c := range [][2]uint32{{0, 0}, {3, 7}, {7, 3}, {5, 5}, {0, math.MaxUint32}, {math.MaxUint32, 0}} {
a, b := c[0], c[1]
got := maxu32(a, b)
require.GreaterOrEqual(t, got, a)
require.GreaterOrEqual(t, got, b)
require.True(t, got == a || got == b, "must return one of its arguments")
require.Equal(t, got, maxu32(b, a), "must be symmetric")
}
}
// TestRmsnormZeroRow: with eps 0 an all-zero activation row drives the RMS denominator
// to zero. The kernel must clamp it — an integer divide by zero panics, and a panic
// inside a precompile takes the validator down.
func TestRmsnormZeroRow(t *testing.T) {
const d = 4
out := make([]int8, d)
require.NotPanics(t, func() {
rmsnormI8(make([]int8, d), []int8{1, 1, 1, 1}, out, d, 5, 0)
})
require.Equal(t, make([]int8, d), out, "a zero row normalises to zeros")
}
// TestRmsnormDividesByTheRootMeanSquare pins the arithmetic itself: ss = 4·80² = 25600
// and isqrt(25600) = 160, so each output is (x·w·mult)/160.
func TestRmsnormDividesByTheRootMeanSquare(t *testing.T) {
const d = 4
x := []int8{80, 80, 80, 80}
out := make([]int8, d)
rmsnormI8(x, []int8{1, 1, 1, 1}, out, d, 8, 0)
require.Equal(t, []int8{4, 4, 4, 4}, out, "80·1·8/160")
rmsnormI8(x, []int8{2, 2, 2, 2}, out, d, 8, 0)
require.Equal(t, []int8{8, 8, 8, 8}, out, "doubling the weight doubles the output; the divisor is unchanged")
rmsnormI8(x, []int8{127, 127, 127, 127}, out, d, 8, 0)
require.Equal(t, []int8{127, 127, 127, 127}, out, "an over-range result saturates, it must never wrap")
// eps only enlarges the divisor, so raising it can only shrink the result.
prev := int8(math.MaxInt8)
for _, eps := range []int32{0, 39936, 1_000_000} {
rmsnormI8(x, []int8{1, 1, 1, 1}, out, d, 8, eps)
require.LessOrEqual(t, out[0], prev, "a larger eps must not enlarge the result")
prev = out[0]
}
require.Zero(t, prev, "a large enough eps drives the row to zero")
}
// TestAttentionAtLengthOne: with a single token there is exactly one key, so the
// softmax weight is 1 whatever the scores are. The output must therefore depend on v
// alone, and the causal flag must make no difference.
func TestAttentionAtLengthOne(t *testing.T) {
const seq, d = 1, 2
v := []int8{100, -100}
zeroScores := make([]int8, d)
loudScores := []int8{120, -7}
a := make([]int8, d)
b := make([]int8, d)
attentionI8(zeroScores, zeroScores, v, aivmExpLUT[:], a, seq, d, 9, 7, true)
attentionI8(loudScores, []int8{-60, 33}, v, aivmExpLUT[:], b, seq, d, 9, 7, true)
require.Equal(t, a, b, "at length one the scores cannot matter")
open := make([]int8, d)
attentionI8(zeroScores, zeroScores, v, aivmExpLUT[:], open, seq, d, 9, 7, false)
require.Equal(t, a, open, "at length one the mask has nothing to mask")
require.NotEqual(t, make([]int8, d), a, "the value vector must actually reach the output")
}
// TestAttentionMaskHidesTheFuture: under the causal mask row 0 is computed from token 0
// alone, so rewriting token 1's value must leave it untouched — and must not, with the
// mask off. Scores are all equal (q and k are zero) so any leak shows up in the output.
func TestAttentionMaskHidesTheFuture(t *testing.T) {
const seq, d = 2, 2
q := make([]int8, seq*d)
k := make([]int8, seq*d)
v := []int8{100, 100, -100, -100}
vFuture := []int8{100, 100, 7, 55} // same first row, different second
masked := make([]int8, seq*d)
maskedFuture := make([]int8, seq*d)
attentionI8(q, k, v, aivmExpLUT[:], masked, seq, d, 9, 7, true)
attentionI8(q, k, vFuture, aivmExpLUT[:], maskedFuture, seq, d, 9, 7, true)
require.Equal(t, masked[:d], maskedFuture[:d], "row 0 must not see row 1")
require.NotEqual(t, masked[d:], maskedFuture[d:], "row 1 must see row 1")
open := make([]int8, seq*d)
attentionI8(q, k, v, aivmExpLUT[:], open, seq, d, 9, 7, false)
require.NotEqual(t, masked[:d], open[:d], "with the mask off row 0 must see row 1")
}
// TestAttentionZeroTable: a probability table of all zeros drives the softmax
// denominator to zero. The kernel must clamp it rather than divide by zero.
func TestAttentionZeroTable(t *testing.T) {
const seq, d = 2, 2
out := make([]int8, seq*d)
require.NotPanics(t, func() {
attentionI8(make([]int8, seq*d), make([]int8, seq*d), []int8{100, 100, -100, -100},
make([]int32, 256), out, seq, d, 9, 7, true)
})
require.Equal(t, make([]int8, seq*d), out, "no probability mass, no output")
}
+358
View File
@@ -0,0 +1,358 @@
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// module_config_test.go — the 0x0303 module surface: registration, the upgrade Config,
// the gas schedule, and every refusal Run can issue. These assert relationships and
// refusals, never the constants themselves: a test that restates GasBaseInference
// proves nothing, while "one more token costs more" and "the clamped price always
// reverts" are properties a broken implementation cannot satisfy.
package inference
import (
"encoding/binary"
"math"
"reflect"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
"github.com/stretchr/testify/require"
)
// surplus is handed over and above RequiredGas so a refusal is never an out-of-gas
// refusal, and so the gas actually charged is observable in the return value.
const surplus uint64 = 4321
// call runs the precompile with exactly RequiredGas+surplus.
func call(input []byte) ([]byte, uint64, error) {
gas := InferencePrecompile.RequiredGas(input) + surplus
return InferencePrecompile.Run(nil, common.Address{}, common.Address{}, input, gas, false)
}
// refuse asserts the precompile rejects input, produces nothing, and still charges
// the full reserved price — otherwise the ABI could be probed for free.
func refuse(t *testing.T, input []byte, want string) {
t.Helper()
ret, rem, err := call(input)
require.ErrorContains(t, err, want)
require.Nil(t, ret, "a refusal must produce no output")
require.Equal(t, surplus, rem, "a refusal must still charge exactly RequiredGas")
}
func decode(b []byte) []uint32 {
out := make([]uint32, len(b)/4)
for i := range out {
out[i] = binary.BigEndian.Uint32(b[i*4:])
}
return out
}
func gasFor(nNew uint32, prompt []uint32) uint64 {
return InferencePrecompile.RequiredGas(encodeGenerate(nNew, prompt))
}
func tokens(n int, vocab uint32) []uint32 {
p := make([]uint32, n)
for i := range p {
p[i] = uint32(i) % vocab
}
return p
}
// ---- Run: every refusal ------------------------------------------------------------
func TestRunRefusals(t *testing.T) {
vocab := fixtureModel().Cfg.Vocab
selectorOnly := make([]byte, 4)
binary.BigEndian.PutUint32(selectorOnly, SelectorGenerate)
for _, c := range []struct {
name string
input []byte
want string
}{
{"no input at all", nil, "input too short"},
{"one byte short of a selector", []byte{0, 0, 0}, "input too short"},
{"unknown selector", []byte{0xde, 0xad, 0xbe, 0xef}, "unknown selector"},
{"selector with nothing after it", selectorOnly, "input too short for generate"},
{"token count but no prompt", encodeGenerate(1, nil), "input too short for generate"},
{"one byte short of a token", append(encodeGenerate(1, nil), 0, 0, 0), "input too short for generate"},
{"prompt is one byte past a token", append(encodeGenerate(0, []uint32{1}), 0), "not a multiple of 4"},
{"prompt is half a token past a token", append(encodeGenerate(0, []uint32{1}), 0, 0), "not a multiple of 4"},
{"prompt is three bytes past a token", append(encodeGenerate(0, []uint32{1}), 0, 0, 0), "not a multiple of 4"},
{"one token past the generation limit", encodeGenerate(MaxNewTokens+1, []uint32{1}), "nNew 65 exceeds max"},
{"generation limit at the uint32 ceiling", encodeGenerate(math.MaxUint32, []uint32{1}), "nNew 4294967295 exceeds max"},
{"one token past the sequence limit", encodeGenerate(0, tokens(MaxSeqLen+1, vocab)), "sequence 257 exceeds max"},
{"prompt plus generation past the sequence limit", encodeGenerate(MaxNewTokens, tokens(MaxSeqLen-int(MaxNewTokens)+1, vocab)), "sequence 257 exceeds max"},
{"token id at the vocab ceiling", encodeGenerate(0, []uint32{1, vocab}), "token 48 out of vocab"},
{"token id far past the vocab", encodeGenerate(0, []uint32{math.MaxUint32}), "token 4294967295 out of vocab"},
} {
t.Run(c.name, func(t *testing.T) { refuse(t, c.input, c.want) })
}
}
// TestRunAcceptsTheLastLegalToken pins the other side of the vocab bound: the check is
// >= vocab, so vocab-1 must still decode.
func TestRunAcceptsTheLastLegalToken(t *testing.T) {
vocab := fixtureModel().Cfg.Vocab
ret, _, err := call(encodeGenerate(0, []uint32{vocab - 1}))
require.NoError(t, err)
require.Equal(t, []uint32{vocab - 1}, decode(ret))
}
// TestRunChargesBeforeItParses: the gas check must come first, or a caller could
// discover the ABI's refusals without paying for them.
func TestRunChargesBeforeItParses(t *testing.T) {
ret, rem, err := InferencePrecompile.Run(nil, common.Address{}, common.Address{}, []byte{0xde, 0xad, 0xbe, 0xef}, 0, false)
require.ErrorContains(t, err, "out of gas")
require.Nil(t, ret)
require.Zero(t, rem)
}
// TestRunGasBoundary: one gas short reverts, exactly enough succeeds and leaves nothing.
func TestRunGasBoundary(t *testing.T) {
input := encodeGenerate(1, []uint32{1})
need := InferencePrecompile.RequiredGas(input)
ret, rem, err := InferencePrecompile.Run(nil, common.Address{}, common.Address{}, input, need-1, false)
require.ErrorContains(t, err, "out of gas")
require.Nil(t, ret)
require.Zero(t, rem, "an out-of-gas revert refunds nothing")
ret, rem, err = InferencePrecompile.Run(nil, common.Address{}, common.Address{}, input, need, false)
require.NoError(t, err)
require.NotEmpty(t, ret)
require.Zero(t, rem)
}
// ---- the gas schedule --------------------------------------------------------------
// TestRequiredGasMonotonic: one more requested token always costs more, and a longer
// prompt is never cheaper. The price ignores the prompt today, so the second half holds
// as an equality; it is written as an inequality so it still holds if the schedule
// starts charging for the prompt, which is quadratic work the price does not yet see.
func TestRequiredGasMonotonic(t *testing.T) {
prompt := []uint32{1}
prev := gasFor(0, prompt)
require.Equal(t, InferencePrecompile.RequiredGas(nil), prev,
"zero new tokens must cost the same as an unparseable input: the base is the floor")
for n := uint32(1); n <= MaxNewTokens; n++ {
g := gasFor(n, prompt)
require.Greater(t, g, prev, "asking for one more token must cost more")
prev = g
}
for _, n := range []uint32{0, 1, MaxNewTokens} {
require.GreaterOrEqual(t, gasFor(n, tokens(200, 48)), gasFor(n, prompt),
"a longer prompt must never be cheaper than a short one")
}
}
// TestRequiredGasFloorsAtTheBase: anything too short to carry a token count is priced
// at the base, and that base is the cheapest the precompile ever is.
func TestRequiredGasFloorsAtTheBase(t *testing.T) {
base := InferencePrecompile.RequiredGas(nil)
for n := 0; n < 8; n++ {
require.Equal(t, base, InferencePrecompile.RequiredGas(make([]byte, n)),
"input with no readable token count must be priced at the base")
}
require.Equal(t, base, gasFor(0, []uint32{1}))
for n := uint32(0); n <= MaxNewTokens; n++ {
require.GreaterOrEqual(t, gasFor(n, []uint32{1}), base)
}
}
// TestRequiredGasSaturates: past the generation limit the price stops growing, so an
// absurd token count cannot be used to reserve an absurd amount of gas.
func TestRequiredGasSaturates(t *testing.T) {
atMax := gasFor(MaxNewTokens, []uint32{1})
for _, n := range []uint32{MaxNewTokens + 1, MaxNewTokens + 2, 1000, math.MaxUint32 / 2, math.MaxUint32} {
require.Equal(t, atMax, gasFor(n, []uint32{1}), "price must saturate at the generation limit")
}
}
// TestClampedPriceAlwaysReverts is the invariant that keeps the price and the work
// honest. RequiredGas CLAMPS nNew to MaxNewTokens; Run REJECTS it. Those two must
// never disagree — every request that receives the clamped price must revert, so a
// caller can never be quoted for 64 tokens and served more.
func TestClampedPriceAlwaysReverts(t *testing.T) {
prompt := []uint32{1}
atMax := gasFor(MaxNewTokens, prompt)
for _, n := range []uint32{MaxNewTokens + 1, MaxNewTokens + 2, 100, 1000, math.MaxUint32} {
input := encodeGenerate(n, prompt)
require.Equal(t, atMax, InferencePrecompile.RequiredGas(input), "the price is clamped here")
ret, _, err := call(input)
require.Error(t, err, "a clamped price must buy no work")
require.Nil(t, ret)
}
// The boundary itself is not clamped and not rejected: nNew == MaxNewTokens clears
// the generation gate and fails later, on the vocab check.
refuse(t, encodeGenerate(MaxNewTokens, []uint32{fixtureModel().Cfg.Vocab}), "out of vocab")
}
// ---- Run: the successful shape -----------------------------------------------------
// TestRunReturnsPromptThenGeneration: the reply is the prompt verbatim followed by the
// requested tokens, so a caller can locate the generated span without re-encoding.
func TestRunReturnsPromptThenGeneration(t *testing.T) {
prompt := []uint32{1, 7, 13, 2}
ret, _, err := call(encodeGenerate(0, prompt))
require.NoError(t, err)
require.Equal(t, prompt, decode(ret), "zero new tokens echoes the prompt untouched")
ret, _, err = call(encodeGenerate(2, prompt))
require.NoError(t, err)
got := decode(ret)
require.Len(t, got, len(prompt)+2)
require.Equal(t, prompt, got[:len(prompt)], "the prompt prefix must survive generation")
}
// TestRunAtTheSequenceLimit: a prompt that exactly fills the window is accepted, one
// token more is not.
func TestRunAtTheSequenceLimit(t *testing.T) {
vocab := fixtureModel().Cfg.Vocab
full := tokens(MaxSeqLen, vocab)
ret, _, err := call(encodeGenerate(0, full))
require.NoError(t, err)
require.Equal(t, full, decode(ret))
refuse(t, encodeGenerate(0, tokens(MaxSeqLen+1, vocab)), "sequence 257 exceeds max")
}
// TestRunIsByteIdentical: the precompile is on the consensus path, so repeated
// invocations of the same input must not differ by a single byte or a single gas.
func TestRunIsByteIdentical(t *testing.T) {
input := encodeGenerate(3, []uint32{1, 7, 13, 2})
first, rem, err := call(input)
require.NoError(t, err)
require.NotEmpty(t, first)
for i := 0; i < 3; i++ {
again, rem2, err2 := call(input)
require.NoError(t, err2)
require.Equal(t, first, again, "the consensus path must be byte-identical across invocations")
require.Equal(t, rem, rem2, "and must charge identically")
}
}
// ---- module + config ---------------------------------------------------------------
func TestModuleWiring(t *testing.T) {
byAddr, ok := modules.GetPrecompileModuleByAddress(ContractAddress)
require.True(t, ok, "init must have registered the module")
byKey, ok := modules.GetPrecompileModule(ConfigKey)
require.True(t, ok, "the config key must resolve to the same module")
require.Equal(t, byAddr.Address, byKey.Address)
require.Equal(t, byAddr.ConfigKey, byKey.ConfigKey)
require.Same(t, InferencePrecompile, byAddr.Contract, "the registry must hold the singleton")
require.Equal(t, byAddr.ConfigKey, byAddr.Configurator.MakeConfig().Key(),
"the configurator must mint configs under the key the registry indexes by")
// init panics if registration fails; prove the collision it guards against is real.
require.Error(t, modules.RegisterModule(Module), "a second registration at this address must be refused")
}
// TestMakeConfigIsFreshAndUnset: every call hands back its own unscheduled config. A
// shared value would leak one chain's upgrade into the next.
func TestMakeConfigIsFreshAndUnset(t *testing.T) {
first, ok := Module.Configurator.MakeConfig().(*Config)
require.True(t, ok, "MakeConfig must mint this precompile's config type")
require.Nil(t, first.Timestamp(), "a fresh config is not scheduled")
require.False(t, first.IsDisabled())
require.NoError(t, first.Verify(nil))
first.Upgrade = precompileconfig.Upgrade{BlockTimestamp: ptr(9), Disable: true}
second := Module.Configurator.MakeConfig()
require.Nil(t, second.Timestamp(), "a later config must not inherit an earlier one")
require.False(t, second.IsDisabled())
require.True(t, second.Equal(Module.Configurator.MakeConfig()), "two fresh configs are equal")
}
// TestConfigReportsTheUpgradeItHolds: Key, Timestamp and IsDisabled must read the
// embedded upgrade rather than answer from a constant.
func TestConfigReportsTheUpgradeItHolds(t *testing.T) {
c := new(Config)
require.Equal(t, Module.ConfigKey, c.Key())
require.Nil(t, c.Timestamp())
require.False(t, c.IsDisabled())
for _, at := range []uint64{0, 7, 1_700_000_000} {
c.Upgrade.BlockTimestamp = ptr(at)
require.Equal(t, at, *c.Timestamp())
}
c.Upgrade.Disable = true
require.True(t, c.IsDisabled())
require.NoError(t, c.Verify(nil), "a disabled upgrade still verifies")
require.Equal(t, Module.ConfigKey, c.Key(), "the key does not move with the upgrade")
}
// TestConfigEqualComparesEveryField mutates one field at a time and requires equality
// to break in both directions.
func TestConfigEqualComparesEveryField(t *testing.T) {
// Equal forwards the whole embedded upgrade, so the exhaustiveness claim below is
// only true for these shapes. A new field must teach Equal about itself.
require.Equal(t, 1, reflect.TypeOf(Config{}).NumField(), "new Config field: teach Equal about it")
require.Equal(t, 2, reflect.TypeOf(precompileconfig.Upgrade{}).NumField(), "new Upgrade field: teach Equal about it")
base := func() *Config { return &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr(100)}} }
// Distinct pointers holding the same instant: Equal compares values, not addresses.
require.True(t, base().Equal(base()))
for _, c := range []struct {
name string
other *Config
}{
{"timestamp differs", &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr(101)}}},
{"timestamp unset", &Config{}},
{"disable differs", &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr(100), Disable: true}}},
{"both differ", &Config{Upgrade: precompileconfig.Upgrade{Disable: true}}},
} {
t.Run(c.name, func(t *testing.T) {
require.False(t, base().Equal(c.other))
require.False(t, c.other.Equal(base()), "Equal must be symmetric")
})
}
}
// TestConfigEqualRejectsAForeignConfig: the foreign config carries an identical
// upgrade, so only the type can tell the two apart.
func TestConfigEqualRejectsAForeignConfig(t *testing.T) {
c := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr(100)}}
require.False(t, c.Equal(nil), "a missing config is not this config")
require.False(t, c.Equal(&foreign{Upgrade: c.Upgrade}), "another precompile's config is not this config")
require.True(t, c.Equal(&Config{Upgrade: c.Upgrade}), "but the same config still is")
}
// TestConfigureTouchesNoState: this precompile is pure compute. Any StateDB or block
// call would reach the embedded nil interface and panic.
func TestConfigureTouchesNoState(t *testing.T) {
cfg := Module.Configurator.MakeConfig()
require.NoError(t, Module.Configurator.Configure(nil, cfg, deadState{}, deadBlock{}))
}
// ---- helpers -----------------------------------------------------------------------
func ptr(v uint64) *uint64 { return &v }
// foreign is another precompile's config: a different type carrying the same upgrade.
type foreign struct{ precompileconfig.Upgrade }
func (*foreign) Key() string { return "someOtherPrecompileConfig" }
func (*foreign) Verify(precompileconfig.ChainConfig) error { return nil }
func (*foreign) Equal(cfg precompileconfig.Config) bool { _, ok := cfg.(*foreign); return ok }
// deadState and deadBlock panic on any use: they hold a nil interface.
type deadState struct{ contract.StateDB }
type deadBlock struct {
contract.ConfigurationBlockContext
}
+853
View File
@@ -0,0 +1,853 @@
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package modelregistry
import (
"encoding/binary"
"reflect"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// fixtures
// ---------------------------------------------------------------------------
var (
govA = common.HexToAddress("0x00000000000000000000000000000000000000A1")
govB = common.HexToAddress("0x00000000000000000000000000000000000000A2")
govC = common.HexToAddress("0x00000000000000000000000000000000000000A3")
outsider = common.HexToAddress("0x00000000000000000000000000000000000000BB")
beluga = common.HexToHash("0x0000000000000000000000000000000000000000000000000042424265677561")
weightV1 = common.HexToHash("0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890")
weightV2 = common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111")
)
const selectorUnknown uint32 = 0xDEADBEEF
func ptr64(v uint64) *uint64 { return &v }
// countingState is the mock StateDB plus a write counter. Reading state back is
// not enough to prove a refused call wrote nothing — a write of the identical
// value is invisible to a read. The counter proves SetState was never reached.
type countingState struct {
*mockState
writes int
}
var _ contract.StateDB = (*countingState)(nil)
func (c *countingState) SetState(addr common.Address, k, v common.Hash) common.Hash {
c.writes++
return c.mockState.SetState(addr, k, v)
}
func (c *countingState) snap() map[common.Hash]common.Hash {
out := make(map[common.Hash]common.Hash, len(c.m))
for k, v := range c.m {
out[k] = v
}
return out
}
// seeded builds a registry whose genesis governance set is admins.
func seeded(t *testing.T, admins ...common.Address) (*countingState, mockAccessible) {
t.Helper()
st := &countingState{mockState: newMockState()}
require.NoError(t, (&configurator{}).Configure(nil, &Config{Admins: admins}, st, nil))
st.writes = 0 // genesis seeding is not what the caller under test is doing
return st, mockAccessible{s: st}
}
// run executes input, paying exactly what RequiredGas asks.
func run(acc mockAccessible, caller common.Address, in []byte, readOnly bool) ([]byte, error) {
ret, _, err := ModelRegistryPrecompile.Run(
acc, caller, ContractAddress, in, ModelRegistryPrecompile.RequiredGas(in), readOnly)
return ret, err
}
// refuses asserts input is rejected AND that the rejection was inert: no write
// attempted, and the whole state map byte-identical afterwards.
func refuses(t *testing.T, st *countingState, acc mockAccessible, caller common.Address, in []byte, readOnly bool) error {
t.Helper()
before := st.snap()
st.writes = 0
_, err := run(acc, caller, in, readOnly)
require.Error(t, err)
require.Zero(t, st.writes, "refused call reached SetState")
require.Equal(t, before, st.snap(), "refused call mutated state")
return err
}
// adopted reads the registry through the precompile ABI, which is what a
// contract on-chain sees.
func adopted(t *testing.T, acc mockAccessible, name common.Hash) (common.Hash, common.Hash) {
t.Helper()
ret, err := run(acc, outsider, getApprovedInput(name), true)
require.NoError(t, err)
require.Len(t, ret, 64)
return common.BytesToHash(ret[0:32]), common.BytesToHash(ret[32:64])
}
func isAdminABI(t *testing.T, acc mockAccessible, a common.Address) bool {
t.Helper()
ret, err := run(acc, outsider, isAdminInput(a), true)
require.NoError(t, err)
require.Len(t, ret, 32)
return ret[31] == 1
}
// truncate returns a call whose selector is intact but whose body is one byte
// short of the minimum the handler needs.
func truncate(selector uint32, bodyLen int) []byte {
return append(sel(selector), make([]byte, bodyLen)...)
}
// ---------------------------------------------------------------------------
// module wiring
// ---------------------------------------------------------------------------
func TestModuleRegistration(t *testing.T) {
byKey, ok := modules.GetPrecompileModule(ConfigKey)
require.True(t, ok, "package init did not register the module under its config key")
require.Equal(t, ContractAddress, byKey.Address)
byAddr, ok := modules.GetPrecompileModuleByAddress(ContractAddress)
require.True(t, ok, "module is not reachable by address")
require.Equal(t, ConfigKey, byAddr.ConfigKey)
// Genesis JSON routes by config key; the address routes EVM calls. Both must
// land on this package's contract or the precompile is unreachable.
require.Same(t, ModelRegistryPrecompile, byAddr.Contract)
require.Equal(t, ConfigKey, Module.Configurator.MakeConfig().Key())
// The address is consensus: every chain config, Solidity binding and
// off-chain reader already names it. Slot 0x02 of the AI reserved range
// 0x0300…0000..00FF is MODEL_REGISTRY; moving it would leave callers
// talking to an empty account rather than to a precompile.
require.Equal(t,
common.HexToAddress("0x0300000000000000000000000000000000000002"),
ContractAddress)
// The config key is the genesis/upgrade JSON key. Renaming it does not
// fail — the host simply never finds a config for this module, so the
// precompile silently never activates.
require.Equal(t, "modelRegistryConfig", ConfigKey)
// So are the selectors. Distinctness matters as much as the values: two
// selectors sharing a number would make the second method unreachable,
// because Run dispatches on the first matching arm.
selectors := map[string]uint32{
"adopt": SelectorAdopt,
"getApproved": SelectorGetApproved,
"isAdmin": SelectorIsAdmin,
"setAdmin": SelectorSetAdmin,
}
require.Equal(t, map[string]uint32{
"adopt": 0x01000000,
"getApproved": 0x02000000,
"isAdmin": 0x03000000,
"setAdmin": 0x04000000,
}, selectors)
seen := map[uint32]string{}
for name, s := range selectors {
prev, dup := seen[s]
require.False(t, dup, "%s and %s share selector %#x", prev, name, s)
seen[s] = name
}
require.NotContains(t, seen, selectorUnknown, "the test's unknown selector is a real method")
}
func TestMakeConfigIsFreshAndEmpty(t *testing.T) {
c := (&configurator{}).MakeConfig()
require.NotNil(t, c)
cfg, ok := c.(*Config)
require.True(t, ok, "MakeConfig must produce the type Configure asserts on")
require.Empty(t, cfg.Admins, "a fresh config must seed no governance")
require.Nil(t, cfg.Timestamp())
require.False(t, cfg.IsDisabled())
require.NoError(t, cfg.Verify(nil))
require.Equal(t, ConfigKey, cfg.Key())
// The host unmarshals genesis JSON into whatever MakeConfig hands back. If
// it returned a shared value, one chain's admin set would leak into another.
other := (&configurator{}).MakeConfig()
require.NotSame(t, cfg, other)
cfg.Admins = append(cfg.Admins, govA)
require.Empty(t, other.(*Config).Admins)
}
func TestConfigMirrorsUpgrade(t *testing.T) {
for _, tc := range []struct {
name string
up precompileconfig.Upgrade
}{
{"unscheduled", precompileconfig.Upgrade{}},
{"scheduled", precompileconfig.Upgrade{BlockTimestamp: ptr64(1_700_000_000)}},
{"disabled", precompileconfig.Upgrade{BlockTimestamp: ptr64(9), Disable: true}},
} {
t.Run(tc.name, func(t *testing.T) {
c := &Config{Upgrade: tc.up, Admins: []common.Address{govA}}
require.Equal(t, ConfigKey, c.Key())
require.Equal(t, tc.up.Timestamp(), c.Timestamp())
require.Equal(t, tc.up.Disable, c.IsDisabled())
// Verify gates genesis JSON; this config carries nothing to reject.
require.NoError(t, c.Verify(nil))
})
}
// Timestamp aliases the upgrade's pointer rather than copying a snapshot.
c := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr64(5)}}
require.Equal(t, uint64(5), *c.Timestamp())
}
// ---------------------------------------------------------------------------
// Config.Equal
// ---------------------------------------------------------------------------
// foreignConfig is an unrelated Config implementation. Equal must reject it on
// type, never by reading fields it does not have.
type foreignConfig struct{}
func (foreignConfig) Key() string { return "foreign" }
func (foreignConfig) Timestamp() *uint64 { return nil }
func (foreignConfig) IsDisabled() bool { return false }
func (foreignConfig) Verify(precompileconfig.ChainConfig) error { return nil }
func (foreignConfig) Equal(precompileconfig.Config) bool { return false }
var _ precompileconfig.Config = foreignConfig{}
func TestConfigEqual(t *testing.T) {
up := func(ts *uint64, disable bool) precompileconfig.Upgrade {
return precompileconfig.Upgrade{BlockTimestamp: ts, Disable: disable}
}
for _, tc := range []struct {
name string
a, b *Config
want bool
}{
{"zero values", &Config{}, &Config{}, true},
{"nil vs empty admin slice", &Config{}, &Config{Admins: []common.Address{}}, true},
{"same everything",
&Config{Upgrade: up(ptr64(7), true), Admins: []common.Address{govA, govB}},
&Config{Upgrade: up(ptr64(7), true), Admins: []common.Address{govA, govB}}, true},
// Upgrade half.
{"timestamp differs",
&Config{Upgrade: up(ptr64(7), false)}, &Config{Upgrade: up(ptr64(8), false)}, false},
{"timestamp set vs unscheduled",
&Config{Upgrade: up(ptr64(7), false)}, &Config{Upgrade: up(nil, false)}, false},
{"disable differs",
&Config{Upgrade: up(ptr64(7), true)}, &Config{Upgrade: up(ptr64(7), false)}, false},
// Admins half.
{"admin count differs (none vs one)",
&Config{}, &Config{Admins: []common.Address{govA}}, false},
{"admin count differs (one vs two)",
&Config{Admins: []common.Address{govA}},
&Config{Admins: []common.Address{govA, govB}}, false},
{"same length, different member",
&Config{Admins: []common.Address{govA}},
&Config{Admins: []common.Address{govB}}, false},
{"same length, one member swapped",
&Config{Admins: []common.Address{govA, govB}},
&Config{Admins: []common.Address{govA, govC}}, false},
// Order is part of the config document: two genesis files listing the
// same admins in different orders are different files, and Equal is how
// the host detects a config it has not already applied.
{"same members, different order",
&Config{Admins: []common.Address{govA, govB}},
&Config{Admins: []common.Address{govB, govA}}, false},
// Both halves differ at once.
{"upgrade and admins both differ",
&Config{Upgrade: up(ptr64(1), false), Admins: []common.Address{govA}},
&Config{Upgrade: up(ptr64(2), true), Admins: []common.Address{govB}}, false},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, tc.a.Equal(tc.b))
require.Equal(t, tc.want, tc.b.Equal(tc.a), "Equal must be symmetric")
})
}
full := &Config{Upgrade: up(ptr64(7), true), Admins: []common.Address{govA}}
require.True(t, full.Equal(full), "Equal must be reflexive")
require.False(t, full.Equal(nil), "a nil config is not equal to a real one")
require.False(t, full.Equal(foreignConfig{}), "a foreign Config type is never equal")
}
// TestConfigEqualComparesEveryField fails the day a field is added to Config
// without teaching Equal about it — the classic way an upgrade check goes blind.
func TestConfigEqualComparesEveryField(t *testing.T) {
base := Config{
Upgrade: precompileconfig.Upgrade{BlockTimestamp: ptr64(7)},
Admins: []common.Address{govA},
}
mutate := map[string]func(*Config){
"Upgrade": func(c *Config) { c.Upgrade.BlockTimestamp = ptr64(8) },
"Admins": func(c *Config) { c.Admins = []common.Address{govB} },
}
rt := reflect.TypeOf(base)
require.Equal(t, len(mutate), rt.NumField(),
"Config's field set changed; extend Equal and this table")
for i := range rt.NumField() {
name := rt.Field(i).Name
change, known := mutate[name]
require.True(t, known, "no mutation covers field %s", name)
t.Run(name, func(t *testing.T) {
other := base
change(&other)
require.False(t, base.Equal(&other), "Equal ignores %s", name)
require.False(t, other.Equal(&base), "Equal ignores %s", name)
})
}
}
// ---------------------------------------------------------------------------
// Configure
// ---------------------------------------------------------------------------
func TestConfigureSeedsGenesisAdmins(t *testing.T) {
for _, tc := range []struct {
name string
admins []common.Address
}{
{"no admins", nil},
{"empty slice", []common.Address{}},
{"one admin", []common.Address{govA}},
{"several admins", []common.Address{govA, govB, govC}},
{"duplicate entries", []common.Address{govA, govA}},
} {
t.Run(tc.name, func(t *testing.T) {
st := &countingState{mockState: newMockState()}
require.NoError(t, (&configurator{}).Configure(nil, &Config{Admins: tc.admins}, st, nil))
acc := mockAccessible{s: st}
for _, a := range tc.admins {
require.True(t, IsAdmin(st, a), "%s was listed at genesis but is not admin", a)
require.True(t, isAdminABI(t, acc, a), "ABI disagrees with the Go helper for %s", a)
}
// Seeding is not "everyone is admin".
require.False(t, IsAdmin(st, outsider))
require.False(t, isAdminABI(t, acc, outsider))
if len(tc.admins) == 0 {
require.Zero(t, st.writes, "an empty governance set must touch no state")
} else {
require.NotZero(t, st.writes)
// The seeding is real governance, not just a flag: a seeded
// admin can actually adopt.
_, err := run(acc, tc.admins[0], adoptInput(beluga, weightV1, 1), false)
require.NoError(t, err)
}
})
}
}
func TestConfigureRejectsForeignConfig(t *testing.T) {
for _, tc := range []struct {
name string
cfg precompileconfig.Config
}{
{"nil config", nil},
{"foreign type", foreignConfig{}},
} {
t.Run(tc.name, func(t *testing.T) {
st := &countingState{mockState: newMockState()}
err := (&configurator{}).Configure(nil, tc.cfg, st, nil)
require.ErrorContains(t, err, "unexpected config type")
require.Zero(t, st.writes, "a rejected config must not seed anything")
require.Empty(t, st.m)
})
}
}
// ---------------------------------------------------------------------------
// access control
// ---------------------------------------------------------------------------
func TestNonAdminCannotAdopt(t *testing.T) {
st, acc := seeded(t, govA)
// Nothing adopted yet.
ver, weight := adopted(t, acc, beluga)
require.Equal(t, common.Hash{}, ver)
require.Equal(t, common.Hash{}, weight)
err := refuses(t, st, acc, outsider, adoptInput(beluga, weightV1, 1), false)
require.ErrorContains(t, err, "not an admin")
// State is still empty when read back through the ABI.
ver, weight = adopted(t, acc, beluga)
require.Equal(t, common.Hash{}, ver, "a refused adopt published a version")
require.Equal(t, common.Hash{}, weight, "a refused adopt published weights")
// And a refused caller cannot overwrite an existing adoption either.
_, err = run(acc, govA, adoptInput(beluga, weightV1, 1), false)
require.NoError(t, err)
err = refuses(t, st, acc, outsider, adoptInput(beluga, weightV2, 99), false)
require.ErrorContains(t, err, "not an admin")
ver, weight = adopted(t, acc, beluga)
require.Equal(t, uint64(1), binary.BigEndian.Uint64(ver[24:32]))
require.Equal(t, weightV1, weight, "a refused adopt replaced the live weights")
}
func TestNonAdminCannotSetAdmin(t *testing.T) {
st, acc := seeded(t, govA)
err := refuses(t, st, acc, outsider, setAdminInput(outsider, true), false)
require.ErrorContains(t, err, "not an admin")
require.False(t, IsAdmin(st, outsider), "an outsider promoted itself")
// Nor can an outsider strip a real admin.
err = refuses(t, st, acc, outsider, setAdminInput(govA, false), false)
require.ErrorContains(t, err, "not an admin")
require.True(t, IsAdmin(st, govA), "an outsider demoted a genesis admin")
}
// TestAuthorizationPrecedesParsing pins the order of the checks in Run:
// read-only, then admin, then body length. A caller who is not allowed to write
// must be turned away before the body is even looked at, so no malformed-input
// path can ever run ahead of the authorization check.
func TestAuthorizationPrecedesParsing(t *testing.T) {
st, acc := seeded(t, govA)
for _, tc := range []struct {
name string
caller common.Address
in []byte
readOnly bool
want string
}{
{"adopt, outsider, short body", outsider, truncate(SelectorAdopt, 95), false, "not an admin"},
{"setAdmin, outsider, short body", outsider, truncate(SelectorSetAdmin, 63), false, "not an admin"},
{"adopt, admin, read-only, short body", govA, truncate(SelectorAdopt, 95), true, "read-only"},
{"setAdmin, admin, read-only, short body", govA, truncate(SelectorSetAdmin, 63), true, "read-only"},
{"adopt, outsider, read-only", outsider, adoptInput(beluga, weightV1, 1), true, "read-only"},
{"setAdmin, outsider, read-only", outsider, setAdminInput(outsider, true), true, "read-only"},
} {
t.Run(tc.name, func(t *testing.T) {
err := refuses(t, st, acc, tc.caller, tc.in, tc.readOnly)
require.ErrorContains(t, err, tc.want)
})
}
}
func TestAdminAdoptsAndPromotes(t *testing.T) {
st, acc := seeded(t, govA)
// govB is not admin yet, so it cannot adopt.
require.False(t, isAdminABI(t, acc, govB))
err := refuses(t, st, acc, govB, adoptInput(beluga, weightV1, 1), false)
require.ErrorContains(t, err, "not an admin")
// govA promotes govB.
ret, err := run(acc, govA, setAdminInput(govB, true), false)
require.NoError(t, err)
require.Equal(t, boolWord(true), ret)
require.True(t, IsAdmin(st, govB))
require.True(t, isAdminABI(t, acc, govB))
// The promotion is real: govB can now adopt, and the adoption is visible.
ret, err = run(acc, govB, adoptInput(beluga, weightV2, 42), false)
require.NoError(t, err)
require.Equal(t, boolWord(true), ret)
ver, weight := adopted(t, acc, beluga)
require.Equal(t, uint64(42), binary.BigEndian.Uint64(ver[24:32]))
require.Equal(t, weightV2, weight)
// Promotion does not spill onto anyone else.
require.False(t, IsAdmin(st, govC))
require.False(t, IsAdmin(st, outsider))
// A newly promoted admin can promote in turn: the privilege is not
// special-cased to the genesis set.
_, err = run(acc, govB, setAdminInput(govC, true), false)
require.NoError(t, err)
require.True(t, IsAdmin(st, govC))
}
func TestSetAdminDemotes(t *testing.T) {
st, acc := seeded(t, govA, govB)
require.True(t, IsAdmin(st, govB))
ret, err := run(acc, govA, setAdminInput(govB, false), false)
require.NoError(t, err)
require.Equal(t, boolWord(true), ret)
// Demotion clears the slot rather than leaving a stale non-zero value.
require.Equal(t, common.Hash{}, st.GetState(ContractAddress, adminKey(govB)))
require.False(t, IsAdmin(st, govB))
require.False(t, isAdminABI(t, acc, govB))
// A demoted admin is refused on both privileged entry points.
err = refuses(t, st, acc, govB, adoptInput(beluga, weightV1, 1), false)
require.ErrorContains(t, err, "not an admin")
err = refuses(t, st, acc, govB, setAdminInput(govB, true), false)
require.ErrorContains(t, err, "not an admin")
// Demotion is not contagious.
require.True(t, IsAdmin(st, govA))
// A re-promotion restores the privilege, so demotion is a toggle and not a
// one-way burn of the address.
_, err = run(acc, govA, setAdminInput(govB, true), false)
require.NoError(t, err)
require.True(t, IsAdmin(st, govB))
}
// TestSoleAdminCanDemoteItself records that governance has no floor: the last
// admin may remove itself, after which nothing can ever adopt or re-admit an
// admin again, because both paths require an existing admin. Configure only
// runs at activation, so there is no way back.
func TestSoleAdminCanDemoteItself(t *testing.T) {
st, acc := seeded(t, govA)
_, err := run(acc, govA, setAdminInput(govA, false), false)
require.NoError(t, err)
require.False(t, IsAdmin(st, govA))
err = refuses(t, st, acc, govA, setAdminInput(govA, true), false)
require.ErrorContains(t, err, "not an admin")
err = refuses(t, st, acc, govA, adoptInput(beluga, weightV1, 1), false)
require.ErrorContains(t, err, "not an admin")
}
// ---------------------------------------------------------------------------
// read-only
// ---------------------------------------------------------------------------
func TestReadOnlyRefusesWritesAndAllowsReads(t *testing.T) {
st, acc := seeded(t, govA)
_, err := run(acc, govA, adoptInput(beluga, weightV1, 3), false)
require.NoError(t, err)
// Writes: refused even for a genesis admin, and inert.
err = refuses(t, st, acc, govA, adoptInput(beluga, weightV2, 9), true)
require.ErrorContains(t, err, "read-only")
err = refuses(t, st, acc, govA, setAdminInput(govB, true), true)
require.ErrorContains(t, err, "read-only")
err = refuses(t, st, acc, govA, setAdminInput(govA, false), true)
require.ErrorContains(t, err, "read-only")
// Nothing moved.
require.True(t, IsAdmin(st, govA))
require.False(t, IsAdmin(st, govB))
ver, weight := adopted(t, acc, beluga)
require.Equal(t, uint64(3), binary.BigEndian.Uint64(ver[24:32]))
require.Equal(t, weightV1, weight)
// Reads still answer under read-only — that is the whole point of eth_call.
require.True(t, isAdminABI(t, acc, govA))
require.False(t, isAdminABI(t, acc, govB))
}
// ---------------------------------------------------------------------------
// Run: refusals
// ---------------------------------------------------------------------------
func TestRunRefusalsAreInert(t *testing.T) {
st, acc := seeded(t, govA)
// Give the state something to lose.
_, err := run(acc, govA, adoptInput(beluga, weightV1, 5), false)
require.NoError(t, err)
for _, tc := range []struct {
name string
in []byte
want string
}{
{"nil input", nil, "input too short"},
{"one byte", []byte{0x01}, "input too short"},
{"two bytes", []byte{0x01, 0x00}, "input too short"},
{"three bytes", []byte{0x01, 0x00, 0x00}, "input too short"},
{"unknown selector", sel(selectorUnknown), "unknown selector"},
{"unknown selector with body", append(sel(selectorUnknown), make([]byte, 96)...), "unknown selector"},
{"adopt body one byte short", truncate(SelectorAdopt, 95), "adopt: input too short"},
{"getApproved body one byte short", truncate(SelectorGetApproved, 31), "getApproved: input too short"},
{"isAdmin body one byte short", truncate(SelectorIsAdmin, 31), "isAdmin: input too short"},
{"setAdmin body one byte short", truncate(SelectorSetAdmin, 63), "setAdmin: input too short"},
{"adopt with no body", sel(SelectorAdopt), "adopt: input too short"},
{"getApproved with no body", sel(SelectorGetApproved), "getApproved: input too short"},
{"isAdmin with no body", sel(SelectorIsAdmin), "isAdmin: input too short"},
{"setAdmin with no body", sel(SelectorSetAdmin), "setAdmin: input too short"},
} {
t.Run(tc.name, func(t *testing.T) {
// Caller is an admin under a writable call, so the only thing that
// can reject these is the decoding itself.
err := refuses(t, st, acc, govA, tc.in, false)
require.ErrorContains(t, err, tc.want)
})
}
// The live adoption survived every one of them.
ver, weight := adopted(t, acc, beluga)
require.Equal(t, uint64(5), binary.BigEndian.Uint64(ver[24:32]))
require.Equal(t, weightV1, weight)
require.True(t, IsAdmin(st, govA))
}
// TestRunAcceptsMinimumBodies is the other half of the length checks: one byte
// less is refused, exactly enough is accepted. Without this, a handler that
// demanded one byte too many would still pass the refusal table.
func TestRunAcceptsMinimumBodies(t *testing.T) {
_, acc := seeded(t, govA)
for _, tc := range []struct {
name string
in []byte
}{
{"adopt", truncate(SelectorAdopt, 96)},
{"getApproved", truncate(SelectorGetApproved, 32)},
{"isAdmin", truncate(SelectorIsAdmin, 32)},
{"setAdmin", truncate(SelectorSetAdmin, 64)},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := run(acc, govA, tc.in, false)
require.NoError(t, err)
})
}
// Trailing bytes past the minimum are ignored, not rejected.
_, err := run(acc, govA, append(adoptInput(beluga, weightV1, 1), make([]byte, 33)...), false)
require.NoError(t, err)
ver, weight := adopted(t, acc, beluga)
require.Equal(t, uint64(1), binary.BigEndian.Uint64(ver[24:32]))
require.Equal(t, weightV1, weight)
}
// ---------------------------------------------------------------------------
// gas
// ---------------------------------------------------------------------------
func TestRequiredGasPricesBySelector(t *testing.T) {
gasOf := ModelRegistryPrecompile.RequiredGas
adopt := gasOf(adoptInput(beluga, weightV1, 1))
setAdmin := gasOf(setAdminInput(govB, true))
getApproved := gasOf(getApprovedInput(beluga))
isAdmin := gasOf(isAdminInput(govA))
// Relative shape, not literals: nothing is free; a read of two slots costs
// more than a read of one; a write costs more than a read; a two-slot write
// costs more than a one-slot write.
require.Positive(t, isAdmin)
require.Greater(t, getApproved, isAdmin)
require.Greater(t, setAdmin, getApproved)
require.Greater(t, adopt, setAdmin)
// Price follows the selector alone — same selector, different body, same
// price — so a caller cannot buy a discount by varying arguments.
require.Equal(t, adopt, gasOf(adoptInput(weightV2, beluga, 1<<40)))
require.Equal(t, setAdmin, gasOf(setAdminInput(outsider, false)))
require.Equal(t, isAdmin, gasOf(isAdminInput(outsider)))
require.Equal(t, getApproved, gasOf(getApprovedInput(weightV1)))
// Nor by truncating the body: the price is fixed before the body is read.
require.Equal(t, adopt, gasOf(truncate(SelectorAdopt, 0)))
require.Equal(t, setAdmin, gasOf(truncate(SelectorSetAdmin, 1)))
// Undecodable input falls to the module's default price. It is never free —
// probing the precompile costs gas — and never priced as a write.
for _, in := range [][]byte{nil, {}, {0x01}, {0x01, 0x02, 0x03}, sel(selectorUnknown)} {
def := gasOf(in)
require.Equal(t, getApproved, def, "default price drifted from the read price")
require.Positive(t, def)
require.Less(t, def, setAdmin)
}
// RequiredGas is pure: it may not consult state, so repeating a call after
// the registry has changed must return the same number.
st, acc := seeded(t, govA)
in := adoptInput(beluga, weightV1, 1)
_, err := run(acc, govA, in, false)
require.NoError(t, err)
require.NotEmpty(t, st.m)
require.Equal(t, adopt, gasOf(in))
}
// TestRunDebitsExactlyRequiredGas pins the accounting: one gas short refuses and
// consumes everything; anything at or above the price leaves the surplus.
func TestRunDebitsExactlyRequiredGas(t *testing.T) {
for _, tc := range []struct {
name string
in []byte
}{
{"adopt", adoptInput(beluga, weightV1, 1)},
{"getApproved", getApprovedInput(beluga)},
{"isAdmin", isAdminInput(govA)},
{"setAdmin", setAdminInput(govB, true)},
{"unknown selector", sel(selectorUnknown)},
{"short input", []byte{0x01}},
{"truncated adopt body", truncate(SelectorAdopt, 95)},
} {
t.Run(tc.name, func(t *testing.T) {
need := ModelRegistryPrecompile.RequiredGas(tc.in)
require.Positive(t, need)
// One gas short: refused, no gas returned, nothing written.
st, acc := seeded(t, govA)
before := st.snap()
ret, remaining, err := ModelRegistryPrecompile.Run(
acc, govA, ContractAddress, tc.in, need-1, false)
require.ErrorContains(t, err, "out of gas")
require.Nil(t, ret)
require.Zero(t, remaining)
require.Zero(t, st.writes, "an out-of-gas call still wrote state")
require.Equal(t, before, st.snap())
// Exactly the price: the call is decided on its merits and the whole
// supply is consumed.
_, acc = seeded(t, govA)
_, remaining, _ = ModelRegistryPrecompile.Run(
acc, govA, ContractAddress, tc.in, need, false)
require.Zero(t, remaining)
// Surplus is returned untouched, whether the call succeeds or is
// refused after the gas check.
_, acc = seeded(t, govA)
_, remaining, _ = ModelRegistryPrecompile.Run(
acc, govA, ContractAddress, tc.in, need+1234, false)
require.Equal(t, uint64(1234), remaining)
})
}
}
// ---------------------------------------------------------------------------
// storage layout
// ---------------------------------------------------------------------------
// TestStateKeysAreStable pins the storage layout. These slots are consensus:
// a live chain's governance set and adopted models sit at exactly these
// addresses, so changing the hash, a domain prefix, or the argument order
// relocates every existing entry to a slot nothing reads. That failure is
// silent — the registry would come back from the upgrade with no admins and no
// adopted model, and no error anywhere. Change these values only as a
// deliberate, migrated hard fork.
func TestStateKeysAreStable(t *testing.T) {
require.Equal(t,
common.HexToHash("0x1b008afa4aa1ed15a87c1c0a6811b61d26d57c311d618395d3892b1ee4ebc119"),
adminKey(govA))
require.Equal(t,
common.HexToHash("0x4d8cb13131d6a12dff452503f667e4dd4d65433ae656738a58f24cba0579729d"),
verKey(beluga))
require.Equal(t,
common.HexToHash("0xb0c71026c4b8bc2ef8f1ab3b0beda93d82b007d628336ceb8463aceaf62603fc"),
weightKey(beluga))
// The three domains are separated: the same bytes read as an address and as
// a model name never land in the same slot, so adopting a model can never
// mint an admin and promoting an admin can never publish weights.
asName := common.BytesToHash(govA.Bytes())
slots := []common.Hash{adminKey(govA), verKey(asName), weightKey(asName)}
for i := range slots {
for j := i + 1; j < len(slots); j++ {
require.NotEqual(t, slots[i], slots[j], "storage domains %d and %d collide", i, j)
}
}
// Distinct subjects get distinct slots.
require.NotEqual(t, adminKey(govA), adminKey(govB))
require.NotEqual(t, verKey(beluga), verKey(weightV1))
require.NotEqual(t, weightKey(beluga), weightKey(weightV1))
// enabledHash is a non-zero word, which is the whole test IsAdmin applies.
require.NotEqual(t, common.Hash{}, enabledHash)
}
func TestAdoptIsPerModelName(t *testing.T) {
_, acc := seeded(t, govA)
other := common.HexToHash("0x02")
_, err := run(acc, govA, adoptInput(beluga, weightV1, 1), false)
require.NoError(t, err)
_, err = run(acc, govA, adoptInput(other, weightV2, 2), false)
require.NoError(t, err)
ver, weight := adopted(t, acc, beluga)
require.Equal(t, uint64(1), binary.BigEndian.Uint64(ver[24:32]))
require.Equal(t, weightV1, weight)
ver, weight = adopted(t, acc, other)
require.Equal(t, uint64(2), binary.BigEndian.Uint64(ver[24:32]))
require.Equal(t, weightV2, weight)
// An unadopted name reads as empty, which is how a caller tells "no brain
// chosen yet" from "this one".
ver, weight = adopted(t, acc, common.HexToHash("0x03"))
require.Equal(t, common.Hash{}, ver)
require.Equal(t, common.Hash{}, weight)
// The admin flag and the model slots cannot collide: adopting a model whose
// name is an admin address must not confer or revoke adminship.
nameOfAdmin := common.BytesToHash(govA.Bytes())
_, err = run(acc, govA, adoptInput(nameOfAdmin, weightV2, 7), false)
require.NoError(t, err)
require.True(t, IsAdmin(acc.s, govA))
require.False(t, IsAdmin(acc.s, govB))
}
// TestGetApprovedNarrowsTheVersionWord records that the two read paths disagree
// above 2^64: the ABI returns the full 32-byte word that was adopted, while the
// Go helper reports only its low 64 bits.
func TestGetApprovedNarrowsTheVersionWord(t *testing.T) {
st, acc := seeded(t, govA)
// A version word whose value does not fit in 64 bits.
hiVersion := common.HexToHash("0x0000000000000000000000000000000000000001000000000000000000000000")
in := append(append(sel(SelectorAdopt), beluga[:]...), append(hiVersion[:], weightV1[:]...)...)
_, err := run(acc, govA, in, false)
require.NoError(t, err)
ver, weight := adopted(t, acc, beluga)
require.Equal(t, hiVersion, ver, "the ABI must return the exact word that was adopted")
require.Equal(t, weightV1, weight)
v, w := GetApproved(st, beluga)
require.Equal(t, weightV1, w)
require.Zero(t, v, "GetApproved reports the low 64 bits only; callers must not "+
"treat it as the full adopted version")
// Inside 64 bits the two paths agree, which is the supported range.
_, err = run(acc, govA, adoptInput(beluga, weightV2, 1<<40), false)
require.NoError(t, err)
v, w = GetApproved(st, beluga)
require.Equal(t, uint64(1)<<40, v)
require.Equal(t, weightV2, w)
}
// TestSetAdminTreatsAnyNonZeroWordAsTrue pins the bool decoding: Solidity sends
// 0 or 1, but the handler accepts any non-zero word as "enable" rather than
// rejecting a non-canonical encoding.
func TestSetAdminTreatsAnyNonZeroWordAsTrue(t *testing.T) {
st, acc := seeded(t, govA)
enable := make([]byte, 32)
enable[0] = 0x02 // non-canonical true, high byte set
in := append(append(sel(SelectorSetAdmin), word(govB.Bytes())...), enable...)
_, err := run(acc, govA, in, false)
require.NoError(t, err)
require.True(t, IsAdmin(st, govB))
// The address is decoded from the low 20 bytes; the 12 padding bytes are
// ignored rather than rejected.
padded := append(append(sel(SelectorIsAdmin), make([]byte, 12)...), govB.Bytes()...)
padded[4] = 0xFF
ret, err := run(acc, outsider, padded, true)
require.NoError(t, err)
require.Equal(t, byte(1), ret[31])
}