mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
The list was every Token the indexer had ever written, and a Uniswap-v2 PAIR is itself an ERC-20 — so are v3 position receipts, vault shares and an NFT collection. All four were offered in the picker as things to trade. Pool membership excludes them without naming any of them, and is the honest rule anyway: you can trade a token exactly when there is somewhere to trade it. It also never offered the chain's own coin. The form sells native LUX through the zero address, so without that row the commonest trade on the exchange cannot be expressed at all. The sentinel takes the wrapped token's decimals, because a scale that disagrees misprices every trade starting in native, and it appears only when a pool actually holds the wrapped token. Co-authored-by: Hanzo Dev <[email protected]>
327 lines
11 KiB
Go
327 lines
11 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/v1/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"),
|
|
Native: os.Getenv("WRAPPED_NATIVE"),
|
|
}, store)
|
|
// What a start is for: ask the chain about the rows that are still short an
|
|
// answer. Before Run, so live indexing is not racing the same writes. A row
|
|
// with everything on it costs nothing, which is why this is simply what a
|
|
// start does rather than something to remember to turn on.
|
|
if n, err := idx.BackfillTokens(ctx); err != nil {
|
|
slog.Warn("token backfill", "error", err, "settled", n)
|
|
} else {
|
|
slog.Info("token backfill", "settled", n)
|
|
}
|
|
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)
|
|
|
|
// The swap form's token list, served from the table this process indexes
|
|
// rather than from a forwarder in front of it. The chain answers for itself
|
|
// (eth_chainId) so nothing here has a second opinion about which chain it is;
|
|
// if the node cannot say, the route is not registered and the client keeps
|
|
// the endpoint it has rather than being handed a list labelled with a guess.
|
|
if chainID, err := engine.ChainIDFromRPC(ctx, *rpcEndpoint); err != nil {
|
|
slog.Warn("token list not served: the chain did not identify itself", "err", err)
|
|
} else {
|
|
mux.HandleFunc("GET "+prefix+"/swappable_tokens", engine.HandleSwappableTokens(store, chainID, os.Getenv("COIN"), os.Getenv("WRAPPED_NATIVE")))
|
|
slog.Info("token list served", "path", prefix+"/swappable_tokens", "chain", chainID)
|
|
|
|
// What a swap would pay, over the same book. QUOTER_V2 is the V3
|
|
// periphery quoter — an address of a deployed contract, alongside the
|
|
// factories above, not a switch: a chain without one has no V3 venue to
|
|
// ask, so those pools go unquoted and the answer says so.
|
|
q := engine.NewQuoter(store, chainID, *rpcEndpoint, os.Getenv("QUOTER_V2"), os.Getenv("WRAPPED_NATIVE"))
|
|
mux.HandleFunc("POST "+prefix+"/quote", engine.HandleQuote(q))
|
|
slog.Info("quotes served", "path", prefix+"/quote", "chain", chainID, "quoter", os.Getenv("QUOTER_V2"))
|
|
}
|
|
|
|
// /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
|
|
}
|