mirror of
https://github.com/luxfi/explorer.git
synced 2026-09-24 00:25:12 +00:00
One binary answers explore.lux.network, explore.hanzo.network and
explore.zoo.network. Only /envs.js and the slug-prefixed /v1/indexer/{slug}
routes knew that. Everything else read whichever chain the config called
default, so Hanzo's home page printed 1,107,383 total blocks above its own
Latest blocks list topping out at 29,805 — Lux's number, on Hanzo's page,
overstating Hanzo's chain 37x and Zoo's 49x. Measured on all three brands.
Config.Serves(host) is now the one thing that answers "which chain is this
request about", and the three surfaces that were guessing ask it:
* StatsService keeps a read handle per chain instead of one pinned at
startup, so /v1/counters, /v1/lines, /v1/pages/* and /v1/stats report the
host's chain: 1107259 / 29805 / 22728 on the three brand hosts, where all
three read 1107259 before.
* The realtime stream carries the host's chain. It carried all of them: a
45s read of /v1/base/realtime returned the same 6 cchain + 14 hanzo + 5 zoo
events on every brand host, and the SPA appends what it is sent, so a
50-block page rendered 63 rows with 2 Lux on top and 8 Zoo at the bottom.
?chain= still overrides; an empty filter no longer means "all chains",
because no brand host wants another brand's blocks.
* A slug-less /v1/indexer/* request follows the host, falling back to the
default only when the host names no chain (api-explore.lux.network) or
names one that is not up.
Also here, because they were all the same mistake about the same column: the
timestamp in evm_blocks is a Go time.Time bound into a column declared
TIMESTAMP, so SQLite stores a datetime STRING, and everything that read it as
a unix epoch got nothing and said nothing.
* /stats' daily charts were empty on all three brands. strftime with the
'unixepoch' modifier returns NULL on a datetime string, the scan failed,
and handlePagesMain discarded the error into `daily, _ :=` — a nil slice
marshals to `null`, which the page renders as "No data". Now 22 points.
* Average block time read 0.0s on a chain producing a block every 10s: the
three-value Scan failed and its error was never checked, so the zero went
out as the answer. Now 60.0 on a one-block-per-minute fixture.
* /v1/blocks reported `"timestamp": 2026` for every block — CAST of a
datetime string takes its leading digits, and 2026 looks like a number
rather than a failure. Now a real epoch.
* Transactions (24h) was the lifetime total. SQLite orders every INTEGER
below every TEXT, so `timestamp > <epoch>` was true for all 1.1M rows.
blockEpoch and blockDay are the only two spellings allowed to touch that
column now. The fixtures were the reason none of this failed a test: they
declared the column TIMESTAMP and then inserted integers into it, so the suite
exercised a database the indexer never writes. They bind a time.Time now, and
on the old queries they fail with exactly what production showed —
average_block_time "0.0", empty charts, timestamp 2026.
Deleted: the eight per-channel SSE routes at bare /blocks, /transactions,
/tokens, /gas-tracker, /stats, /validators, /token-transfers and /internal-txs.
Every one of those is also an SPA page path, and one mux cannot serve both; the
SPA opens only the multiplexed stream, so they had no subscriber to lose. With
them goes the second envelope, the per-channel framing built on every
broadcast, and the client's channel field — SSE has one endpoint and one shape.
indexer v1.5.3 for the block transaction-count name.
Co-authored-by: Hanzo Dev <[email protected]>
600 lines
19 KiB
Go
600 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"github.com/luxfi/graph/engine"
|
|
graphidx "github.com/luxfi/graph/indexer"
|
|
graphstor "github.com/luxfi/graph/storage"
|
|
|
|
"github.com/luxfi/indexer/chain"
|
|
"github.com/luxfi/indexer/evm"
|
|
"github.com/luxfi/indexer/explorer"
|
|
"github.com/luxfi/indexer/platform"
|
|
idxstor "github.com/luxfi/indexer/storage"
|
|
)
|
|
|
|
// ChainSupervisor owns the per-chain indexer + graph goroutines plus the
|
|
// route dispatcher that maps /v1/indexer/{slug}/* and /v1/graph/{slug}/*
|
|
// requests to the correct handler. Routes are mutable at runtime: adding a
|
|
// chain spawns its workers, removing a chain cancels them.
|
|
type ChainSupervisor struct {
|
|
dataDir string
|
|
cfg Config // resolves a request host to its chain; see Config.Serves
|
|
mu sync.Mutex
|
|
state map[string]*chainState
|
|
|
|
indexerRoutes sync.Map // map[slug]http.Handler
|
|
graphRoutes sync.Map // map[slug + "/" + subgraph]http.Handler
|
|
defaultSlug string // serves /v1/indexer/* when the host names no chain
|
|
|
|
// realtimeHub is the explorer's RealtimeHub. When set (via
|
|
// AttachRealtime), the supervisor wires each per-chain EVM indexer's
|
|
// Subscriber.OnBroadcast callback so block events flow into the hub
|
|
// → out to WebSocket + SSE subscribers. Nil = no live broadcast.
|
|
realtimeHub *RealtimeHub
|
|
}
|
|
|
|
type chainState struct {
|
|
cfg ChainConfig
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewChainSupervisor creates a supervisor rooted at cfg.DataDir.
|
|
func NewChainSupervisor(cfg Config) *ChainSupervisor {
|
|
return &ChainSupervisor{
|
|
dataDir: cfg.DataDir,
|
|
cfg: cfg,
|
|
state: make(map[string]*chainState),
|
|
}
|
|
}
|
|
|
|
// AttachRealtime wires per-chain block events into the explorer's
|
|
// RealtimeHub. Called once from main.go after both the supervisor and
|
|
// the registry's hub exist. Subsequent chain spawns (runChain below)
|
|
// install the Subscriber.OnBroadcast callback automatically.
|
|
func (s *ChainSupervisor) AttachRealtime(h *RealtimeHub) {
|
|
s.realtimeHub = h
|
|
}
|
|
|
|
// MountRoutes installs the /v1/indexer and /v1/graph dispatchers on mux. The
|
|
// dispatchers look up per-chain handlers from the supervisor's route maps so
|
|
// chains added at runtime become reachable without re-mounting.
|
|
func (s *ChainSupervisor) MountRoutes(mux *http.ServeMux) {
|
|
mux.Handle("/v1/indexer/", http.HandlerFunc(s.dispatchIndexer))
|
|
mux.Handle("/v1/explorer/", http.HandlerFunc(s.dispatchExplorerLegacy))
|
|
mux.Handle("/v1/graph/", http.HandlerFunc(s.dispatchGraph))
|
|
}
|
|
|
|
// SetDefaultSlug picks which chain serves the unprefixed /v1/indexer/* route.
|
|
func (s *ChainSupervisor) SetDefaultSlug(slug string) {
|
|
s.mu.Lock()
|
|
s.defaultSlug = slug
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// start spawns workers for cfg. Idempotent: starting an already-running slug
|
|
// is a no-op.
|
|
func (s *ChainSupervisor) start(cfg ChainConfig) {
|
|
s.mu.Lock()
|
|
if _, ok := s.state[cfg.Slug]; ok {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
st := &chainState{cfg: cfg, cancel: cancel}
|
|
s.state[cfg.Slug] = st
|
|
if cfg.Default && s.defaultSlug == "" {
|
|
s.defaultSlug = cfg.Slug
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
st.wg.Add(1)
|
|
go func() {
|
|
defer st.wg.Done()
|
|
s.runChain(ctx, cfg)
|
|
}()
|
|
|
|
if cfg.Graph.Enabled {
|
|
for _, sg := range cfg.Graph.Subgraphs {
|
|
if !sg.Enabled {
|
|
continue
|
|
}
|
|
sg := sg
|
|
st.wg.Add(1)
|
|
go func() {
|
|
defer st.wg.Done()
|
|
s.runSubgraph(ctx, cfg, sg)
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
// stop cancels and waits for all workers belonging to slug.
|
|
func (s *ChainSupervisor) stop(slug string) {
|
|
s.mu.Lock()
|
|
st, ok := s.state[slug]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
delete(s.state, slug)
|
|
if s.defaultSlug == slug {
|
|
s.defaultSlug = ""
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
st.cancel()
|
|
st.wg.Wait()
|
|
|
|
s.indexerRoutes.Delete(slug)
|
|
s.graphRoutes.Range(func(k, _ any) bool {
|
|
if key, ok := k.(string); ok && strings.HasPrefix(key, slug+"/") {
|
|
s.graphRoutes.Delete(k)
|
|
}
|
|
return true
|
|
})
|
|
log.Printf("[supervisor] stopped %s", slug)
|
|
}
|
|
|
|
// Wait blocks until ctx is done, then cancels every chain and waits for
|
|
// goroutines to drain.
|
|
func (s *ChainSupervisor) Wait(ctx context.Context) {
|
|
<-ctx.Done()
|
|
s.mu.Lock()
|
|
slugs := make([]string, 0, len(s.state))
|
|
for k := range s.state {
|
|
slugs = append(slugs, k)
|
|
}
|
|
s.mu.Unlock()
|
|
for _, slug := range slugs {
|
|
s.stop(slug)
|
|
}
|
|
}
|
|
|
|
// runChain runs one chain's indexer to completion or context cancel and then
|
|
// mounts the standalone explorer API once the SQLite schema is ready.
|
|
func (s *ChainSupervisor) runChain(ctx context.Context, cfg ChainConfig) {
|
|
chainDir := filepath.Join(s.dataDir, cfg.Slug)
|
|
if err := os.MkdirAll(chainDir, 0755); err != nil {
|
|
log.Printf("[%s] mkdir: %v", cfg.Slug, err)
|
|
return
|
|
}
|
|
store, err := idxstor.NewUnified(idxstor.DefaultUnifiedConfig(chainDir))
|
|
if err != nil {
|
|
log.Printf("[%s] storage: %v", cfg.Slug, err)
|
|
return
|
|
}
|
|
defer store.Close()
|
|
if err := store.Init(ctx); err != nil {
|
|
log.Printf("[%s] storage init: %v", cfg.Slug, err)
|
|
return
|
|
}
|
|
dbPath := filepath.Join(chainDir, "query", "indexer.db")
|
|
log.Printf("[%s] indexing %s (%s) -> %s", cfg.Slug, cfg.Name, cfg.CoinSymbol, dbPath)
|
|
|
|
go s.mountIndexerAPI(ctx, cfg, dbPath)
|
|
|
|
switch cfg.Type {
|
|
case "", "evm":
|
|
poll := 30 * time.Second
|
|
if cfg.Indexer.PollInterval != "" {
|
|
if d, err := time.ParseDuration(cfg.Indexer.PollInterval); err == nil {
|
|
poll = d
|
|
}
|
|
}
|
|
idx, err := evm.NewIndexer(evm.Config{
|
|
ChainName: cfg.Name,
|
|
ChainID: cfg.ChainID,
|
|
RPCEndpoint: cfg.RPC,
|
|
HTTPPort: 0,
|
|
PollInterval: poll,
|
|
StartBlock: cfg.Indexer.StartBlock,
|
|
}, store)
|
|
if err != nil {
|
|
log.Printf("[%s] evm indexer: %v", cfg.Slug, err)
|
|
return
|
|
}
|
|
// Live broadcast bridge — when the supervisor has been given a
|
|
// RealtimeHub (see AttachRealtime), every block the indexer
|
|
// commits is re-published into the hub on the chain's slug. The
|
|
// hub then fans out to WebSocket and SSE subscribers (see
|
|
// realtime.go + realtime_sse.go). slug captured by value so the
|
|
// closure doesn't see a mutated `cfg` if the supervisor loops.
|
|
if s.realtimeHub != nil && idx.Subscriber() != nil {
|
|
slug := cfg.Slug
|
|
hub := s.realtimeHub
|
|
idx.Subscriber().OnBroadcast = func(eventType string, data any) {
|
|
hub.Broadcast(eventType, slug, data)
|
|
}
|
|
}
|
|
if err := idx.Init(ctx); err != nil {
|
|
log.Printf("[%s] evm init: %v", cfg.Slug, err)
|
|
return
|
|
}
|
|
if err := idx.Run(ctx); err != nil && ctx.Err() == nil {
|
|
log.Printf("[%s] evm run: %v", cfg.Slug, err)
|
|
}
|
|
case "linear", "platform":
|
|
// P-Chain (platform VM) indexing. The generic chain indexer supplies
|
|
// the canonical pchain_blocks schema; the platform adapter supplies the
|
|
// validator/delegator/etc schema and the RPC block/validator fetchers.
|
|
poll := 30 * time.Second
|
|
if cfg.Indexer.PollInterval != "" {
|
|
if d, err := time.ParseDuration(cfg.Indexer.PollInterval); err == nil {
|
|
poll = d
|
|
}
|
|
}
|
|
adapter := platform.New(cfg.RPC)
|
|
chainCfg := platform.NewConfig() // ChainType=pchain -> pchain_blocks table
|
|
chainCfg.ChainName = cfg.Name
|
|
chainCfg.RPCEndpoint = cfg.RPC
|
|
chainCfg.PollInterval = poll
|
|
idx, err := chain.New(chainCfg, store, adapter)
|
|
if err != nil {
|
|
log.Printf("[%s] platform indexer: %v", cfg.Slug, err)
|
|
return
|
|
}
|
|
// Init once: creates pchain_blocks (generic schema) and the adapter's
|
|
// pchain_validators/pchain_delegators/... tables. We drive our own poll
|
|
// loop rather than idx.Run() so we don't stand up the chain indexer's
|
|
// WS-gated poller + HTTP server (the standalone API reads the SQLite DB
|
|
// directly). Blocks are written via store.StoreBlock, not idx.StoreBlock,
|
|
// to avoid the indexer's subscriber broadcast (nothing drains it here).
|
|
if err := idx.Init(ctx); err != nil {
|
|
log.Printf("[%s] platform init: %v", cfg.Slug, err)
|
|
return
|
|
}
|
|
const platformBlocksTable = "pchain_blocks"
|
|
sync := func() {
|
|
raw, err := adapter.GetRecentBlocks(ctx, 50)
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
log.Printf("[%s] platform blocks: %v", cfg.Slug, err)
|
|
}
|
|
} else {
|
|
for _, rb := range raw {
|
|
b, perr := adapter.ParseBlock(rb)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
sb := &idxstor.Block{
|
|
ID: b.ID,
|
|
ParentID: b.ParentID,
|
|
Height: b.Height,
|
|
Timestamp: b.Timestamp,
|
|
Status: string(b.Status),
|
|
TxCount: b.TxCount,
|
|
TxIDs: b.TxIDs,
|
|
Data: b.Data,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
if err := store.StoreBlock(ctx, platformBlocksTable, sb); err != nil && ctx.Err() == nil {
|
|
log.Printf("[%s] platform store block %d: %v", cfg.Slug, b.Height, err)
|
|
}
|
|
}
|
|
}
|
|
if err := adapter.SyncValidators(ctx, store); err != nil && ctx.Err() == nil {
|
|
log.Printf("[%s] platform validators: %v", cfg.Slug, err)
|
|
}
|
|
}
|
|
sync() // first pass immediately so tables populate before the first tick
|
|
ticker := time.NewTicker(poll)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
sync()
|
|
}
|
|
}
|
|
default:
|
|
log.Printf("[%s] %s indexer: passive (no chain-specific implementation)", cfg.Slug, cfg.Type)
|
|
<-ctx.Done()
|
|
}
|
|
}
|
|
|
|
// mountIndexerAPI waits for the per-chain SQLite schema to settle, then
|
|
// publishes the standalone explorer API in the indexer/explorer route maps.
|
|
func (s *ChainSupervisor) mountIndexerAPI(ctx context.Context, cfg ChainConfig, dbPath string) {
|
|
apiPrefix := "/v1/indexer/" + cfg.Slug
|
|
deadline := time.Now().Add(5 * time.Minute)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(time.Second):
|
|
}
|
|
if _, err := os.Stat(dbPath); err != nil {
|
|
if time.Now().After(deadline) {
|
|
log.Printf("[%s] api waiting for %s: %v", cfg.Slug, dbPath, err)
|
|
deadline = time.Now().Add(5 * time.Minute)
|
|
}
|
|
continue
|
|
}
|
|
// Gate readiness on the primary blocks table for this chain type:
|
|
// EVM writes evm_blocks; platform/linear writes pchain_blocks (created
|
|
// by the generic chain indexer's Init — NOT by platform.InitSchema,
|
|
// which only creates the validator/delegator/etc tables).
|
|
gateTable := ""
|
|
switch cfg.Type {
|
|
case "", "evm":
|
|
gateTable = "evm_blocks"
|
|
case "platform", "linear":
|
|
gateTable = "pchain_blocks"
|
|
}
|
|
if gateTable != "" {
|
|
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
|
|
if err == nil {
|
|
var n int
|
|
_ = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", gateTable).Scan(&n)
|
|
db.Close()
|
|
if n == 0 {
|
|
if time.Now().After(deadline) {
|
|
log.Printf("[%s] api waiting for %s", cfg.Slug, gateTable)
|
|
deadline = time.Now().Add(5 * time.Minute)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
srv, err := explorer.NewStandaloneServer(explorer.Config{
|
|
IndexerDBPath: dbPath,
|
|
ChainID: cfg.ChainID,
|
|
ChainName: cfg.Name,
|
|
CoinSymbol: cfg.CoinSymbol,
|
|
APIPrefix: apiPrefix,
|
|
// Native-coin balances come from the chain, not from the
|
|
// index — the same RPC this chain is indexed from.
|
|
RPCEndpoint: cfg.RPC,
|
|
})
|
|
if err != nil {
|
|
if time.Now().After(deadline) {
|
|
log.Printf("[%s] api: %v", cfg.Slug, err)
|
|
deadline = time.Now().Add(5 * time.Minute)
|
|
}
|
|
continue
|
|
}
|
|
s.indexerRoutes.Store(cfg.Slug, srv.Handler())
|
|
log.Printf("[%s] api mounted at %s/*", cfg.Slug, apiPrefix)
|
|
return
|
|
}
|
|
}
|
|
|
|
// runSubgraph spawns a graph indexer + engine for a single subgraph and
|
|
// publishes its handler at /v1/graph/{chain-slug}/{subgraph-name}/.
|
|
func (s *ChainSupervisor) runSubgraph(ctx context.Context, cfg ChainConfig, sg Subgraph) {
|
|
dir := filepath.Join(s.dataDir, cfg.Slug, "graph", sg.Name)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
log.Printf("[%s/%s] mkdir: %v", cfg.Slug, sg.Name, err)
|
|
return
|
|
}
|
|
store, err := graphstor.New(dir)
|
|
if err != nil {
|
|
log.Printf("[%s/%s] storage: %v", cfg.Slug, sg.Name, err)
|
|
return
|
|
}
|
|
defer store.Close()
|
|
if err := store.Init(ctx); err != nil {
|
|
log.Printf("[%s/%s] storage init: %v", cfg.Slug, sg.Name, err)
|
|
return
|
|
}
|
|
|
|
idx := graphidx.NewWithConfig(graphidx.Config{
|
|
RPC: cfg.RPC,
|
|
PoolManager: cfg.PoolManager, // empty => indexer defaults to 0x9999
|
|
StartBlock: cfg.Indexer.StartBlock,
|
|
// Every other line this supervisor emits is prefixed "[slug/name]"; the
|
|
// graph indexer's were not, so in a six-chain process there was no way to
|
|
// tell which chain a line came from — or which chain had gone quiet.
|
|
Label: cfg.Slug + "/" + sg.Name,
|
|
}, store)
|
|
// One-shot enrichment of Token rows persisted by an older graph build with the
|
|
// address placeholder (symbol == shortAddr). Opt-in via BACKFILL_TOKENS=1; 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 {
|
|
log.Printf("[%s/%s] token backfill: %v (enriched=%d)", cfg.Slug, sg.Name, err, n)
|
|
} else {
|
|
log.Printf("[%s/%s] token backfill: enriched=%d", cfg.Slug, sg.Name, n)
|
|
}
|
|
}
|
|
go func() {
|
|
if err := idx.Run(ctx); err != nil && ctx.Err() == nil {
|
|
log.Printf("[%s/%s] indexer: %v", cfg.Slug, sg.Name, err)
|
|
}
|
|
}()
|
|
|
|
eng := engine.New(store, &engine.Config{
|
|
MaxQueryDepth: 10,
|
|
MaxResultSize: 1 << 20,
|
|
QueryTimeoutMs: 30000,
|
|
})
|
|
schema := sg.Schema
|
|
if schema == "" {
|
|
schema = "amm"
|
|
}
|
|
if err := eng.LoadBuiltin(schema); err != nil {
|
|
log.Printf("[%s/%s] schema %q: %v", cfg.Slug, sg.Name, schema, err)
|
|
return
|
|
}
|
|
|
|
prefix := fmt.Sprintf("/v1/graph/%s/%s", cfg.Slug, sg.Name)
|
|
mux := http.NewServeMux()
|
|
gqlHandler := func(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
|
|
var req engine.Request
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"errors":[{"message":"invalid JSON"}]}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
resp := eng.Execute(r.Context(), &req)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
statusHandler := func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
st := idx.Status()
|
|
fmt.Fprintf(w, `{"status":"ok","block":%d,"indexed":%d}`, st.LatestBlock, st.IndexedEvents)
|
|
}
|
|
mux.HandleFunc("POST "+prefix+"/graphql", gqlHandler)
|
|
mux.HandleFunc("GET "+prefix+"/graphql", eng.HandleGraphiQL)
|
|
mux.HandleFunc("POST "+prefix+"/ql", gqlHandler)
|
|
mux.HandleFunc("GET "+prefix+"/ql", eng.HandleGraphiQL)
|
|
mux.HandleFunc("GET "+prefix+"/health", statusHandler)
|
|
|
|
s.graphRoutes.Store(cfg.Slug+"/"+sg.Name, http.Handler(mux))
|
|
log.Printf("[%s/%s] graph mounted at %s/{graphql,ql,health} (schema=%s)",
|
|
cfg.Slug, sg.Name, prefix, schema)
|
|
|
|
<-ctx.Done()
|
|
}
|
|
|
|
// dispatchIndexer routes /v1/indexer/{slug}/* and /v1/indexer/* (default
|
|
// chain) to the per-chain handler installed by mountIndexerAPI.
|
|
//
|
|
// Edge case the older code got wrong: when the first path segment matches
|
|
// the slug regex but is NOT a registered chain (e.g. `main-page` in
|
|
// `/v1/indexer/main-page/blocks`), splitSlug peels it off as a "slug" and
|
|
// the dispatcher then forwarded only the REMAINING segments to the
|
|
// default chain. That turned `/v1/indexer/main-page/blocks` into
|
|
// `/v1/indexer/evm/blocks` server-side — a completely different endpoint
|
|
// with a different response shape (paginated envelope vs. raw array). The
|
|
// SPA's `?.slice(0,8)` on the home page's Latest Blocks widget tripped
|
|
// over the envelope and threw "o.slice is not a function".
|
|
//
|
|
// Fix: when the parsed slug isn't a real chain, re-join it with the rest
|
|
// before delegating, so the unrecognized segment is preserved as part of
|
|
// the path under the default chain.
|
|
func (s *ChainSupervisor) dispatchIndexer(w http.ResponseWriter, r *http.Request) {
|
|
slug, rest, ok := splitSlug(r.URL.Path, "/v1/indexer/")
|
|
if !ok {
|
|
s.serveHost(w, r, "")
|
|
return
|
|
}
|
|
if h, found := s.indexerRoutes.Load(slug); found {
|
|
h.(http.Handler).ServeHTTP(w, r)
|
|
return
|
|
}
|
|
fullTail := slug
|
|
if rest != "" {
|
|
fullTail = slug + "/" + rest
|
|
}
|
|
s.serveHost(w, r, fullTail)
|
|
}
|
|
|
|
// serveHost forwards a slug-less /v1/indexer/* request to the chain the
|
|
// request host is served. The standalone server is mounted under its slug, so
|
|
// the URL is rewritten to include the slug before delegating.
|
|
//
|
|
// Pinning this to the configured default instead of the host answered Lux's
|
|
// blocks, addresses and tokens on api-explore.hanzo.network and
|
|
// api-explore.zoo.network — the same window of Lux heights, to the block, on
|
|
// all three brands.
|
|
func (s *ChainSupervisor) serveHost(w http.ResponseWriter, r *http.Request, rest string) {
|
|
slug := s.hostSlug(r.Host)
|
|
if slug == "" {
|
|
http.Error(w, `{"error":"no chain for host"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
h, ok := s.indexerRoutes.Load(slug)
|
|
if !ok {
|
|
http.Error(w, `{"error":"chain not ready"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
r2 := r.Clone(r.Context())
|
|
r2.URL.Path = "/v1/indexer/" + slug + "/" + strings.TrimPrefix(rest, "/")
|
|
h.(http.Handler).ServeHTTP(w, r2)
|
|
}
|
|
|
|
// hostSlug is the chain a slug-less request is answered from: the chain the
|
|
// host names, when that chain is running, else the default. Chains can be
|
|
// added and removed at runtime, so a host naming a chain that is not up falls
|
|
// back rather than 404ing.
|
|
func (s *ChainSupervisor) hostSlug(host string) string {
|
|
if c, ok := s.cfg.Chain(host); ok {
|
|
if _, running := s.indexerRoutes.Load(c.Slug); running {
|
|
return c.Slug
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.defaultSlug
|
|
}
|
|
|
|
// dispatchExplorerLegacy preserves the older /v1/explorer/{slug}/* prefix
|
|
// for clients that haven't migrated to /v1/indexer/.
|
|
func (s *ChainSupervisor) dispatchExplorerLegacy(w http.ResponseWriter, r *http.Request) {
|
|
slug, rest, ok := splitSlug(r.URL.Path, "/v1/explorer/")
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
h, found := s.indexerRoutes.Load(slug)
|
|
if !found {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
r2 := r.Clone(r.Context())
|
|
r2.URL.Path = "/v1/indexer/" + slug + "/" + strings.TrimPrefix(rest, "/")
|
|
h.(http.Handler).ServeHTTP(w, r2)
|
|
}
|
|
|
|
// dispatchGraph routes /v1/graph/{slug}/{subgraph}/* to the matching engine.
|
|
func (s *ChainSupervisor) dispatchGraph(w http.ResponseWriter, r *http.Request) {
|
|
rest := strings.TrimPrefix(r.URL.Path, "/v1/graph/")
|
|
if rest == r.URL.Path {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
parts := strings.SplitN(rest, "/", 3)
|
|
if len(parts) < 2 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
key := parts[0] + "/" + parts[1]
|
|
h, ok := s.graphRoutes.Load(key)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
h.(http.Handler).ServeHTTP(w, r)
|
|
}
|
|
|
|
// splitSlug returns the chain slug and the remainder of the path after a
|
|
// known prefix (e.g. "/v1/indexer/"). Returns ok=false if the path does not
|
|
// match the prefix or contains no slug.
|
|
func splitSlug(path, prefix string) (slug, rest string, ok bool) {
|
|
if !strings.HasPrefix(path, prefix) {
|
|
return "", "", false
|
|
}
|
|
tail := strings.TrimPrefix(path, prefix)
|
|
if tail == "" {
|
|
return "", "", false
|
|
}
|
|
parts := strings.SplitN(tail, "/", 2)
|
|
if !slugPattern.MatchString(parts[0]) {
|
|
return "", "", false
|
|
}
|
|
if len(parts) == 1 {
|
|
return parts[0], "", true
|
|
}
|
|
return parts[0], parts[1], true
|
|
}
|