mirror of
https://github.com/luxfi/precompile.git
synced 2026-07-27 03:33:45 +00:00
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:
+62
-123
@@ -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())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user