Files
graph/cmd/graphd/main.go
T
zeekay 705e38c45e fix(indexer): gate V2/V3 AMM handlers to canonical factory; floor parseLimit everywhere
Red re-verify found the V4 AMM-spoof gate left a parallel bypass.

MEDIUM — V2/V3 AMM handlers were UNGATED: handlePairCreated, handlePoolCreated,
handleSwapV2, handleSwapV3 accepted events from ANY emitter — a parallel path to
seed phantom pairs/pools/swaps and inflate the SAME public Factory/TVL/volume
aggregates the now-gated V4 handlers (0x9999) block. Mirror the V4 gate:
  - PairCreated/PoolCreated now require the canonical FactoryV2/FactoryV3 emitter
    (the AMM trust root), exactly as V4 requires 0x9999.
  - V2/V3 Swaps are emitted by the pair/pool, not the factory, so the gate is
    "is the emitter a pool the canonical factory created?" (isKnownPool). The
    factory authorizes a pool; only that pool's Swaps are honored. A rogue
    factory's PairCreated is rejected, so its pools are never registered, so its
    Swaps are rejected — the whole parallel path is closed.
  - Pair/pool keys lower-cased at seed so the Swap-side lookup is RPC-casing safe.
  - Factory addresses configurable (Config.FactoryV2/V3 + FACTORY_V2/V3 env),
    default to canonical Lux mainnet (LuxMainnet.Factory{V2,V3}); main wires env.

INFO — the per-namespace resolver limit lacked the `if limit < 1 { limit = 100 }`
floor that resolvers/dex has, so a negative/zero `first` flowed to ListByType
(`first:-1` = "no limit" in SQLite — an unbounded scan). Brought every pl()/
parseLimit() copy to the dex-canonical body (floor <1->100 + cap 1000); fhe and
platform also gained the previously-missing 1000 cap. All namespaces now identical.

Tests (go test ./...): indexer + resolvers/{dex,exchange,fhe,platform} green.
  - TestHandleV2_RejectsNonFactory, TestHandleV3_RejectsNonFactory: rogue
    PairCreated/PoolCreated/Swap seeds nothing and does not inflate the factory
    aggregate; the canonical-source event IS honored end-to-end.
  - TestPL_ClampsFirst / TestParseLimit_ClampsFirst (exchange/fhe/platform):
    negative/zero floored to 100, capped at 1000.
2026-06-21 20:27:48 -07:00

297 lines
8.9 KiB
Go

// graphd — standalone GraphQL daemon for any EVM chain.
//
// Connects to an RPC endpoint, indexes events into local SQLite/ZapDB,
// serves a GraphQL API compatible with The Graph subgraph queries.
// Also runs as a Lux VM (G-Chain) when loaded by luxd as a plugin.
//
// Standalone:
//
// graphd --rpc=http://node:8545 # any EVM
// graphd --rpc=http://node:9650/ext/bc/{chainID}/rpc # any Lux chain
// graphd --config=subgraphs.yaml # multi-subgraph
// graphd --rpc=http://node:8545 --schema=uniswap-v2 # specific schema
//
// Scale horizontally: run N graphd nodes behind a load balancer.
// Each indexes independently into its own local storage.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/luxfi/graph/engine"
"github.com/luxfi/graph/indexer"
"github.com/luxfi/graph/storage"
)
var version = "dev"
func main() {
var (
rpcEndpoint = flag.String("rpc", "", "EVM JSON-RPC endpoint")
httpAddr = flag.String("http", ":8080", "GraphQL HTTP listen address")
dataDir = flag.String("data", "", "Data directory (default: ~/.graph/data)")
schemaName = flag.String("schema", "", "Built-in schema: amm, amm-v2, amm-v3, amm-v4, erc20, erc721, all")
configFile = flag.String("config", "", "Subgraph config YAML")
showVersion = flag.Bool("version", false, "Show version")
)
flag.Parse()
if *showVersion {
fmt.Printf("graph %s\n", version)
os.Exit(0)
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
if *rpcEndpoint == "" {
*rpcEndpoint = os.Getenv("RPC_ENDPOINT")
}
if *rpcEndpoint == "" {
fmt.Fprintln(os.Stderr, "graph: --rpc or RPC_ENDPOINT required")
os.Exit(1)
}
if *dataDir == "" {
*dataDir = envOr("DATA_DIR", filepath.Join(homeDir(), ".graph", "data"))
}
slog.Info("starting", "version", version, "rpc", *rpcEndpoint)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() { <-sig; cancel() }()
// Storage: SQLite WAL (default) or in-memory (nosqlite build)
store, err := storage.New(*dataDir)
if err != nil {
slog.Error("storage", "error", err)
os.Exit(1)
}
defer store.Close()
if err := store.Init(ctx); err != nil {
slog.Error("storage init", "error", err)
os.Exit(1)
}
// WAL streaming to S3 with PQ encryption (noop if REPLICATE_S3_ENDPOINT unset)
stopReplicate := storage.StartReplicate(filepath.Join(*dataDir, "graph.db"))
defer stopReplicate()
// Indexer: subscribe to EVM events via RPC. The AMM trust roots (V2/V3
// factories) and the 0x9999 settlement manager default to the canonical Lux
// mainnet addresses; override per-network (e.g. Zoo) via env. Events from any
// other emitter are rejected so they cannot seed phantom pairs/pools/swaps or
// inflate the public Factory/TVL/volume aggregates.
idx := indexer.NewWithConfig(indexer.Config{
RPC: *rpcEndpoint,
FactoryV2: os.Getenv("FACTORY_V2"),
FactoryV3: os.Getenv("FACTORY_V3"),
PoolManager: os.Getenv("POOL_MANAGER"),
}, store)
go func() {
if err := idx.Run(ctx); err != nil && ctx.Err() == nil {
slog.Error("indexer", "error", err)
os.Exit(1)
}
}()
// Query engine: GraphQL over indexed data
eng := engine.New(store, &engine.Config{
MaxQueryDepth: 10,
MaxResultSize: 1 << 20, // 1MB
QueryTimeoutMs: 30000,
})
// Load schema
if *configFile != "" {
if err := eng.LoadConfig(*configFile); err != nil {
slog.Error("config", "error", err)
os.Exit(1)
}
} else if *schemaName != "" {
if err := eng.LoadBuiltin(*schemaName); err != nil {
slog.Error("schema", "name", *schemaName, "error", err)
os.Exit(1)
}
} else {
// Default: full DEX schema (v2+v3 compatible)
eng.LoadBuiltin("amm")
}
m := &metrics{}
// All components mount under /v1/explorer. Override via GRAPH_PREFIX.
prefix := os.Getenv("GRAPH_PREFIX")
if prefix == "" {
prefix = "/v1/explorer"
}
mux := http.NewServeMux()
// GraphQL endpoint with MaxBytesReader (10MB) and metrics tracking
mux.HandleFunc("POST "+prefix+"/graphql", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10MB
m.queryCount.Add(1)
m.blockHeight.Store(int64(idx.Status().LatestBlock))
var req engine.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
m.queryErrors.Add(1)
http.Error(w, `{"errors":[{"message":"invalid JSON"}]}`, http.StatusBadRequest)
return
}
resp := eng.Execute(r.Context(), &req)
if len(resp.Errors) > 0 {
m.queryErrors.Add(1)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("GET "+prefix+"/graphql", eng.HandleGraphiQL)
// /ql — canonical short alias for /graphql. Same payload shape, less typing.
// Short-alias convention: /v1/graph/ql.
mux.HandleFunc("POST "+prefix+"/ql", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
m.queryCount.Add(1)
m.blockHeight.Store(int64(idx.Status().LatestBlock))
var req engine.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
m.queryErrors.Add(1)
http.Error(w, `{"errors":[{"message":"invalid JSON"}]}`, http.StatusBadRequest)
return
}
resp := eng.Execute(r.Context(), &req)
if len(resp.Errors) > 0 {
m.queryErrors.Add(1)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("GET "+prefix+"/ql", eng.HandleGraphiQL)
mux.HandleFunc("GET "+prefix+"/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
status := idx.Status()
fmt.Fprintf(w, `{"status":"ok","block":%d,"indexed":%d}`, status.LatestBlock, status.IndexedEvents)
})
mux.HandleFunc("GET "+prefix+"/ready", func(w http.ResponseWriter, r *http.Request) {
status := idx.Status()
w.Header().Set("Content-Type", "application/json")
if status.LatestBlock == 0 {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprint(w, `{"ready":false,"reason":"indexer has not processed any blocks"}`)
return
}
fmt.Fprintf(w, `{"ready":true,"block":%d}`, status.LatestBlock)
})
mux.HandleFunc("GET "+prefix+"/metrics", m.handleMetrics)
mux.HandleFunc("GET "+prefix+"/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"name":"graph","version":"%s","graphql":"%s/graphql"}`, version, prefix)
})
// Middleware stack: security headers + CORS -> request logging -> rate limiting
rl := newRateLimiter(100, 100) // 100 req/s per IP, burst 100
handler := securityMiddleware(mux)
handler = loggingMiddleware(handler)
handler = rateLimitMiddleware(rl, handler)
srv := &http.Server{
Addr: *httpAddr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20, // 1MB
}
go func() {
slog.Info("listening", "addr", *httpAddr, "graphql", fmt.Sprintf("http://localhost%s/graphql", *httpAddr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("http", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
slog.Info("stopped")
}
// securityMiddleware adds CORS and security headers.
func securityMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// loggingMiddleware logs every request with method, path, status, and duration.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (sr *statusRecorder) WriteHeader(code int) {
sr.status = code
sr.ResponseWriter.WriteHeader(code)
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sr := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(sr, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", sr.status,
"duration_ms", time.Since(start).Milliseconds(),
"ip", clientIP(r),
)
})
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func homeDir() string {
h, _ := os.UserHomeDir()
return h
}