mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
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.
297 lines
8.9 KiB
Go
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
|
|
}
|