mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
Every already-indexed token had a symbol and no supply, so market cap and fully diluted value printed a dash however much the asset traded. The read existed; nothing ever reached it. The cold-start guard treated a real symbol as proof the row was complete, so a token seen once was never asked about again — and the row it adopted dropped the supply field on the way through. One predicate now says whether a row still has something to ask the chain: a real symbol AND a supply. One resolver answers it — store first, contract for the rest, memoised per address — and one merge folds a fresh read over what is already known, so a reverting call never erases a symbol and never erases the value locked, volume and price the valuation pass put on the same row. That last one was live: SeedToken is INSERT OR REPLACE and the write was built from the contract read alone. And one unit. The contract reports base units, the native coin's genesis figure is already whole, and both landed in the same field — a client that scales one and not the other is a valuation 10^18 out. The field is whole tokens now, converted where it is read, exactly: integer division and the remainder, because float64 gives up four orders of magnitude below a two-trillion-token supply. The backfill is what a start does rather than a flag to remember. Co-authored-by: Hanzo Dev <[email protected]>
319 lines
10 KiB
Go
319 lines
10 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 := 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", handleSwappableTokens(store, chainID))
|
|
slog.Info("token list served", "path", prefix+"/swappable_tokens", "chain", chainID)
|
|
}
|
|
|
|
// /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
|
|
}
|