fix(graph): make the GraphQL precompile deterministic + panic-free

The 0x0500..0010 GraphQL precompile (a registered StatefulPrecompiledContract)
ran the consensus Run path through four nondeterminism/halt hazards:

1. NIL-CLIENT PANIC (chain halt). The default instance holds a nil client until
   VM init wires one; Query called a method on the nil interface with no guard,
   panicking the unrecovered dispatch path and halting every validator that
   processed the tx. -> nil now returns typed ErrClientNotInitialized.
2. time.Now()/wall-clock (startTime, cache TTL, stats timing) — diverges on
   per-validator clock skew. -> removed entirely.
3. Process-global in-memory cache (map keyed by query, wall-clock TTL). A warm
   vs cold validator returned stale-vs-fresh data and a different GasUsed — both
   consensus-visible. -> removed; every query executes against local DB state.
4. Process-global stats counters mutated per call and returned on-chain via
   getStats — cross-tx mutable state. -> removed; getStats returns deterministic
   zero. And executeMultiChainQuery used goroutines whose scheduler-random order
   decided the result set and 'first error' -> now sequential in TargetChains
   order (deterministic results + first-error).

The precompile is now a stateless pure function of (input, local G-Chain DB):
no mutex, no cache, no stats, no goroutines, no wall-clock.

Regression: TestGraphQL_NilClientReturnsTypedErrorNotPanic (no panic, typed
error via both Query and the Run dispatch path), TestGraphQL_DeterministicAcrossRuns
(identical Data+GasUsed, no cache discount), TestGraphQL_MultiChainSequentialDeterministic
(chains visited in request order, byte-identical output x50),
TestGraphQL_MultiChainFirstErrorDeterministic (first error in request order).
This commit is contained in:
zeekay
2026-06-27 21:23:30 -07:00
parent bddb3a379e
commit b804fd1e08
3 changed files with 220 additions and 130 deletions
+62 -123
View File
@@ -9,7 +9,6 @@ import (
"encoding/binary"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/luxfi/geth/common"
@@ -44,29 +43,20 @@ var (
// This precompile enables any EVM contract to execute GraphQL queries
// against the unified G-Chain query layer.
type GraphQLPrecompile struct {
mu sync.RWMutex
// client is the connection to G-Chain
// 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
// read-only thereafter — so the Run path holds no lock and shares no mutable state.
client GChainClient
// cache stores recent query results for gas efficiency
cache map[[32]byte]*CacheEntry
// stats tracks query statistics
stats *QueryStats
// config holds runtime configuration
// config holds runtime configuration, set once at Configure time and read-only
// during execution.
config *Config
}
// CacheEntry represents a cached query result
type CacheEntry struct {
Data []byte
Timestamp time.Time
TTL time.Duration
}
// QueryStats tracks query performance
// QueryStats is retained for response-shape compatibility only. A precompile may not
// accumulate process-global counters: they diverge across validators (and across a single
// node's restarts) and would leak nondeterministic data into consensus output. getStats
// therefore always returns the zero value — see runGetStats.
type QueryStats struct {
TotalQueries uint64
CacheHits uint64
@@ -94,8 +84,6 @@ type Config struct {
func NewGraphQLPrecompile(client GChainClient) *GraphQLPrecompile {
return &GraphQLPrecompile{
client: client,
cache: make(map[[32]byte]*CacheEntry),
stats: &QueryStats{},
config: &Config{
MaxCacheSize: 1000,
DefaultCacheTTL: 10 * time.Second,
@@ -115,63 +103,46 @@ func makeStorageKey(prefix []byte, data []byte) common.Hash {
return key
}
// makeCacheKey creates a cache key from query and variables
func makeCacheKey(query string, variables []byte) [32]byte {
h := sha256.New()
h.Write([]byte(query))
h.Write(variables)
var key [32]byte
copy(key[:], h.Sum(nil))
return key
}
// =========================================================================
// Core Query Methods
// =========================================================================
// Query executes a GraphQL query and returns the result
// This is the main entry point for EVM contracts
// Query executes a GraphQL query and returns the result. It is the main entry point for
// EVM contracts and is a DETERMINISTIC, STATELESS function of (input, local G-Chain DB):
// - no time.Now()/wall-clock (it forked on per-validator clock skew),
// - no process-global cache (a warm-vs-cold node returned stale-vs-fresh data and
// different gas — both consensus-visible),
// - no process-global stats counters (cross-tx mutable state, returned on-chain),
// - no goroutines (scheduler order is nondeterministic),
// - 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) {
p.mu.Lock()
defer p.mu.Unlock()
startTime := time.Now()
// Validate query
if err := p.validateQuery(req); err != nil {
return QueryResponse{}, err
}
// Calculate initial gas cost
// Fail CLOSED if the client was never wired (the default instance holds a nil
// client until SetGraphVMClient runs at VM init). A nil-interface call below would
// panic, and a panic in the precompile dispatch path is unrecovered — it halts every
// validator that processed the tx. Return a typed error instead.
if p.client == nil {
return QueryResponse{}, ErrClientNotInitialized
}
// Gas cost is a pure function of the query (no cache-hit discount), so it is
// identical on every node.
gasCost := p.calculateGasCost(req)
if gasCost > gasLimit {
return QueryResponse{}, ErrGasExceeded
}
// Check cache
cacheKey := makeCacheKey(req.Query, req.Variables)
if entry, ok := p.cache[cacheKey]; ok {
if time.Since(entry.Timestamp) < entry.TTL {
p.stats.CacheHits++
return QueryResponse{
Data: entry.Data,
GasUsed: GasQueryBase, // Minimal gas for cache hit
}, nil
}
// Cache expired
delete(p.cache, cacheKey)
}
p.stats.CacheMisses++
// Execute query against G-Chain
ctx, cancel := context.WithTimeout(context.Background(), p.config.QueryTimeout)
defer cancel()
var variables map[string]any
if len(req.Variables) > 0 {
if err := json.Unmarshal(req.Variables, &variables); err != nil {
@@ -179,24 +150,26 @@ func (p *GraphQLPrecompile) Query(
}
}
// No wall-clock deadline: a context timeout fires nondeterministically (a slow node
// times out while a fast one succeeds) and forks the chain. The query is a bounded
// local-DB read, already gated by gas.
ctx := context.Background()
var result []byte
var err error
if len(req.TargetChains) == 0 {
// Query all chains via G-Chain unified layer
switch {
case len(req.TargetChains) == 0:
// Query all chains via the G-Chain unified layer.
result, err = p.client.Query(ctx, req.Query, variables)
} else if len(req.TargetChains) == 1 {
// Query specific chain
case len(req.TargetChains) == 1:
// Query a specific chain.
result, err = p.client.QueryChain(ctx, req.TargetChains[0], req.Query, variables)
} else {
// Multi-chain query
default:
// Multi-chain query (sequential, deterministic).
result, err = p.executeMultiChainQuery(ctx, req, variables)
}
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return QueryResponse{}, ErrQueryTimeout
}
return QueryResponse{
Errors: []QueryError{{Message: err.Error()}},
GasUsed: gasCost,
@@ -208,28 +181,10 @@ func (p *GraphQLPrecompile) Query(
return QueryResponse{}, ErrQueryTooLarge
}
// Calculate additional gas for response size
responseGas := uint64(len(result)) * GasPerByte
totalGas := gasCost + responseGas
// Cache result
if len(p.cache) < p.config.MaxCacheSize {
p.cache[cacheKey] = &CacheEntry{
Data: result,
Timestamp: time.Now(),
TTL: p.config.DefaultCacheTTL,
}
}
// Update stats
p.stats.TotalQueries++
p.stats.TotalGasUsed += totalGas
elapsed := time.Since(startTime)
p.stats.AvgResponseTime = (p.stats.AvgResponseTime + elapsed) / 2
return QueryResponse{
Data: result,
GasUsed: totalGas,
GasUsed: gasCost + responseGas,
}, nil
}
@@ -313,45 +268,39 @@ func (p *GraphQLPrecompile) calculateGasCost(req QueryRequest) uint64 {
return cost
}
// executeMultiChainQuery executes a query across multiple chains
// 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"
// slot — so different validators could surface different errors (or partial result sets)
// and fork. Iterating TargetChains in order makes both the result map and the first error
// deterministic; json.Marshal then sorts the integer keys, so the bytes are identical
// everywhere.
func (p *GraphQLPrecompile) executeMultiChainQuery(
ctx context.Context,
req QueryRequest,
variables map[string]any,
) ([]byte, error) {
results := make(map[uint64]json.RawMessage)
var mu sync.Mutex
var wg sync.WaitGroup
var firstErr error
for _, chainID := range req.TargetChains {
wg.Add(1)
go func(cid uint64) {
defer wg.Done()
result, err := p.client.QueryChain(ctx, cid, req.Query, variables)
mu.Lock()
defer mu.Unlock()
if err != nil && firstErr == nil {
result, err := p.client.QueryChain(ctx, chainID, req.Query, variables)
if err != nil {
if firstErr == nil {
firstErr = err
return
}
results[cid] = json.RawMessage(result)
}(chainID)
continue
}
results[chainID] = json.RawMessage(result)
}
wg.Wait()
if firstErr != nil && len(results) == 0 {
return nil, firstErr
}
// Combine results
combined := map[string]any{
"data": results,
}
return json.Marshal(combined)
}
@@ -359,27 +308,17 @@ func (p *GraphQLPrecompile) executeMultiChainQuery(
// View Methods
// =========================================================================
// GetStats returns query statistics
// GetStats returns query statistics. Precompiles cannot keep process-global counters
// (they diverge across validators), so this is always the deterministic zero value.
func (p *GraphQLPrecompile) GetStats() *QueryStats {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stats
return &QueryStats{}
}
// GetConfig returns the current configuration
// GetConfig returns the current (immutable) configuration.
func (p *GraphQLPrecompile) GetConfig() *Config {
p.mu.RLock()
defer p.mu.RUnlock()
return p.config
}
// ClearCache clears the query cache
func (p *GraphQLPrecompile) ClearCache() {
p.mu.Lock()
defer p.mu.Unlock()
p.cache = make(map[[32]byte]*CacheEntry)
}
// =========================================================================
// EVM Precompile Interface
// =========================================================================
@@ -474,10 +413,10 @@ func (p *GraphQLPrecompile) runQueryPredefined(input []byte) ([]byte, error) {
return resp.Data, nil
}
// runGetStats handles the getStats method call
// runGetStats handles the getStats method call. It returns the deterministic zero stats
// (telemetry counters are not consensus state).
func (p *GraphQLPrecompile) runGetStats() ([]byte, error) {
stats := p.GetStats()
return json.Marshal(stats)
return json.Marshal(p.GetStats())
}
// =========================================================================
+150
View File
@@ -0,0 +1,150 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package graph
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"testing"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// stubClient is a deterministic in-memory GChainClient for consensus-path tests. It records
// the order in which chains are queried so we can assert sequential (non-goroutine)
// execution.
type stubClient struct {
calls []uint64
result []byte
perChain map[uint64][]byte
errOn map[uint64]error
}
func (s *stubClient) Query(_ context.Context, _ string, _ map[string]any) ([]byte, error) {
return s.result, nil
}
func (s *stubClient) QueryChain(_ context.Context, chainID uint64, _ string, _ map[string]any) ([]byte, error) {
s.calls = append(s.calls, chainID)
if e, ok := s.errOn[chainID]; ok {
return nil, e
}
if r, ok := s.perChain[chainID]; ok {
return r, nil
}
return s.result, nil
}
// TestGraphQL_NilClientReturnsTypedErrorNotPanic is the chain-halt regression. The default
// precompile holds a nil client until VM init wires one; before the fix, invoking it called
// a method on the nil interface and PANICKED in the unrecovered precompile dispatch path,
// halting every validator that processed the tx. It must now return a typed error.
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)
require.ErrorIs(t, err, ErrClientNotInitialized)
})
// Same guarantee through the registered StatefulPrecompiledContract dispatch path.
c := &GraphQLContract{precompile: NewGraphQLPrecompile(nil)}
reqBytes, err := json.Marshal(QueryRequest{Query: "query { chainInfo }"})
require.NoError(t, err)
input := make([]byte, 4+len(reqBytes))
binary.BigEndian.PutUint32(input[:4], 0x01)
copy(input[4:], reqBytes)
require.NotPanics(t, func() {
_, _, runErr := c.Run(nil, common.Address{}, ContractGraphQLAddress, input, 1_000_000, true)
require.ErrorIs(t, runErr, ErrClientNotInitialized)
})
}
// TestGraphQL_DeterministicAcrossRuns proves the Run path carries no wall-clock and no
// cross-tx cache: the same query against the same client yields byte-identical Data AND an
// identical GasUsed on every invocation. Before the fix the second call hit the in-memory
// cache and returned a different (discounted) GasUsed — a consensus-visible divergence
// between a warm and a cold validator.
func TestGraphQL_DeterministicAcrossRuns(t *testing.T) {
client := &stubClient{result: []byte(`{"chainInfo":{"vmName":"graphvm"}}`)}
p := NewGraphQLPrecompile(client)
req := QueryRequest{Query: "query { chainInfo { vmName } }"}
first, err := p.Query(nil, common.Address{}, 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)
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)")
}
}
// TestGraphQL_MultiChainSequentialDeterministic proves the multi-chain path runs
// sequentially in TargetChains order (no goroutines) and emits byte-identical output every
// time. The concurrent version it replaced let goroutines populate the result set and the
// "first error" in scheduler-random order, so different validators could fork.
func TestGraphQL_MultiChainSequentialDeterministic(t *testing.T) {
req := QueryRequest{
Query: "query { tvl }",
TargetChains: []uint64{96369, 200200, 36963}, // deliberately not sorted
}
var golden []byte
for run := 0; run < 50; run++ {
client := &stubClient{
perChain: map[uint64][]byte{
96369: []byte(`{"tvl":1}`),
200200: []byte(`{"tvl":2}`),
36963: []byte(`{"tvl":3}`),
},
}
p := NewGraphQLPrecompile(client)
resp, err := p.Query(nil, common.Address{}, req, 10_000_000)
require.NoError(t, err)
// Chains are visited in exactly the request order — proof of sequential execution.
require.Equal(t, []uint64{96369, 200200, 36963}, client.calls)
if run == 0 {
golden = resp.Data
} else {
require.Equal(t, golden, resp.Data, "multi-chain output must be byte-identical across runs")
}
}
// And the combined payload is well-formed JSON with all three chains present.
var combined map[string]any
require.NoError(t, json.Unmarshal(golden, &combined))
data, ok := combined["data"].(map[string]any)
require.True(t, ok)
require.Len(t, data, 3)
}
// TestGraphQL_MultiChainFirstErrorDeterministic proves the surfaced error is the FIRST in
// request order (deterministic), not whichever goroutine raced first.
func TestGraphQL_MultiChainFirstErrorDeterministic(t *testing.T) {
errA := errors.New("chain A failed")
errB := errors.New("chain B failed")
req := QueryRequest{
Query: "query { tvl }",
TargetChains: []uint64{11, 22},
}
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)
// 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)
require.Equal(t, errA.Error(), resp.Errors[0].Message)
}
}
+8 -7
View File
@@ -63,13 +63,14 @@ const (
// 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")
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