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]>
Explorer
Single-binary Lux explorer: chain indexer, GraphQL engine, and SPA frontend
in one Go process. Imports luxfi/indexer
and luxfi/graph as libraries; embeds the
luxfi/explore Next.js build via
go:embed.
ghcr.io/luxfi/explorer
-> indexes one or more EVM chains (luxfi/indexer)
-> exposes per-chain GraphQL (luxfi/graph)
-> serves embedded SPA + REST API (luxfi/explore)
Routes
| Path | Purpose |
|---|---|
/ |
SPA (embedded; SPA-routing fallback) |
/envs.js |
Runtime config window.ENV = {...} |
/icon.svg, /logo.svg |
Per-host brand assets (disk override) |
/health |
Service health |
/v1/indexer/* |
Default chain explorer API |
/v1/indexer/{slug}/* |
Per-chain explorer API |
/v1/explorer/{slug}/* |
Legacy alias for /v1/indexer/{slug} |
/v1/graph/{slug}/{subgraph}/graphql |
Per-chain, per-subgraph GraphQL |
/v1/explorer/admin/chains[/{slug}] |
Runtime registry CRUD |
/v1/explorer/realtime |
WebSocket realtime hub |
/v1/explorer/realtime/stats |
Realtime stats |
Quick Start
docker run -p 8090:8090 \
-v $(pwd)/chains.example.yaml:/etc/explorer/chains.yaml:ro \
-v explorer-data:/data \
ghcr.io/luxfi/explorer:latest \
--config /etc/explorer/chains.yaml
Or with compose:
docker compose up
Configuration
chains.yaml is the single source of truth. Every aspect of the deploy --
chains, branding, GraphQL subgraphs, network switcher links -- is per-chain
customizable. See chains.example.yaml for the full
schema.
data_dir: /data
http_addr: :8090
brand_default:
name: Lux Explorer
coin: LUX
accent_color: "#5cf"
networks:
- { label: Mainnet, domain: explorer.lux.network, chain_id: 96369 }
chains:
- slug: cchain
name: Lux C-Chain
chain_id: 96369
type: evm
rpc: https://api.lux.network/v1/bc/C/rpc
coin: LUX
enabled: true
default: true
indexer:
poll_interval: 30s
graph:
enabled: true
subgraphs:
- { name: amm, schema: amm, enabled: true }
brand:
name: Lux C-Chain
accent_color: "#5cf"
$ENV_VAR substitution in rpc: / ws: lets one file template every
network -- mount it read-only and inject secrets via env vars.
Per-Network Customization
The same image powers every deploy. To rebrand for a different network:
- Mount a different
chains.yaml. - Optionally mount
icon.svg/logo.svgat the path referenced bychains.yaml-- they are read from disk per-request, not embedded. - Set environment variables to fill the
${VAR}placeholders.
No image rebuild needed. The SPA reads chain list and brand at runtime from
/envs.js.
Runtime Registry
Chains can be added, updated, and removed at runtime without restart:
# Add
curl -X POST http://localhost:8090/v1/explorer/admin/chains \
-H 'Content-Type: application/json' \
-d '{"slug":"new","name":"New","chain_id":123,"rpc":"http://node:8545","type":"evm"}'
# List
curl http://localhost:8090/v1/explorer/admin/chains
# Remove
curl -X DELETE http://localhost:8090/v1/explorer/admin/chains/new
Adding a chain spawns its indexer + (optional) graph workers; removing cancels them and frees the routes.
mDNS Discovery
Set EXPLORER_MDNS=true to auto-register chains advertised by local Lux
nodes (_luxd._tcp, _zood._tcp, _hanzod._tcp, _parsd._tcp). The
explorer queries info.getChains on the discovered node and registers each
tracked chain. mDNS-sourced chains never override config-sourced entries.
Storage Layout
/data/
cchain/
query/indexer.db (SQLite WAL, indexer)
{zapdb}/ (KV store)
graph/
amm/graph.db (SQLite WAL, per-subgraph)
zoo/
...
Each chain (and each subgraph) owns an isolated SQLite + KV store. WAL is
optionally streamed to S3 with PQ encryption when REPLICATE_S3_ENDPOINT
is set -- see luxfi/indexer/daemon.
Local Build
# Sibling layout: ~/work/lux/{indexer,graph,explorer}
cd ~/work/lux/explorer
go build -o explorer .
go.mod uses local replace directives pointing at ../indexer and
../graph. Production CI clones all three repos into the same layout
inside the Docker build.
Endpoints (curl examples)
curl http://localhost:8090/health
curl http://localhost:8090/v1/indexer/cchain/blocks/latest
curl -X POST http://localhost:8090/v1/graph/cchain/amm/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ swaps(first:5) { id amount0 amount1 } }"}'
Related
luxfi/indexer-- chain indexer libraryluxfi/graph-- per-chain GraphQL libraryluxfi/explore-- Next.js SPA