Files
zeekayandHanzo Dev f3c6e28a43 a query that cannot be parsed is refused, not held open
Every pass of the top-level scan must consume something. Standing still on
input it does not understand returns no answer, forever, holding the request
open — and this is a public endpoint. Two deliberate breaks of the scan hung
the whole test suite instead of failing it; now they fail it.

Also covers the whitespace GraphQL allows and clients emit: a space before a
field's arguments, and around an alias's colon.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 12:51:43 -07:00

702 lines
19 KiB
Go

// Package engine implements the GraphQL query execution engine.
//
// Extracted from chains/graphvm — same resolvers, no consensus deps.
// Reads from local storage (SQLite/ZapDB), serves GraphQL queries.
package engine
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/luxfi/graph/resolvers/ai"
"github.com/luxfi/graph/resolvers/bridge"
"github.com/luxfi/graph/resolvers/dao"
"github.com/luxfi/graph/resolvers/derivatives"
"github.com/luxfi/graph/resolvers/dex"
"github.com/luxfi/graph/resolvers/did"
"github.com/luxfi/graph/resolvers/exchange"
"github.com/luxfi/graph/resolvers/fhe"
"github.com/luxfi/graph/resolvers/governance"
"github.com/luxfi/graph/resolvers/identity"
"github.com/luxfi/graph/resolvers/key"
"github.com/luxfi/graph/resolvers/liquid"
"github.com/luxfi/graph/resolvers/liquidity"
"github.com/luxfi/graph/resolvers/liquidprotocol"
"github.com/luxfi/graph/resolvers/mpc"
"github.com/luxfi/graph/resolvers/oracle"
"github.com/luxfi/graph/resolvers/platform"
"github.com/luxfi/graph/resolvers/precompile"
"github.com/luxfi/graph/resolvers/prediction"
"github.com/luxfi/graph/resolvers/privacy"
"github.com/luxfi/graph/resolvers/quantum"
"github.com/luxfi/graph/resolvers/relay"
"github.com/luxfi/graph/resolvers/securities"
"github.com/luxfi/graph/resolvers/servicenode"
"github.com/luxfi/graph/resolvers/treasury"
"github.com/luxfi/graph/resolvers/utxo"
"github.com/luxfi/graph/resolvers/zk"
"github.com/luxfi/graph/storage"
)
// Config controls query execution limits.
type Config struct {
MaxQueryDepth int `json:"maxQueryDepth" yaml:"max_query_depth"`
MaxTopFields int `json:"maxTopFields" yaml:"max_top_fields"`
MaxResultSize int `json:"maxResultSize" yaml:"max_result_size"`
QueryTimeoutMs int `json:"queryTimeoutMs" yaml:"query_timeout_ms"`
}
// A page that asks for several tables at once is ordinary, not abusive: the
// Explore transactions view alone requests 23. The cap exists to bound work per
// request, so it sits with the other limits rather than as a literal at the
// check.
const defaultMaxTopFields = 64
func (e *Engine) maxTopFields() int {
if e.config == nil || e.config.MaxTopFields <= 0 {
return defaultMaxTopFields
}
return e.config.MaxTopFields
}
// Engine executes GraphQL queries against indexed data.
type Engine struct {
store *storage.Store
config *Config
timeout time.Duration
mu sync.RWMutex
resolvers map[string]ResolverFunc
schemas map[string]*Schema
}
// Schema represents a deployed subgraph schema.
type Schema struct {
Name string `json:"name" yaml:"name"`
Version string `json:"version" yaml:"version"`
Source string `json:"source" yaml:"source"` // GraphQL SDL
Types []string `json:"types" yaml:"types"`
}
// ResolverFunc resolves a GraphQL field from storage.
type ResolverFunc func(ctx context.Context, store *storage.Store, args map[string]interface{}) (interface{}, error)
// Request is an incoming GraphQL request.
type Request struct {
Query string `json:"query"`
OperationName string `json:"operationName,omitempty"`
Variables map[string]interface{} `json:"variables,omitempty"`
}
// Response is a GraphQL response.
type Response struct {
Data interface{} `json:"data,omitempty"`
Errors []Error `json:"errors,omitempty"`
}
// Error is a GraphQL error.
type Error struct {
Message string `json:"message"`
}
// New creates a query engine backed by the given store.
func New(store *storage.Store, cfg *Config) *Engine {
if cfg == nil {
cfg = &Config{MaxQueryDepth: 10, MaxTopFields: defaultMaxTopFields, MaxResultSize: 1 << 20, QueryTimeoutMs: 30000}
}
e := &Engine{
store: store,
config: cfg,
timeout: time.Duration(cfg.QueryTimeoutMs) * time.Millisecond,
resolvers: make(map[string]ResolverFunc),
schemas: make(map[string]*Schema),
}
e.registerBuiltinResolvers()
return e
}
// RegisterResolver adds a named resolver.
func (e *Engine) RegisterResolver(name string, fn ResolverFunc) {
e.mu.Lock()
defer e.mu.Unlock()
e.resolvers[name] = fn
}
// LoadBuiltin loads a built-in schema by name.
func (e *Engine) LoadBuiltin(name string) error {
switch name {
case "amm", "amm-v2", "uniswap-v2":
e.registerAMMResolvers()
return nil
case "amm-v3", "uniswap-v3":
e.registerAMMResolvers()
return nil
case "amm-v4", "uniswap-v4", "v4":
e.registerAMMResolvers()
e.registerV4Resolvers()
return nil
case "dex":
e.registerChain(dex.Register)
return nil
case "fhe", "threshold":
e.registerChain(fhe.Register)
return nil
case "platform", "pchain":
e.registerChain(platform.Register)
return nil
case "exchange", "xchain":
e.registerChain(exchange.Register)
return nil
case "utxo":
e.registerChain(utxo.Register)
return nil
case "zk", "zchain":
e.registerChain(zk.Register)
return nil
case "mpc", "mchain":
e.registerChain(mpc.Register)
return nil
case "bridge", "bchain":
e.registerChain(bridge.Register)
return nil
case "privacy":
e.registerChain(privacy.Register)
return nil
case "quantum", "qchain":
e.registerChain(quantum.Register)
return nil
case "key", "kchain":
e.registerChain(key.Register)
return nil
case "ai", "achain":
e.registerChain(ai.Register)
return nil
case "identity", "ichain":
e.registerChain(identity.Register)
return nil
case "oracle", "ochain":
e.registerChain(oracle.Register)
return nil
case "relay", "rchain":
e.registerChain(relay.Register)
return nil
case "servicenode", "schain":
e.registerChain(servicenode.Register)
return nil
case "precompile", "precompiles":
e.registerChain(precompile.Register)
return nil
case "governance":
e.registerChain(governance.Register)
return nil
case "dao":
e.registerChain(dao.Register)
return nil
case "treasury":
e.registerChain(treasury.Register)
return nil
case "liquid", "liquid-staking":
e.registerChain(liquid.Register)
return nil
case "liquidity", "liquidity-protocol", "omnichain":
e.registerChain(liquidity.Register)
return nil
case "liquid-protocol", "teleport", "liquid-vault":
e.registerChain(liquidprotocol.Register)
return nil
case "did", "did-registry":
e.registerChain(did.Register)
return nil
case "prediction", "prediction-market":
e.registerChain(prediction.Register)
return nil
case "securities", "security-token":
e.registerChain(securities.Register)
return nil
case "derivatives", "futures", "options":
e.registerChain(derivatives.Register)
return nil
case "all":
e.registerAMMResolvers()
e.registerV4Resolvers()
e.registerERC20Resolvers()
e.registerERC721Resolvers()
e.registerChain(dex.Register)
e.registerChain(fhe.Register)
e.registerChain(platform.Register)
e.registerChain(exchange.Register)
e.registerChain(utxo.Register)
e.registerChain(bridge.Register)
e.registerChain(privacy.Register)
e.registerChain(quantum.Register)
e.registerChain(key.Register)
e.registerChain(mpc.Register)
e.registerChain(ai.Register)
e.registerChain(identity.Register)
e.registerChain(oracle.Register)
e.registerChain(relay.Register)
e.registerChain(zk.Register)
e.registerChain(servicenode.Register)
e.registerChain(precompile.Register)
e.registerChain(governance.Register)
e.registerChain(dao.Register)
e.registerChain(treasury.Register)
e.registerChain(liquid.Register)
e.registerChain(did.Register)
e.registerChain(prediction.Register)
e.registerChain(securities.Register)
e.registerChain(derivatives.Register)
e.registerChain(liquidity.Register)
e.registerChain(liquidprotocol.Register)
return nil
case "erc20":
e.registerERC20Resolvers()
return nil
case "erc721":
e.registerERC721Resolvers()
return nil
default:
return fmt.Errorf("unknown built-in schema: %s", name)
}
}
// LoadConfig loads subgraph configuration from a YAML file.
func (e *Engine) LoadConfig(path string) error {
// TODO: parse subgraph.yaml, register event handlers + resolvers
return fmt.Errorf("config loading not yet implemented: %s", path)
}
// Execute runs a GraphQL query.
func (e *Engine) Execute(ctx context.Context, req *Request) *Response {
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
query := strings.TrimSpace(req.Query)
if query == "" {
return &Response{Errors: []Error{{Message: "empty query"}}}
}
if len(query) > 100000 {
return &Response{Errors: []Error{{Message: "query too large"}}}
}
if strings.HasPrefix(strings.ToLower(query), "mutation") {
return &Response{Errors: []Error{{Message: "mutations not allowed — g-node is read-only"}}}
}
// Query depth check: count max nested brace depth, reject > MaxQueryDepth
if depth := queryDepth(query); depth > e.config.MaxQueryDepth {
return &Response{Errors: []Error{{Message: fmt.Sprintf("query depth %d exceeds maximum %d", depth, e.config.MaxQueryDepth)}}}
}
// Parse top-level fields
fields, err := parseTopFields(query)
if err != nil {
return &Response{Errors: []Error{{Message: err.Error()}}}
}
if max := e.maxTopFields(); len(fields) > max {
return &Response{Errors: []Error{{Message: fmt.Sprintf("too many top-level fields (%d), maximum %d", len(fields), max)}}}
}
data := make(map[string]interface{})
for _, f := range fields {
e.mu.RLock()
resolver, ok := e.resolvers[f.name]
e.mu.RUnlock()
if !ok {
return &Response{Errors: []Error{{Message: fmt.Sprintf("unknown field: %s", f.name)}}}
}
result, err := resolver(ctx, e.store, f.args)
if err != nil {
return &Response{Errors: []Error{{Message: err.Error()}}}
}
key := f.name
if f.alias != "" {
key = f.alias
}
data[key] = result
}
encoded, _ := json.Marshal(data)
if len(encoded) > e.config.MaxResultSize {
return &Response{Errors: []Error{{Message: "result exceeds maximum size"}}}
}
return &Response{Data: data}
}
// HandleGraphQL is the HTTP handler for POST /graphql.
func (e *Engine) HandleGraphQL(w http.ResponseWriter, r *http.Request) {
var req Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"errors":[{"message":"invalid JSON"}]}`, http.StatusBadRequest)
return
}
resp := e.Execute(r.Context(), &req)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// HandleGraphiQL serves the GraphiQL IDE for GET /graphql.
func (e *Engine) HandleGraphiQL(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, graphiqlHTML)
}
// registerChain registers resolvers from a chain-specific package.
// Uses a shim map to bridge the type alias boundary.
func (e *Engine) registerChain(register func(map[string]func(context.Context, *storage.Store, map[string]interface{}) (interface{}, error))) {
shim := make(map[string]func(context.Context, *storage.Store, map[string]interface{}) (interface{}, error))
register(shim)
e.mu.Lock()
defer e.mu.Unlock()
for k, v := range shim {
e.resolvers[k] = v
}
}
// registerBuiltinResolvers adds core blockchain resolvers.
func (e *Engine) registerBuiltinResolvers() {
e.resolvers["block"] = e.resolveBlock
e.resolvers["blocks"] = e.resolveBlocks
e.resolvers["transaction"] = e.resolveTransaction
e.resolvers["transactions"] = e.resolveTransactions
e.resolvers["token"] = e.resolveToken
e.resolvers["tokens"] = e.resolveTokens
}
// field represents a parsed top-level GraphQL field.
type field struct {
name string
alias string
args map[string]interface{}
}
// parseTopFields extracts the top-level fields of a GraphQL query.
//
// A field ends where its own parts end — its arguments, then its selection set
// — not at a newline. Whitespace between fields is whitespace: a document is
// the same document pretty-printed or minified, and a client that sends one
// line is entitled to the same answer as one that sends ten.
//
// Splitting on newlines instead made a single-line document parse as ONE field,
// name taken up to the first paren, and every field after it was dropped
// without an error. The explore page asks for its bundle, factory, tokens,
// pools and day series in one document; minified, it would have been answered
// with the bundle alone and no sign anything was missing.
func parseTopFields(query string) ([]field, error) {
start := strings.Index(query, "{")
end := strings.LastIndex(query, "}")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("invalid query: missing braces")
}
body := strings.TrimSpace(query[start+1 : end])
if body == "" {
return nil, fmt.Errorf("empty selection set")
}
var fields []field
for i := 0; i < len(body); {
if isSpace(body[i]) || body[i] == ',' {
i++
continue
}
fieldStart := i
i = skipSpace(body, scanName(body, i))
if i < len(body) && body[i] == ':' { // `alias: name`
i = skipSpace(body, scanName(body, skipSpace(body, i+1)))
}
if i < len(body) && body[i] == '(' {
closed := findMatchingParen(body, i)
if closed <= i {
return nil, fmt.Errorf("unclosed arguments")
}
i = skipSpace(body, closed+1)
}
if i < len(body) && body[i] == '{' {
closed := findMatchingBrace(body, i)
if closed <= i {
return nil, fmt.Errorf("unclosed selection set")
}
i = closed + 1
}
// Every pass must consume something. A scanner that can stand still on
// an input it does not understand does not return a wrong answer — it
// returns none, forever, holding the request open. This is a public
// endpoint; the parser refuses the query instead.
if i == fieldStart {
return nil, fmt.Errorf("cannot parse field at offset %d", fieldStart)
}
if f, err := parseField(body[fieldStart:i]); err == nil && f.name != "" {
fields = append(fields, f)
}
}
if len(fields) == 0 {
return nil, fmt.Errorf("no fields in query")
}
return fields, nil
}
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
// scanName advances past a field or alias name — everything up to the
// punctuation that can follow one.
func scanName(s string, i int) int {
for i < len(s) && !isSpace(s[i]) && s[i] != '(' && s[i] != '{' && s[i] != ':' && s[i] != ',' {
i++
}
return i
}
func skipSpace(s string, i int) int {
for i < len(s) && isSpace(s[i]) {
i++
}
return i
}
// findMatchingBrace finds the '}' closing the '{' at pos, respecting nesting
// and quoted strings.
func findMatchingBrace(s string, pos int) int {
depth := 0
inQuote := false
for i := pos; i < len(s); i++ {
ch := s[i]
if ch == '"' && (i == 0 || s[i-1] != '\\') {
inQuote = !inQuote
continue
}
if inQuote {
continue
}
switch ch {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
func parseField(s string) (field, error) {
s = strings.TrimSpace(s)
if s == "" {
return field{}, fmt.Errorf("empty field")
}
f := field{args: make(map[string]interface{})}
// Check for alias — only if colon appears before any paren/brace
if idx := strings.Index(s, ":"); idx > 0 && !strings.Contains(s[:idx], "(") && !strings.Contains(s[:idx], "{") {
f.alias = strings.TrimSpace(s[:idx])
s = strings.TrimSpace(s[idx+1:])
}
// Extract name (before args or subselection)
nameEnd := len(s)
for i, ch := range s {
if ch == '(' || ch == '{' || ch == ' ' {
nameEnd = i
break
}
}
f.name = s[:nameEnd]
// Extract args from (...) — brace-aware to handle nested objects like where: { pool: "0x..." }
if parenStart := strings.Index(s, "("); parenStart != -1 {
parenEnd := findMatchingParen(s, parenStart)
if parenEnd > parenStart {
argsStr := s[parenStart+1 : parenEnd]
f.args = parseArgs(argsStr)
}
}
return f, nil
}
// findMatchingParen finds the closing ')' that matches the '(' at pos,
// respecting nested braces and quoted strings.
func findMatchingParen(s string, pos int) int {
depth := 0
inQuote := false
for i := pos; i < len(s); i++ {
ch := s[i]
if ch == '"' && (i == 0 || s[i-1] != '\\') {
inQuote = !inQuote
continue
}
if inQuote {
continue
}
switch ch {
case '(', '{':
depth++
case ')':
depth--
if depth == 0 {
return i
}
case '}':
depth--
}
}
return -1
}
// parseArgs splits a top-level argument string into key-value pairs,
// respecting nested braces so that `where: { pool: "0x...", date_gte: 123 }`
// is kept as a single value for key "where".
func parseArgs(s string) map[string]interface{} {
args := make(map[string]interface{})
s = strings.TrimSpace(s)
if s == "" {
return args
}
for len(s) > 0 {
// Find key (up to first ':')
colonIdx := -1
for i := 0; i < len(s); i++ {
if s[i] == ':' {
colonIdx = i
break
}
}
if colonIdx < 0 {
break
}
key := strings.TrimSpace(s[:colonIdx])
s = strings.TrimSpace(s[colonIdx+1:])
// Parse value — could be quoted string, nested object, or bare token
var val interface{}
if len(s) == 0 {
break
}
switch s[0] {
case '"':
// Quoted string — find closing quote
end := 1
for end < len(s) {
if s[end] == '"' && s[end-1] != '\\' {
break
}
end++
}
if end < len(s) {
val = s[1:end]
s = strings.TrimSpace(s[end+1:])
} else {
val = s[1:]
s = ""
}
case '{':
// Nested object — find matching '}'
depth := 0
end := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
end = i
goto foundBrace
}
}
}
end = len(s) - 1
foundBrace:
inner := strings.TrimSpace(s[1:end])
val = parseArgs(inner)
s = strings.TrimSpace(s[end+1:])
default:
// Bare token (number, enum, etc.) — read until comma or end
end := len(s)
for i := 0; i < len(s); i++ {
if s[i] == ',' {
end = i
break
}
}
val = strings.TrimSpace(s[:end])
s = strings.TrimSpace(s[end:])
}
// Strip leading comma separator
if len(s) > 0 && s[0] == ',' {
s = strings.TrimSpace(s[1:])
}
args[key] = val
}
return args
}
// queryDepth counts the maximum nesting depth of braces in the query.
// Each `{` increments depth, each `}` decrements. Ignores quoted strings.
func queryDepth(query string) int {
maxDepth := 0
depth := 0
inQuote := false
for i := 0; i < len(query); i++ {
ch := query[i]
if ch == '"' && (i == 0 || query[i-1] != '\\') {
inQuote = !inQuote
continue
}
if inQuote {
continue
}
switch ch {
case '{':
depth++
if depth > maxDepth {
maxDepth = depth
}
case '}':
depth--
}
}
return maxDepth
}
const graphiqlHTML = `<!DOCTYPE html>
<html><head><title>g-node GraphQL</title>
<link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css"/>
</head><body style="margin:0;overflow:hidden">
<div id="graphiql" style="height:100vh"></div>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/graphiql@3/graphiql.min.js" crossorigin></script>
<script>
ReactDOM.createRoot(document.getElementById('graphiql')).render(
React.createElement(GraphiQL, {
fetcher: GraphiQL.createFetcher({url: '/graphql'}),
defaultEditorToolsVisibility: true,
})
);
</script></body></html>`