mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
The valuation pass has always priced every token in USD — that is where TVL and volume come from — but it published none of it. The subgraph format carries a price only as the product `token.derivedETH * bundle.ethPriceUSD`, and both sides were left unwritten: mainnet served derivedETH="" for all 8 tokens against a null bundle. lux.exchange and the explorer therefore rendered real TVL and real volume beside a blank price and no market cap, on every market. "ETH" is the wire's name for the chain's own coin, so the bundle now carries the native coin's USD price and each token its price in native units. The two multiply back to exactly the USD price the pass computed, and the wrapped native quotes 1 of itself by construction. No second price source, no oracle: one relaxation, two projections of it. Unpriced stays unpriced. A token no pool connects to a stable publishes an empty derivedETH and no bundle is written at all — a zero there would render every token free rather than unquoted. fmtUSD could not carry these numbers: it is a CURRENCY format, and at two decimals LUX at $0.00041334 renders "0.00". fmtPrice keeps 12 significant digits. Wrapped-native address is per-chain (Zoo's differs), so it follows the factories' existing pattern — Config field, Lux mainnet default, env override. Tests run under -tags nosqlite: the default store links two sqlite C implementations (mattn + sqlite3vfs) and 283 duplicate symbols make the test binary unlinkable, which is why the swap-count tests added earlier had never once executed. They pass now, along with the new ones. Removing the SeedBundle call fails TestPricesReachTheWire with "bundle absent", so the test tracks the publication and not a reimplementation of it. Co-authored-by: Hanzo Dev <[email protected]>
310 lines
9.6 KiB
Go
310 lines
9.6 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)
|
|
// One-shot enrichment of Token rows persisted by an older build with the
|
|
// address placeholder (symbol == shortAddr). Opt-in via BACKFILL_TOKENS=1 so
|
|
// a normal start is unchanged; runs before Run so live indexing is not racing
|
|
// the same SeedToken writes. Cheap: ≤3 eth_calls per placeholder token, no
|
|
// re-sync. New tokens are enriched on first sight regardless of this flag.
|
|
if os.Getenv("BACKFILL_TOKENS") == "1" {
|
|
if n, err := idx.BackfillTokens(ctx); err != nil {
|
|
slog.Warn("token backfill", "error", err, "enriched", n)
|
|
} else {
|
|
slog.Info("token backfill", "enriched", 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)
|
|
|
|
// /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
|
|
}
|