Files
graph/cmd/graphd/main.go
T
zeekayandHanzo Dev cb4dc0c059 price every token on the wire, not just in the pass
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]>
2026-08-10 20:53:24 -07:00

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
}