unified explorer: single Go binary with embedded SPA + per-chain graph

Module renamed to github.com/luxfi/explorer (was -unified). Imports
luxfi/indexer/{daemon,evm,storage,explorer} and luxfi/graph/{engine,
indexer,storage} as libraries; embeds the luxfi/explore SPA via go:embed
all:static.

A new ChainSupervisor wraps the existing ChainRegistry: adding a chain
(via config / mDNS / admin API) spawns a per-chain indexer goroutine plus
one graph engine + indexer per enabled subgraph; removing cancels them
and frees the routes from a sync.Map dispatcher mounted on /v1/indexer/,
/v1/explorer/ (legacy alias), and /v1/graph/.

Config schema is fully per-chain: indexer poll interval, list of graph
subgraphs (each can pick a built-in schema like amm, securities, fhe…),
brand (name/coin/accent + on-disk icon/logo paths), token features, and
top-level networks list. /envs.js exposes the chain list and active
brand to the SPA at runtime, so a deploy can rebrand by remounting
chains.yaml + assets — no image rebuild.

Dockerfile clones luxfi/explore + luxfi/indexer + luxfi/graph as
siblings inside the build, populates ./static from the SPA build, and
produces one binary. CI checks out the three repos as siblings so the
local replace in go.mod works without tagged releases.
This commit is contained in:
2026-05-05 21:03:52 -07:00
parent 9fa3cdebd3
commit 0a97dedfe9
18 changed files with 1637 additions and 518 deletions
+23 -8
View File
@@ -1,18 +1,33 @@
name: CI
on:
push:
branches:
- main
branches: [main]
pull_request:
branches:
- main
branches: [main]
permissions:
contents: read
# go.mod uses local replace directives to ../indexer and ../graph. CI checks
# out all three repos as siblings so `go build ./...` resolves without any
# network hop to a tagged version.
jobs:
test:
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
with: { path: explorer }
- uses: actions/checkout@v4
with: { repository: luxfi/indexer, ref: main, path: indexer }
- uses: actions/checkout@v4
with: { repository: luxfi/graph, ref: main, path: graph }
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go mod download
- run: go test ./... && go build ./...
with: { go-version-file: explorer/go.mod }
- name: Build
working-directory: explorer
run: |
go build ./...
- name: Test
working-directory: explorer
run: |
go test -race -count=1 ./...
+1 -2
View File
@@ -1,7 +1,6 @@
# Binaries
bin/
explorer
explorer-unified
/explorer
*.exe
*.test
+26 -47
View File
@@ -1,81 +1,60 @@
# luxfi/explorer — Combined: indexer + graph + explore frontend
#
# ghcr.io/luxfi/indexer ← chain data (blocks, txs, tokens, contracts)
# ghcr.io/luxfi/graph ← subgraph events (swaps, pools, DeFi, GraphQL)
# ghcr.io/luxfi/explore ← Next.js frontend
# ghcr.io/luxfi/explorer ← This: all three combined
#
# Each component can also run standalone for independent scaling.
# Unified Lux explorer image. Single Go binary, embedded SPA, indexer +
# graph as libraries. Deploy with a chains.yaml (or env vars) and persist
# /data; brand swap by remounting chains.yaml + assets, no rebuild needed.
ARG GO_VERSION=1.26
ARG NODE_VERSION=22
ARG EXPLORE_REPO=https://github.com/luxfi/explore.git
ARG EXPLORE_REF=main
ARG INDEXER_REPO=https://github.com/luxfi/indexer.git
ARG INDEXER_REF=main
ARG GRAPH_REPO=https://github.com/luxfi/graph.git
ARG GRAPH_REF=main
ARG EXPLORE_REPO=https://github.com/luxfi/explore.git
ARG EXPLORE_REF=main
# ---- Stage 1: Build frontend ----
# ---- Stage 1: build the SPA ----
FROM node:${NODE_VERSION}-alpine AS frontend
ARG EXPLORE_REPO
ARG EXPLORE_REF
RUN apk add --no-cache git
WORKDIR /app
RUN git clone --depth=1 --branch=${EXPLORE_REF} ${EXPLORE_REPO} .
RUN corepack enable && pnpm install --frozen-lockfile
ENV NEXT_PUBLIC_API_BASE_PATH=/v1/explorer
ENV NODE_OPTIONS="--max-old-space-size=8192"
RUN sed -i "s/output: 'standalone'/output: 'export'/" next.config.js || true
RUN pnpm build || true
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN corepack enable && pnpm install --frozen-lockfile && pnpm build || true
RUN mkdir -p /app/out && [ -f /app/out/index.html ] || \
echo '<!DOCTYPE html><html><head><title>Explorer</title></head><body></body></html>' > /app/out/index.html
echo '<!doctype html><title>Explorer</title><div id="root"></div>' > /app/out/index.html
# ---- Stage 2: Build Go binaries ----
# ---- Stage 2: clone indexer + graph siblings, build the unified binary ----
FROM golang:${GO_VERSION}-alpine AS builder
ARG INDEXER_REPO
ARG INDEXER_REF
ARG GRAPH_REPO
ARG GRAPH_REF
RUN apk add --no-cache gcc musl-dev sqlite-dev git
ARG VERSION=dev
RUN apk add --no-cache gcc musl-dev sqlite-dev git
# Build indexer
WORKDIR /indexer
RUN git clone --depth=1 --branch=${INDEXER_REF} ${INDEXER_REPO} .
RUN go mod download
COPY --from=frontend /app/out /indexer/cmd/explorer/static
RUN sed -i 's|//go:embed static/\*|//go:embed all:static|' /indexer/cmd/explorer/frontend.go 2>/dev/null || true
RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build \
-ldflags="-s -w -X main.version=${VERSION}" -o /bin/indexer ./cmd/indexer/
RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build \
-ldflags="-s -w -X main.version=${VERSION}" -o /bin/explorer ./cmd/explorer/
WORKDIR /src
RUN git clone --depth=1 --branch=${INDEXER_REF} ${INDEXER_REPO} indexer && \
git clone --depth=1 --branch=${GRAPH_REF} ${GRAPH_REPO} graph
# Build graph
WORKDIR /graph
RUN git clone --depth=1 --branch=${GRAPH_REF} ${GRAPH_REPO} .
RUN go mod download
RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build \
-ldflags="-s -w -X main.version=${VERSION}" -o /bin/graph ./cmd/graph/
WORKDIR /src/explorer
COPY . .
COPY --from=frontend /app/out ./static
# ---- Stage 3: Runtime ----
RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" \
go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /out/explorer .
# ---- Stage 3: runtime ----
FROM alpine:3.21
RUN apk add --no-cache ca-certificates sqlite-libs
COPY --from=builder /bin/indexer /usr/local/bin/indexer
COPY --from=builder /bin/explorer /usr/local/bin/explorer
COPY --from=builder /bin/graph /usr/local/bin/graph
COPY --from=frontend /app/out /srv/frontend
RUN apk add --no-cache ca-certificates sqlite-libs wget
COPY --from=builder /out/explorer /usr/local/bin/explorer
RUN adduser -D -u 65532 explorer
USER explorer
VOLUME /data
ENV DATA_DIR=/data HTTP_ADDR=:8090
EXPOSE 8090 4000
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://localhost:8090/health || exit 1
# Default: run the multi-chain explorer (indexer + graph + frontend)
# Override with: docker run ... indexer --chain cchain --rpc ...
# or: docker run ... graph --rpc ...
ENTRYPOINT ["explorer"]
+140 -222
View File
@@ -1,257 +1,175 @@
# Explorer
Combined block explorer: chain indexer + subgraph engine + frontend in a single image.
Single-binary Lux explorer: chain indexer, GraphQL engine, and SPA frontend
in one Go process. Imports [`luxfi/indexer`](https://github.com/luxfi/indexer)
and [`luxfi/graph`](https://github.com/luxfi/graph) as libraries; embeds the
[`luxfi/explore`](https://github.com/luxfi/explore) Next.js build via
`go:embed`.
```
ghcr.io/luxfi/indexer -- chain data: blocks, txs, tokens, contracts (SQLite)
ghcr.io/luxfi/graph -- subgraph events: swaps, pools, DeFi, GraphQL (SQLite)
ghcr.io/luxfi/explore -- Next.js frontend
ghcr.io/luxfi/explorer -- all three combined (this image)
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)
```
Each component runs standalone for independent scaling, or together in one container.
## Routes
## Architecture
| 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 |
```
+------------------+
| Load Balancer |
| (hanzo/ingress) |
+--------+---------+
|
+--------v---------+
| explorer:8090 |
| (single binary) |
+--+-----+------+--+
| | |
+-------------+ +--+--+ +-------------+
| | | |
+------v------+ +-----v-+ +v-----------+ |
| indexer | | graph | | explore | |
| /v1/explorer | | /gql | | /* (static) | |
+------+------+ +---+---+ +-------------+ |
| | |
+------v--------------v------+ +-------v-------+
| SQLite per-chain (WAL) | | ZapDB KV |
| /data/{chain}/index.db | | fast lookups |
+----------------------------+ +---------------+
|
+------v-----------------------+
| Replicate -> S3 (optional) |
| E2E PQ encrypted (ML-KEM) |
+------------------------------+
RPC Sources (per network):
+-----------------------------------------------+
| api.lux.network/{mainnet,testnet,devnet} |
| /ext/bc/C/rpc C-Chain |
| /ext/bc/{blockchain_id}/rpc Subnet chains |
+-----------------------------------------------+
```
## Quick Start (Local Dev)
## Quick Start
```bash
# Clone
git clone https://github.com/luxfi/explorer-combined.git
cd explorer-combined
# Run against testnet C-Chain
docker compose up
# Or run the image directly
docker run -p 8090:8090 \
-e RPC_ENDPOINT=https://api.lux.network/testnet/ext/bc/C/rpc \
-v $(pwd)/chains.example.yaml:/etc/explorer/chains.yaml:ro \
-v explorer-data:/data \
ghcr.io/luxfi/explorer
ghcr.io/luxfi/explorer:latest \
--config /etc/explorer/chains.yaml
```
Open http://localhost:8090 to see the explorer frontend.
## Run Modes
Or with compose:
```bash
# Combined (default) -- indexes chain + subgraphs, serves API + frontend
docker run -p 8090:8090 \
-e RPC_ENDPOINT=http://node:9650/ext/bc/C/rpc \
-v data:/data \
ghcr.io/luxfi/explorer
# Indexer only (chain data API)
docker run -p 8090:8090 \
-v data:/data \
ghcr.io/luxfi/explorer \
indexer --chain cchain --rpc http://node:9650/ext/bc/C/rpc
# Graph only (subgraph GraphQL)
docker run -p 4000:4000 \
-v data:/data \
ghcr.io/luxfi/explorer \
graph --rpc http://node:9650/ext/bc/C/rpc
docker compose up
```
## Multi-Chain Configuration
## Configuration
Use `chains.yaml` to index multiple chains in one instance.
See `chains.example.yaml` for a complete template with all Lux networks.
`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`](chains.example.yaml) for the full
schema.
```yaml
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/mainnet/ext/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:
1. Mount a different `chains.yaml`.
2. Optionally mount `icon.svg` / `logo.svg` at the path referenced by
`chains.yaml` -- they are read from disk per-request, not embedded.
3. 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:
```bash
docker run -p 8090:8090 \
-v ./chains.yaml:/etc/explorer/chains.yaml \
-v data:/data \
ghcr.io/luxfi/explorer --config /etc/explorer/chains.yaml
# 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
```
## Per-Network Deployment
Adding a chain spawns its indexer + (optional) graph workers; removing
cancels them and frees the routes.
### Mainnet
## 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
```bash
docker run -p 8090:8090 \
-e RPC_ENDPOINT=https://api.lux.network/mainnet/ext/bc/C/rpc \
-e NETWORK=mainnet \
-e CHAIN_ID=96369 \
-v data:/data \
ghcr.io/luxfi/explorer:v0.1.0
# Sibling layout: ~/work/lux/{indexer,graph,explorer}
cd ~/work/lux/explorer
go build -o explorer .
```
Mainnet chains and their RPC endpoints:
`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.
| Chain | EVM Chain ID | RPC Endpoint |
|---------|-------------|----------------------------------------------------------------------|
| C-Chain | 96369 | `https://api.lux.network/mainnet/ext/bc/C/rpc` |
| Zoo | 200200 | `https://api.lux.network/mainnet/ext/bc/2Y625Kvdd.../rpc` |
| Hanzo | 36963 | `https://api.lux.network/mainnet/ext/bc/2GiQb73Ce.../rpc` |
| SPC | 36911 | `https://api.lux.network/mainnet/ext/bc/rtjwvtE1t.../rpc` |
| Pars | 494949 | `https://api.lux.network/mainnet/ext/bc/2pUskxqaL.../rpc` |
### Testnet
## Endpoints (curl examples)
```bash
docker run -p 8090:8090 \
-e RPC_ENDPOINT=https://api.lux.network/testnet/ext/bc/C/rpc \
-e NETWORK=testnet \
-e CHAIN_ID=96368 \
-v data:/data \
ghcr.io/luxfi/explorer:v0.1.0
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 } }"}'
```
### Devnet
```bash
docker run -p 8090:8090 \
-e RPC_ENDPOINT=https://api.lux.network/devnet/ext/bc/C/rpc \
-e NETWORK=devnet \
-e CHAIN_ID=96370 \
-v data:/data \
ghcr.io/luxfi/explorer:v0.1.0
```
## White-Label
Override branding via environment variables. No code changes needed.
```bash
docker run -p 8090:8090 \
-e RPC_ENDPOINT=http://node:8545 \
-e CHAIN_NAME="My Chain" \
-e COIN_SYMBOL=MYC \
-e COIN_DECIMALS=18 \
-e LOGO_URL=https://example.com/logo.svg \
-e FAVICON_URL=https://example.com/favicon.ico \
-e BRAND_COLOR="#FF6600" \
ghcr.io/luxfi/explorer
```
| Variable | Default | Description |
|---------------|---------------|----------------------------------|
| CHAIN_NAME | Lux C-Chain | Display name in header/title |
| COIN_SYMBOL | LUX | Native coin ticker |
| COIN_DECIMALS | 18 | Native coin decimal places |
| LOGO_URL | (Lux logo) | URL or path to header logo |
| FAVICON_URL | (Lux favicon) | URL or path to favicon |
| BRAND_COLOR | #1a1a2e | Primary brand color (hex) |
White-label detection in production uses hostname-based routing.
Each `*.lux.network` subdomain resolves to the correct chain branding automatically.
## PQ Encrypted Backup
The explorer supports post-quantum encrypted backups to S3-compatible storage using ML-KEM-768.
```bash
docker run -p 8090:8090 \
-e RPC_ENDPOINT=https://api.lux.network/mainnet/ext/bc/C/rpc \
-e BACKUP_ENABLED=true \
-e BACKUP_S3_ENDPOINT=https://s3.lux.network \
-e BACKUP_S3_BUCKET=explorer-backups \
-e BACKUP_S3_ACCESS_KEY=<from-kms> \
-e BACKUP_S3_SECRET_KEY=<from-kms> \
-e BACKUP_INTERVAL=6h \
-e BACKUP_PQ_ENCRYPT=true \
-e BACKUP_PQ_PUBLIC_KEY=/etc/explorer/backup.pub \
-v ./backup.pub:/etc/explorer/backup.pub:ro \
-v data:/data \
ghcr.io/luxfi/explorer
```
Backup flow:
1. SQLite checkpoint (WAL flush)
2. Snapshot all chain databases
3. Encrypt with ML-KEM-768 (post-quantum KEM + AES-256-GCM)
4. Upload to S3 with content-addressed naming
5. Prune old snapshots per retention policy
Store secrets in KMS (kms.hanzo.ai), never in environment files committed to git.
## Performance (LP-104)
Benchmarked against Blockscout (Elixir) on identical hardware (4 vCPU, 8 GB RAM).
| Metric | Explorer (Go+SQLite) | Blockscout (Elixir+PG) |
|--------------------------|---------------------|------------------------|
| Block indexing (blocks/s) | 850 | 120 |
| API latency p50 (ms) | 2.1 | 18 |
| API latency p99 (ms) | 8.4 | 145 |
| Memory at 1M blocks (MB) | 180 | 2400 |
| Disk at 1M blocks (GB) | 1.2 | 28 |
| Cold start to serving (s) | 0.8 | 45 |
| Concurrent requests (rps) | 12,000 | 800 |
### Cost Comparison (monthly, per chain)
| Setup | Explorer | Blockscout |
|--------------------|-----------------|--------------------|
| Compute | 1 vCPU / 512 MB | 4 vCPU / 8 GB |
| Database | Embedded SQLite | PostgreSQL 2 vCPU |
| Storage | 2 GB SSD | 30 GB SSD |
| Estimated cost/mo | ~$5 | ~$80 |
| 5 chains x 3 nets | ~$75/mo | ~$1,200/mo |
## Endpoints
| Path | Description |
|--------------------|------------------------------------|
| `/health` | Health check (200 if indexing) |
| `/v1/explorer/*` | Chain data REST API |
| `/v1/explorer/stats` | Block count, tx count, sync status |
| `/graphql` | Subgraph-compatible GraphQL |
| `/*` | Embedded frontend (static) |
## Environment Variables
| Variable | Default | Description |
|-----------------|------------|------------------------------------------|
| RPC_ENDPOINT | (required) | EVM JSON-RPC URL |
| HTTP_ADDR | :8090 | Listen address |
| DATA_DIR | /data | Database storage directory |
| NETWORK | mainnet | Network name (mainnet/testnet/devnet) |
| CHAIN_ID | (auto) | EVM chain ID override |
| LOG_LEVEL | info | Log level (debug/info/warn/error) |
## Related
- [`luxfi/indexer`](https://github.com/luxfi/indexer) -- Go chain indexer
- [`luxfi/graph`](https://github.com/luxfi/graph) -- Subgraph engine, Graph Node replacement
- [`luxfi/explore`](https://github.com/luxfi/explore) -- Next.js frontend
- [`luxfi/explorer-v1`](https://github.com/luxfi/explorer-v1) -- Legacy Elixir stack (archived)
- [`luxfi/indexer`](https://github.com/luxfi/indexer) -- chain indexer library
- [`luxfi/graph`](https://github.com/luxfi/graph) -- per-chain GraphQL library
- [`luxfi/explore`](https://github.com/luxfi/explore) -- Next.js SPA
+102 -92
View File
@@ -1,100 +1,110 @@
# chains.example.yaml -- All Lux chains across all networks
#
# Copy to chains.yaml and adjust as needed:
# cp chains.example.yaml chains.yaml
# chains.example.yaml — full per-network customization for the unified Lux
# explorer. Mount as /etc/explorer/chains.yaml or pass --config=chains.yaml.
# Mainnet (network-id=1)
mainnet:
cchain:
name: "Lux C-Chain"
data_dir: /data
http_addr: :8090
# Default branding the SPA uses when the request hostname does not match a
# chain-specific brand below. Per-chain `brand:` overrides win.
brand_default:
name: Lux Explorer
coin: LUX
accent_color: "#5cf"
icon_url: /icon.svg
logo_url: /logo.svg
# Cross-explorer link list rendered in the SPA's network switcher.
networks:
- label: Mainnet
domain: explorer.lux.network
chain_id: 96369
rpc: "https://api.lux.network/mainnet/ext/bc/C/rpc"
coin_symbol: "LUX"
zoo:
name: "Zoo"
chain_id: 200200
rpc: "https://api.lux.network/mainnet/ext/bc/2Y625Kvdd4wZ5LKx9kLSf1jGALyARmXVN1YsTb4F3Hzv4bPDbp/rpc"
coin_symbol: "ZOO"
hanzo:
name: "Hanzo"
chain_id: 36963
rpc: "https://api.lux.network/mainnet/ext/bc/2GiQb73CeJESjc4omFv2YtQHZrRgJf25NXPzAr5J6UNHRcDV2F/rpc"
coin_symbol: "HANZO"
spc:
name: "SPC"
chain_id: 36911
rpc: "https://api.lux.network/mainnet/ext/bc/rtjwvtE1tEvrokmpeYdTq7b2zqZgmybKwR5MLjKMGAR1W78dQ/rpc"
coin_symbol: "SPC"
pars:
name: "Pars"
chain_id: 494949
rpc: "https://api.lux.network/mainnet/ext/bc/2pUskxqaL5Bpx7uRUGG1fDjPckjxQ4UKX4sLKeaS1NdSVBJd3F/rpc"
coin_symbol: "PARS"
# Testnet (network-id=2)
testnet:
cchain:
name: "Lux C-Chain (Testnet)"
- label: Testnet
domain: testnet.explorer.lux.network
chain_id: 96368
rpc: "https://api.lux.network/testnet/ext/bc/C/rpc"
coin_symbol: "LUX"
zoo:
name: "Zoo (Testnet)"
chain_id: 200201
rpc: "https://api.lux.network/testnet/ext/bc/2Uw1b5tf63vamHsWVeSW9uHcGBVbF5CYHS9JiLWttusY4Vvjsn/rpc"
coin_symbol: "ZOO"
hanzo:
name: "Hanzo (Testnet)"
chain_id: 36964
rpc: "https://api.lux.network/testnet/ext/bc/2wbYEFh7ELuovqXhyYeCLvweZrcEgNUoZVJUAtw145qTVAUJxE/rpc"
coin_symbol: "HANZO"
spc:
name: "SPC (Testnet)"
chain_id: 36910
rpc: "https://api.lux.network/testnet/ext/bc/u4pC2tT61o83pbQ8qwAyfXXGF6nyGFNSwCYHEFcUeESGiNKfu/rpc"
coin_symbol: "SPC"
pars:
name: "Pars (Testnet)"
chain_id: 7071
rpc: "https://api.lux.network/testnet/ext/bc/is5GUzsKHk3Z4Q5JXTjF4Ld9p4TnEs81tJFdaBiEBJVkaFrrs/rpc"
coin_symbol: "PARS"
# Devnet (network-id=3)
devnet:
cchain:
name: "Lux C-Chain (Devnet)"
- label: Devnet
domain: devnet.explorer.lux.network
chain_id: 96370
rpc: "https://api.lux.network/devnet/ext/bc/C/rpc"
coin_symbol: "LUX"
zoo:
name: "Zoo (Devnet)"
chain_id: 200202
rpc: "https://api.lux.network/devnet/ext/bc/2caKCdNop9C4oAPR1kYihN53HeSRDXD3JyMKS7ntfcpQYucmii/rpc"
coin_symbol: "ZOO"
chains:
- slug: cchain
name: Lux C-Chain
chain_id: 96369
type: evm
rpc: ${LUX_CCHAIN_RPC:-https://api.lux.network/mainnet/ext/bc/C/rpc}
coin: LUX
enabled: true
default: true
hanzo:
name: "Hanzo (Devnet)"
chain_id: 36964
rpc: "https://api.lux.network/devnet/ext/bc/tecXMucYDxwN65mebPE6cvQ9GynG6Y5WEvwGFsY3xVDWHxiqT/rpc"
coin_symbol: "HANZO"
indexer:
poll_interval: 30s
start_block: 0
spc:
name: "SPC (Devnet)"
chain_id: 36912
rpc: "https://api.lux.network/devnet/ext/bc/213ZsFLRCBSmrii4huQqa6S3SxYPP8ec6dPEn6ad4CtLk3RdXp/rpc"
coin_symbol: "SPC"
graph:
enabled: true
subgraphs:
- { name: amm, schema: amm, enabled: true }
- { name: securities, schema: securities, enabled: false }
pars:
name: "Pars (Devnet)"
chain_id: 494951
rpc: "https://api.lux.network/devnet/ext/bc/MEGAgimfcodi7QaqCruFpTkNM6fqu7ZduLmWJJjGQwUg8uDPJ/rpc"
coin_symbol: "PARS"
brand:
name: Lux C-Chain
coin: LUX
accent_color: "#5cf"
icon_url: /icon.svg
logo_url: /logo.svg
tokens:
featured: []
allow_list: []
block_list: []
- slug: zoo
name: Zoo
chain_id: 200200
type: evm
rpc: ${ZOO_RPC:-https://api.lux.network/mainnet/ext/bc/2Y625Kvdd4wZ5LKx9kLSf1jGALyARmXVN1YsTb4F3Hzv4bPDbp/rpc}
coin: ZOO
enabled: true
indexer: { poll_interval: 30s }
graph:
enabled: true
subgraphs:
- { name: amm, schema: amm, enabled: true }
brand:
name: Zoo
coin: ZOO
accent_color: "#7c3aed"
- slug: hanzo
name: Hanzo
chain_id: 36963
type: evm
rpc: ${HANZO_RPC:-https://api.lux.network/mainnet/ext/bc/2GiQb73CeJESjc4omFv2YtQHZrRgJf25NXPzAr5J6UNHRcDV2F/rpc}
coin: HANZO
enabled: true
indexer: { poll_interval: 30s }
graph:
enabled: true
subgraphs:
- { name: amm, schema: amm, enabled: true }
brand:
name: Hanzo
coin: HANZO
accent_color: "#0b1220"
- slug: spc
name: SPC
chain_id: 36911
type: evm
rpc: ${SPC_RPC:-https://api.lux.network/mainnet/ext/bc/rtjwvtE1tEvrokmpeYdTq7b2zqZgmybKwR5MLjKMGAR1W78dQ/rpc}
coin: SPC
enabled: true
indexer: { poll_interval: 30s }
- slug: pars
name: Pars
chain_id: 494949
type: evm
rpc: ${PARS_RPC:-https://api.lux.network/mainnet/ext/bc/2pUskxqaL5Bpx7uRUGG1fDjPckjxQ4UKX4sLKeaS1NdSVBJd3F/rpc}
coin: PARS
enabled: true
indexer: { poll_interval: 30s }
+4 -5
View File
@@ -5,17 +5,16 @@ services:
dockerfile: Dockerfile
args:
VERSION: dev
image: ghcr.io/luxfi/explorer:dev
ports:
- "8090:8090"
volumes:
- explorer-data:/data
- ./chains.example.yaml:/etc/explorer/chains.yaml:ro
environment:
- RPC_ENDPOINT=https://api.lux.network/testnet/ext/bc/C/rpc
- NETWORK=testnet
- DATA_DIR=/data
- HTTP_ADDR=:8090
- LOG_LEVEL=debug
DATA_DIR: /data
HTTP_ADDR: :8090
EXPLORER_CONFIG: /etc/explorer/chains.yaml
restart: unless-stopped
volumes:
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"fmt"
"os"
"regexp"
"gopkg.in/yaml.v3"
)
// Config is the top-level explorer configuration loaded from chains.yaml.
type Config struct {
DataDir string `yaml:"data_dir"`
HTTPAddr string `yaml:"http_addr"`
BrandDefault Brand `yaml:"brand_default"`
Networks []Network `yaml:"networks"`
Chains []ChainConfig `yaml:"chains"`
}
// ChainConfig defines a single chain plus per-chain customization. The
// admin/registry/mdns layer also uses this shape; fields beyond Slug/Name/
// ChainID/RPC/Type/Default are optional and apply when the chain is loaded
// from a config file.
type ChainConfig struct {
Slug string `json:"slug" yaml:"slug"`
Name string `json:"name" yaml:"name"`
ChainID int64 `json:"chain_id" yaml:"chain_id"`
Type string `json:"type" yaml:"type"` // evm, dag, linear, solana, bitcoin, cosmos
RPC string `json:"rpc" yaml:"rpc"`
WS string `json:"ws" yaml:"ws"`
CoinSymbol string `json:"coin" yaml:"coin"`
Enabled bool `json:"enabled" yaml:"enabled"`
Default bool `json:"default" yaml:"default"`
Source string `json:"source" yaml:"-"` // config, env, mdns, admin
Indexer IndexerSettings `json:"indexer,omitempty" yaml:"indexer,omitempty"`
Graph GraphSettings `json:"graph,omitempty" yaml:"graph,omitempty"`
Brand *Brand `json:"brand,omitempty" yaml:"brand,omitempty"`
Tokens TokensFeatures `json:"tokens,omitempty" yaml:"tokens,omitempty"`
}
// IndexerSettings overrides indexer behaviour for a single chain.
type IndexerSettings struct {
PollInterval string `yaml:"poll_interval" json:"poll_interval,omitempty"` // e.g. "30s"
StartBlock uint64 `yaml:"start_block" json:"start_block,omitempty"`
}
// GraphSettings configures per-chain GraphQL subgraphs.
type GraphSettings struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Subgraphs []Subgraph `yaml:"subgraphs" json:"subgraphs,omitempty"`
}
// Subgraph mounts a single named GraphQL schema under
// /v1/graph/{chain-slug}/{subgraph-name}/. Schema is either a built-in name
// understood by github.com/luxfi/graph/engine.LoadBuiltin (amm, uniswap-v2,
// uniswap-v3, uniswap-v4, securities, fhe, etc.) or a path to a .graphql file.
type Subgraph struct {
Name string `yaml:"name" json:"name"`
Schema string `yaml:"schema" json:"schema"`
Enabled bool `yaml:"enabled" json:"enabled"`
}
// Brand is the per-chain (or default) UI branding shown by the SPA. Icon/Logo
// files are read from disk on every request so a deploy can swap them without
// rebuilding the binary.
type Brand struct {
Name string `yaml:"name" json:"name,omitempty"`
Coin string `yaml:"coin" json:"coin,omitempty"`
AccentColor string `yaml:"accent_color" json:"accentColor,omitempty"`
IconFile string `yaml:"icon_file" json:"-"`
LogoFile string `yaml:"logo_file" json:"-"`
IconURL string `yaml:"icon_url" json:"iconUrl,omitempty"`
LogoURL string `yaml:"logo_url" json:"logoUrl,omitempty"`
}
// TokensFeatures controls which tokens the SPA highlights or hides.
type TokensFeatures struct {
Featured []string `yaml:"featured" json:"featured,omitempty"`
AllowList []string `yaml:"allow_list" json:"allowList,omitempty"`
BlockList []string `yaml:"block_list" json:"blockList,omitempty"`
}
// Network is a peer explorer the SPA can link to in its network switcher.
type Network struct {
Label string `yaml:"label" json:"label"`
Domain string `yaml:"domain" json:"domain"`
ChainID int64 `yaml:"chain_id" json:"chainId"`
}
// slugPattern restricts chain slugs to lowercase alphanumeric + hyphen so they
// remain safe in URL routes (/v1/indexer/{slug}/) and filesystem paths.
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
// LoadConfig reads chains.yaml, expands $ENV_VAR in RPC/WS, and validates slugs.
func LoadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read %s: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parse %s: %w", path, err)
}
for i := range cfg.Chains {
cfg.Chains[i].RPC = os.Expand(cfg.Chains[i].RPC, os.Getenv)
cfg.Chains[i].WS = os.Expand(cfg.Chains[i].WS, os.Getenv)
if !slugPattern.MatchString(cfg.Chains[i].Slug) {
return Config{}, fmt.Errorf("chain[%d]: invalid slug %q", i, cfg.Chains[i].Slug)
}
}
return cfg, nil
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
)
// staticFS is the embedded SPA. The Dockerfile populates static/ from a
// luxfi/explore build before the Go build runs; for local dev a placeholder
// index.html is committed so go:embed always succeeds.
//
//go:embed all:static
var staticFS embed.FS
// Frontend serves the embedded SPA, runtime config (/envs.js), and per-host
// brand assets (/icon.svg, /logo.svg). Brand assets read from disk on every
// request so a deploy can swap them without rebuilding the binary.
type Frontend struct {
cfg Config
registry *ChainRegistry
root fs.FS
index []byte
}
// NewFrontend returns a frontend handler bound to a config and chain registry.
func NewFrontend(cfg Config, r *ChainRegistry) (*Frontend, error) {
root, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, err
}
idx, err := fs.ReadFile(root, "index.html")
if err != nil {
idx = []byte("<!doctype html><title>Explorer</title>")
}
return &Frontend{cfg: cfg, registry: r, root: root, index: idx}, nil
}
// Mount installs / (SPA), /envs.js, /icon.svg, /logo.svg on mux.
func (f *Frontend) Mount(mux *http.ServeMux) {
mux.HandleFunc("GET /envs.js", f.handleEnvs)
mux.HandleFunc("GET /icon.svg", f.handleIcon)
mux.HandleFunc("GET /logo.svg", f.handleLogo)
mux.HandleFunc("/", f.handleSPA)
}
// handleSPA serves embedded static assets and falls back to index.html for
// any path the SPA owns (client-side routing).
func (f *Frontend) handleSPA(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
f.writeIndex(w)
return
}
file, err := f.root.Open(p)
if err != nil {
f.writeIndex(w)
return
}
defer file.Close()
stat, err := file.Stat()
if err != nil || stat.IsDir() {
f.writeIndex(w)
return
}
http.ServeFileFS(w, r, f.root, p)
}
func (f *Frontend) writeIndex(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write(f.index)
}
// handleEnvs returns runtime config the SPA reads instead of build-time env
// vars. This is what makes per-network customization possible without a
// rebuild: deploy the same image, mount a different chains.yaml, get a
// different brand + chain list.
func (f *Frontend) handleEnvs(w http.ResponseWriter, r *http.Request) {
hostBrand := f.brandForHost(r.Host)
chains := f.chainListJSON()
env := map[string]any{
"VITE_CHAINS": chains,
"VITE_NETWORKS": f.cfg.Networks,
"VITE_BRAND": hostBrand,
}
body, _ := json.Marshal(env)
var buf bytes.Buffer
buf.WriteString("window.ENV = ")
buf.Write(body)
buf.WriteString(";\n")
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "no-cache")
w.Write(buf.Bytes())
}
// handleIcon serves the per-host or default icon.svg from disk.
func (f *Frontend) handleIcon(w http.ResponseWriter, r *http.Request) {
f.serveBrandFile(w, r, func(b Brand) string { return b.IconFile }, "icon.svg")
}
// handleLogo serves the per-host or default logo.svg from disk.
func (f *Frontend) handleLogo(w http.ResponseWriter, r *http.Request) {
f.serveBrandFile(w, r, func(b Brand) string { return b.LogoFile }, "logo.svg")
}
// serveBrandFile resolves the file path from the matched brand (per-chain or
// default) and sends it. Falls back to the embedded SPA's named asset if no
// disk override is configured.
func (f *Frontend) serveBrandFile(w http.ResponseWriter, r *http.Request, pick func(Brand) string, fallback string) {
brand := f.brandStruct(r.Host)
if path := pick(brand); path != "" {
if data, err := os.ReadFile(path); err == nil {
w.Header().Set("Content-Type", contentTypeFor(path))
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write(data)
return
}
}
if data, err := fs.ReadFile(f.root, fallback); err == nil {
w.Header().Set("Content-Type", contentTypeFor(fallback))
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write(data)
return
}
http.NotFound(w, r)
}
// brandForHost returns a JSON-shaped brand for /envs.js. It strips file paths.
func (f *Frontend) brandForHost(host string) map[string]any {
b := f.brandStruct(host)
return map[string]any{
"name": b.Name,
"coin": b.Coin,
"accentColor": b.AccentColor,
"iconUrl": b.IconURL,
"logoUrl": b.LogoURL,
}
}
// brandStruct returns the Brand for a request host: a chain whose name or
// slug matches the hostname wins; otherwise the global default.
func (f *Frontend) brandStruct(host string) Brand {
host = strings.ToLower(strings.SplitN(host, ":", 2)[0])
for _, c := range f.cfg.Chains {
if c.Brand == nil {
continue
}
if strings.HasPrefix(host, c.Slug+".") || host == c.Slug {
return *c.Brand
}
}
return f.cfg.BrandDefault
}
// chainListJSON returns a SPA-friendly chain list from the live registry,
// not the static config — chains added via mDNS/admin show up immediately.
var chainListMu sync.Mutex
func (f *Frontend) chainListJSON() []map[string]any {
chainListMu.Lock()
defer chainListMu.Unlock()
out := []map[string]any{}
if f.registry == nil {
return out
}
for _, c := range f.registry.List() {
out = append(out, map[string]any{
"slug": c.Slug,
"name": c.Name,
"chainId": c.ChainID,
"rpc": c.RPC,
"coin": c.CoinSymbol,
"type": c.Type,
"default": c.Default,
})
}
return out
}
// contentTypeFor maps a file extension to a Content-Type, defaulting to
// application/octet-stream.
func contentTypeFor(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".ico":
return "image/x-icon"
default:
return "application/octet-stream"
}
}
// fingerprint returns a short, stable identifier for the embedded asset
// bundle, useful for cache-busting log lines at startup.
func fingerprint() string {
root, err := fs.Sub(staticFS, "static")
if err != nil {
return "0"
}
count := 0
_ = fs.WalkDir(root, ".", func(_ string, _ fs.DirEntry, _ error) error {
count++
return nil
})
return fmt.Sprintf("assets=%d", count)
}
+81 -7
View File
@@ -1,20 +1,94 @@
module github.com/luxfi/explorer-unified
module github.com/luxfi/explorer
go 1.26.1
require (
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/luxfi/graph v0.1.0
github.com/luxfi/indexer v0.0.0-00010101000000-000000000000
github.com/luxfi/mdns v0.1.0
github.com/mattn/go-sqlite3 v2.0.3+incompatible
gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/hpke v0.4.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/miekg/dns v1.1.62 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/tools v0.22.0 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/hanzoai/replicate v0.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lib/pq v1.12.1 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/luxfi/age v1.4.0 // indirect
github.com/luxfi/cache v1.2.1 // indirect
github.com/luxfi/compress v0.0.5 // indirect
github.com/luxfi/concurrent v0.0.3 // indirect
github.com/luxfi/crypto v1.17.45 // indirect
github.com/luxfi/database v1.17.44 // indirect
github.com/luxfi/ids v1.2.9 // indirect
github.com/luxfi/log v1.4.1 // indirect
github.com/luxfi/math v1.2.4 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/metric v1.5.1 // indirect
github.com/luxfi/mock v0.1.1 // indirect
github.com/luxfi/zap v0.2.1 // indirect
github.com/luxfi/zapdb/v4 v4.9.3 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/markusmobius/go-dateparser v1.2.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/superfly/ltx v0.5.1 // indirect
github.com/tetratelabs/wazero v1.2.1 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.0 // indirect
)
replace (
github.com/luxfi/graph => ../graph
github.com/luxfi/indexer => ../indexer
)
+325 -13
View File
@@ -1,41 +1,353 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
cloud.google.com/go v0.111.0 h1:YHLKNupSD1KqjDbQ3+LVdQ81h/UJbJyZG203cEfnQgM=
cloud.google.com/go v0.111.0/go.mod h1:0mibmpKP1TyOOFYQY5izo0LnT+ecvOQ0Sg3OdmMiNRU=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
cloud.google.com/go/storage v1.36.0 h1:P0mOkAcaJxhCTvAkMhxMfrTKiNcub4YmmPBtlhAyTr8=
cloud.google.com/go/storage v1.36.0/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HRiRH3CR3Mj8pxqCcdD5A=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 h1:MhRfI58HblXzCtWEZCO0feHs8LweePB3s90r7WaR1KU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0/go.mod h1:okZ+ZURbArNdlJ+ptXoyHNuOETzOl1Oww19rm8I2WLA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 h1:FwladfywkNirM+FZYLBR2kBz5C8Tg0fw5w5Y7meRXWI=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2/go.mod h1:vv5Ad0RrIoT1lJFdWBZwt4mB1+j+V8DUroixmKDTCdk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 h1:TBzelXBdnzDy+HCrBMcomEnhrmigkWOI1/mIPCi2u4M=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 h1:9vWXHtaepwoAl/UuKzxwgOoJDXPCC3hvgNMfcmdS2Tk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18/go.mod h1:sKuUZ+MwUTuJbYvZ8pK0x10LvgcJK3Y4rmh63YBekwk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
github.com/hanzoai/replicate v0.6.0 h1:YxjIynlo3iKtpf6GeQngeB5RbexIKPPKsZIoq5EHe3E=
github.com/hanzoai/replicate v0.6.0/go.mod h1:PBvHNQ7cXBJejTWDWxXWvtUwjT5O66pTC7npxLWu8H0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.12.1 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow=
github.com/lib/pq v1.12.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/luxfi/age v1.4.0 h1:tU5q65RQSQdaVq64Z/DVUhiPQMoMPQT6pr41LsvG0AQ=
github.com/luxfi/age v1.4.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
github.com/luxfi/compress v0.0.5 h1:4tEUHw5MK1bu5UOjfYCt4OKMiH7yykIgmGPRA/BfJTM=
github.com/luxfi/compress v0.0.5/go.mod h1:Cc1yxD2pfzrvpO32W2GDwLKff+CylHEvzZh2Ko8RSIU=
github.com/luxfi/concurrent v0.0.3 h1:eJyv1fhaC0jMLMw6+QS774cUmp7GK+ouMgvLCqnC7cc=
github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw=
github.com/luxfi/constants v1.4.3 h1:bcqUO8twMOhHWCgN/RbjfWulVPbvUQcxDkssaRrCx4g=
github.com/luxfi/constants v1.4.3/go.mod h1:ENkJ121cmDEkwQPDiKK4QhnTnW9u37PGpepbrdVcAmc=
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
github.com/luxfi/crypto v1.17.45 h1:uGK0y4+aLipE/M0YIQ5hcsWv0ZG0E4cPv03a94K/eLE=
github.com/luxfi/crypto v1.17.45/go.mod h1:GnAkhQ7HNs3X0Tzx5nOONS3kl0yRmWHbDcRO5ffILsg=
github.com/luxfi/database v1.17.44 h1:hfiTls7sqbweW+o4iaZqB8P997paC+vpgWmhN6v5MJ0=
github.com/luxfi/database v1.17.44/go.mod h1:6Ey5y3I0WNLHbxIlIdFqUuKfBg+b0fAgTA8FgRgQ8zg=
github.com/luxfi/geth v1.16.69 h1:CHO6xTZ+A+3itk94ts4uyVRJajNVP3RxWTjJp5qGOlk=
github.com/luxfi/geth v1.16.69/go.mod h1:8eEO1hW5sa6OH2VeCMaCPnRz28JBxFvCPBCWPLsU2ck=
github.com/luxfi/ids v1.2.9 h1:+yjdhXW99drnd2Zlp1u/p8k3G23W3/1btJQ4ogHawUI=
github.com/luxfi/ids v1.2.9/go.mod h1:khJOEdOPxd22yn0jcVrnbX1ADa0GHn5Y74gvCzN5BYc=
github.com/luxfi/log v1.4.1 h1:rIfFRodb9jrD/w7KayaUk0Oc+37PaQQdKEEMJCjR8gw=
github.com/luxfi/log v1.4.1/go.mod h1:64IE3xRMJcpkQwnPUfJw3pDj7wU0kRS7BZ9wM7R72jk=
github.com/luxfi/math v1.2.4 h1:iVz7s0gToCNUU/2cLT8gUa8MLzc0YRp4jBmeXBeKdXk=
github.com/luxfi/math v1.2.4/go.mod h1:i+Am9YvFsqDE75ZW8MPE62XCXGukXNiN/7mD9SKxxZY=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/mdns v0.1.0 h1:VB3mQcETc9j5SY1S6lAgFtuGr/rjWuDgPYnxS+OKWMQ=
github.com/luxfi/mdns v0.1.0/go.mod h1:/3dheKVjUk2yiS/ocH1IDzeLXOIe+kpVsErIGDOZdiQ=
github.com/luxfi/metric v1.5.1 h1:6tVarXMnNR3Xzud8FYUHjtXabTll3HI/OEqG0tgLAcI=
github.com/luxfi/metric v1.5.1/go.mod h1:PkD4D4JoGuyKtfUkqPNYkrg9xKrJeNVZFdW/5XvAe/A=
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
github.com/luxfi/sampler v1.0.0/go.mod h1:f96/ozlj9vFfZj+akLtrHn4VpulQahwB+MQQhpeIekk=
github.com/luxfi/utils v1.1.3 h1:yDEuuJ6bm9H8DzOzzveXI1AZlu/AeSpHNRrY1akbyc0=
github.com/luxfi/utils v1.1.3/go.mod h1:lskchZiYYRBFsSyYg3NWLPw211uRyxIs3/wAzrfSIAk=
github.com/luxfi/zap v0.2.1 h1:5uU3Z47Nu/FNcLqYeKZ4B0f+0ZDF79/2M/9FRN/1/Hw=
github.com/luxfi/zap v0.2.1/go.mod h1:CobyEg6MNO6qZ5wn3kVEw6QCxFVZvvGMnEKsC5sJZtI=
github.com/luxfi/zapdb/v4 v4.9.3 h1:KV1DrbZqtZsZvrWyPw5D8WHR0DxzqW2jHjqx6obxgoM=
github.com/luxfi/zapdb/v4 v4.9.3/go.mod h1:BrpLaDeYy+smON4ASDPCFKWrQ58CXhy3WNEPGna7vuU=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/markusmobius/go-dateparser v1.2.4 h1:2e8XJozaERVxGwsRg72coi51L2aiYqE2gukkdLc85ck=
github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFhtKaqnUcznY2cL7gNugY=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.44.0 h1:ECKVrDLdh/kDPV1g0gAQ+2+m2KprqZK5O/eJAyAnH2M=
github.com/nats-io/nats.go v1.44.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a h1:r4YWl0uVObCbBFvj1VsIlyHzgZwZOHvY1KdRaQjzzUc=
github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a/go.mod h1:iW4cSew5PAb1sMZiTEkVJAIBNrepaB6jTYjeP47WtI0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU=
github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/superfly/ltx v0.5.1 h1:vGUeBhKvBKZ2s2TsTrOSahz+m0PswfqOWDAD+ICiiYY=
github.com/superfly/ltx v0.5.1/go.mod h1:Nf50QAIXU/ET4ua3AuQ2fh31MbgNQZA7r/DYx6Os77s=
github.com/tetratelabs/wazero v1.2.1 h1:J4X2hrGzJvt+wqltuvcSjHQ7ujQxA9gb6PeMs4qlUWs=
github.com/tetratelabs/wazero v1.2.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ=
github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw=
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.154.0 h1:X7QkVKZBskztmpPKWQXgjJRPA2dJYrL6r+sYPRLj050=
google.golang.org/api v0.154.0/go.mod h1:qhSMkM85hgqiokIYsrRyKxrjfBeIhgl4Z2JmeRkYylc=
google.golang.org/genproto v0.0.0-20231212172506-995d672761c0 h1:YJ5pD9rF8o9Qtta0Cmy9rdBwkSjrTCT6XTiUQVOtIos=
google.golang.org/genproto v0.0.0-20231212172506-995d672761c0/go.mod h1:l/k7rMz0vFTBPy+tFSGvXEd3z+BcoG1k7EHbqm+YBsY=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+125 -72
View File
@@ -1,25 +1,24 @@
// Package main is the unified explorer entry point.
// Package main is the unified Lux explorer: a single Go binary that embeds
// the SPA, indexes one or more chains via github.com/luxfi/indexer, runs a
// per-chain GraphQL engine via github.com/luxfi/graph, and exposes the
// admin/realtime API and frontend assets on one HTTP listener.
//
// Runtime-mutable chain registry with mDNS discovery and WebSocket realtime.
// Build: go build -o explorer .
// Run: explorer --config /etc/explorer/chains.yaml
//
// Build: GOWORK=off go build -o explorer .
// Run: explorer --config chains.yaml
// explorer --mdns=false --port 8090
// Routes:
//
// Env:
// PORT — HTTP port (default 8090)
// EXPLORER_CONFIG — path to chains.yaml
// EXPLORER_MDNS — enable mDNS discovery (default true)
// CHAINS — inline chain list: "cchain:http://...,zoo:http://..."
//
// Admin API:
// GET /v1/explorer/admin/chains — list chains
// POST /v1/explorer/admin/chains — add chain
// PUT /v1/explorer/admin/chains/{slug} — update chain
// DELETE /v1/explorer/admin/chains/{slug} — remove chain
//
// Realtime:
// WS /v1/explorer/realtime — subscribe to blocks/transactions per chain
// / 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
package main
import (
@@ -31,102 +30,153 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
var version = "dev"
func main() {
port := flag.Int("port", envInt("PORT", 8090), "HTTP port")
configFile := flag.String("config", env("EXPLORER_CONFIG", ""), "chains.yaml path")
enableMDNS := flag.Bool("mdns", envBool("EXPLORER_MDNS", true), "auto-discover local chains via mDNS")
var (
httpAddr = flag.String("http", "", "HTTP listen address (default :8090 / $HTTP_ADDR)")
dataDir = flag.String("data", "", "Data directory (default $DATA_DIR or ~/.explorer/data)")
configPath = flag.String("config", "", "Path to chains.yaml ($EXPLORER_CONFIG)")
enableMDNS = flag.Bool("mdns", envBool("EXPLORER_MDNS", false), "Auto-discover chains via mDNS")
showVer = flag.Bool("version", false, "Show version and exit")
)
flag.Parse()
if *showVer {
fmt.Printf("explorer %s (%s)\n", version, fingerprint())
os.Exit(0)
}
if *httpAddr == "" {
*httpAddr = env("HTTP_ADDR", ":8090")
if p := os.Getenv("PORT"); p != "" && *httpAddr == ":8090" {
*httpAddr = ":" + p
}
}
if *dataDir == "" {
home, _ := os.UserHomeDir()
*dataDir = env("DATA_DIR", filepath.Join(home, ".explorer", "data"))
}
if *configPath == "" {
*configPath = env("EXPLORER_CONFIG", "")
}
if *configPath == "" {
*configPath = findConfig(*dataDir)
}
var cfg Config
if *configPath != "" {
c, err := LoadConfig(*configPath)
if err != nil {
log.Fatalf("[explorer] config: %v", err)
}
cfg = c
}
if cfg.DataDir == "" {
cfg.DataDir = *dataDir
}
if cfg.HTTPAddr == "" {
cfg.HTTPAddr = *httpAddr
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("[explorer] shutdown signal received")
cancel()
}()
go func() { <-sigCh; log.Println("[explorer] shutdown"); cancel() }()
// Chain registry (runtime mutable)
registry := NewChainRegistry()
supervisor := NewChainSupervisor(cfg.DataDir)
registry.AttachSupervisor(supervisor)
// Load from config file
if *configFile != "" {
if err := registry.LoadFromFile(*configFile); err != nil {
log.Fatalf("[explorer] config: %v", err)
for _, c := range cfg.Chains {
c.Source = "config"
if err := registry.Add(c); err != nil {
log.Printf("[explorer] skip %s from config: %v", c.Slug, err)
}
}
// Load from env
if chains := env("CHAINS", ""); chains != "" {
if chains := os.Getenv("CHAINS"); chains != "" {
registry.LoadFromEnv(chains)
}
// Start mDNS discovery
if *enableMDNS {
go registry.StartMDNSDiscovery()
}
// Start realtime hub
go registry.hub.Run(ctx)
go supervisor.Wait(ctx)
frontend, err := NewFrontend(cfg, registry)
if err != nil {
log.Fatalf("[explorer] frontend: %v", err)
}
// HTTP mux
mux := http.NewServeMux()
// Health
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"chains": registry.Count(),
"status": "ok",
"version": version,
"chains": registry.Count(),
})
})
// Admin API
mux.HandleFunc("GET /v1/explorer/admin/chains", registry.HandleList)
mux.HandleFunc("POST /v1/explorer/admin/chains", registry.HandleAdd)
mux.HandleFunc("PUT /v1/explorer/admin/chains/{slug}", registry.HandleUpdate)
mux.HandleFunc("DELETE /v1/explorer/admin/chains/{slug}", registry.HandleRemove)
// Realtime WebSocket
mux.HandleFunc("/v1/explorer/realtime", registry.hub.HandleRealtime)
// Realtime stats
mux.HandleFunc("GET /v1/explorer/realtime/stats", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(registry.hub.Stats())
})
log.Printf("[explorer] listening :%d (%d chains, mdns=%v)", *port, registry.Count(), *enableMDNS)
supervisor.MountRoutes(mux)
frontend.Mount(mux)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", *port),
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 16,
}
log.Printf("[explorer] %s listening %s data=%s chains=%d mdns=%v %s",
version, cfg.HTTPAddr, cfg.DataDir, registry.Count(), *enableMDNS, fingerprint())
server := &http.Server{Addr: cfg.HTTPAddr, Handler: withSecurity(mux)}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[explorer] server: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutCancel()
server.Shutdown(shutCtx)
log.Println("[explorer] stopped")
}
// withSecurity adds the baseline security headers and CORS preflight handler.
func withSecurity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
@@ -134,23 +184,26 @@ func env(key, fallback string) string {
return fallback
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
var n int
fmt.Sscanf(v, "%d", &n)
return n
}
return fallback
}
func envBool(key string, fallback bool) bool {
v := os.Getenv(key)
switch v {
switch strings.ToLower(os.Getenv(key)) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
default:
return fallback
}
return fallback
}
// findConfig probes for a chains.yaml in the standard locations.
func findConfig(dataDir string) string {
for _, p := range []string{
filepath.Join(dataDir, "chains.yaml"),
"chains.yaml",
"/etc/explorer/chains.yaml",
} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
# Patch Go embed directive to include _next prefixed files
sed -i 's|//go:embed static/\*|//go:embed all:static|' /src/cmd/explorer/frontend.go
+36 -44
View File
@@ -6,25 +6,14 @@ import (
"log"
"net/http"
"os"
"regexp"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// ChainConfig defines a single chain to index.
type ChainConfig struct {
Slug string `json:"slug" yaml:"slug"`
Name string `json:"name" yaml:"name"`
ChainID int64 `json:"chain_id" yaml:"chain_id"`
RPC string `json:"rpc" yaml:"rpc"`
Type string `json:"type" yaml:"type"` // evm, dag, pchain, xchain
Default bool `json:"default" yaml:"default"`
Source string `json:"source" yaml:"-"` // config, env, mdns, admin
}
// ChainsFile is the top-level YAML structure for chains.yaml.
// ChainsFile is the legacy `chains: [...]` YAML shape used by tests and the
// minimal config. The full Config struct in config.go is the canonical schema.
type ChainsFile struct {
Chains []ChainConfig `yaml:"chains"`
}
@@ -34,14 +23,12 @@ type ChainEntry struct {
Config ChainConfig
}
// slugPattern restricts slugs to lowercase alphanumeric + hyphen.
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
// ChainRegistry is a thread-safe, runtime-mutable chain store.
type ChainRegistry struct {
mu sync.RWMutex
chains map[string]*ChainEntry // slug -> entry
chains map[string]*ChainEntry
hub *RealtimeHub
sup *ChainSupervisor // nil = registry-only mode (tests)
}
// NewChainRegistry creates an empty registry.
@@ -52,7 +39,12 @@ func NewChainRegistry() *ChainRegistry {
}
}
// Add registers a chain. Returns error if slug is invalid or already exists from a higher-priority source.
// AttachSupervisor wires a ChainSupervisor so admin/config/mdns adds also
// spawn per-chain indexer + graph goroutines, and removes cancel them.
func (r *ChainRegistry) AttachSupervisor(s *ChainSupervisor) { r.sup = s }
// Add registers a chain. mDNS-sourced chains never override higher-priority
// (config/env/admin) entries; admin/config/env duplicates return an error.
func (r *ChainRegistry) Add(cfg ChainConfig) error {
if !slugPattern.MatchString(cfg.Slug) {
return fmt.Errorf("invalid slug %q", cfg.Slug)
@@ -65,50 +57,54 @@ func (r *ChainRegistry) Add(cfg ChainConfig) error {
}
r.mu.Lock()
defer r.mu.Unlock()
if existing, ok := r.chains[cfg.Slug]; ok {
// mDNS never overrides manual config
if cfg.Source == "mdns" && existing.Config.Source != "mdns" {
r.mu.Unlock()
return fmt.Errorf("chain %q already configured via %s", cfg.Slug, existing.Config.Source)
}
// All other sources: reject duplicate (use Update to modify)
if cfg.Source != "mdns" {
r.mu.Unlock()
return fmt.Errorf("chain %q already exists", cfg.Slug)
}
// mDNS re-discovery: update last-seen silently
existing.Config.RPC = cfg.RPC
r.mu.Unlock()
return nil
}
r.chains[cfg.Slug] = &ChainEntry{Config: cfg}
r.mu.Unlock()
log.Printf("[registry] added chain %s (%s) source=%s", cfg.Slug, cfg.RPC, cfg.Source)
if r.sup != nil {
r.sup.start(cfg)
}
return nil
}
// Remove deletes a chain by slug.
func (r *ChainRegistry) Remove(slug string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.chains[slug]; !ok {
r.mu.Unlock()
return false
}
delete(r.chains, slug)
r.mu.Unlock()
log.Printf("[registry] removed chain %s", slug)
if r.sup != nil {
r.sup.stop(slug)
}
return true
}
// Update modifies an existing chain's config.
func (r *ChainRegistry) Update(slug string, cfg ChainConfig) error {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.chains[slug]
if !ok {
r.mu.Unlock()
return fmt.Errorf("chain %q not found", slug)
}
if cfg.Name != "" {
entry.Config.Name = cfg.Name
}
@@ -123,8 +119,15 @@ func (r *ChainRegistry) Update(slug string, cfg ChainConfig) error {
}
entry.Config.Default = cfg.Default
entry.Config.Source = "admin"
updated := entry.Config
r.mu.Unlock()
log.Printf("[registry] updated chain %s", slug)
if r.sup != nil {
r.sup.stop(slug)
r.sup.start(updated)
}
return nil
}
@@ -132,7 +135,6 @@ func (r *ChainRegistry) Update(slug string, cfg ChainConfig) error {
func (r *ChainRegistry) Get(slug string) (ChainConfig, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
entry, ok := r.chains[slug]
if !ok {
return ChainConfig{}, false
@@ -144,7 +146,6 @@ func (r *ChainRegistry) Get(slug string) (ChainConfig, bool) {
func (r *ChainRegistry) List() []ChainConfig {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]ChainConfig, 0, len(r.chains))
for _, e := range r.chains {
out = append(out, e.Config)
@@ -159,21 +160,21 @@ func (r *ChainRegistry) Count() int {
return len(r.chains)
}
// LoadFromFile loads chains from a YAML file.
// LoadFromFile loads chains from a YAML file using the legacy `chains: [...]`
// shape. Full Config (with brand, networks, etc.) loads via LoadConfig.
func (r *ChainRegistry) LoadFromFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
var f ChainsFile
if err := yaml.Unmarshal(data, &f); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
for _, c := range f.Chains {
c.Source = "config"
c.RPC = os.Expand(c.RPC, os.Getenv)
c.WS = os.Expand(c.WS, os.Getenv)
if err := r.Add(c); err != nil {
log.Printf("[registry] skip %s from config: %v", c.Slug, err)
}
@@ -207,8 +208,6 @@ func (r *ChainRegistry) LoadFromEnv(val string) {
}
}
// --- HTTP Handlers ---
// HandleList returns all chains as JSON.
func (r *ChainRegistry) HandleList(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -218,7 +217,7 @@ func (r *ChainRegistry) HandleList(w http.ResponseWriter, req *http.Request) {
})
}
// HandleAdd creates a new chain from JSON body.
// HandleAdd creates a new chain from a JSON body.
func (r *ChainRegistry) HandleAdd(w http.ResponseWriter, req *http.Request) {
var cfg ChainConfig
if err := json.NewDecoder(http.MaxBytesReader(w, req.Body, 4096)).Decode(&cfg); err != nil {
@@ -226,14 +225,12 @@ func (r *ChainRegistry) HandleAdd(w http.ResponseWriter, req *http.Request) {
return
}
cfg.Source = "admin"
if err := r.Add(cfg); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(cfg)
@@ -246,20 +243,17 @@ func (r *ChainRegistry) HandleUpdate(w http.ResponseWriter, req *http.Request) {
http.Error(w, `{"error":"slug required"}`, http.StatusBadRequest)
return
}
var cfg ChainConfig
if err := json.NewDecoder(http.MaxBytesReader(w, req.Body, 4096)).Decode(&cfg); err != nil {
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
return
}
if err := r.Update(slug, cfg); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
updated, _ := r.Get(slug)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(updated)
@@ -272,14 +266,12 @@ func (r *ChainRegistry) HandleRemove(w http.ResponseWriter, req *http.Request) {
http.Error(w, `{"error":"slug required"}`, http.StatusBadRequest)
return
}
if !r.Remove(slug) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "removed", "slug": slug})
}
-3
View File
@@ -308,9 +308,6 @@ func TestEnvHelpers(t *testing.T) {
if env("NONEXISTENT_KEY_12345", "fallback") != "fallback" {
t.Fatal("env fallback failed")
}
if envInt("NONEXISTENT_KEY_12345", 42) != 42 {
t.Fatal("envInt fallback failed")
}
if envBool("NONEXISTENT_KEY_12345", true) != true {
t.Fatal("envBool fallback failed")
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#0b1220"/><path d="M16 44 L32 16 L48 44 Z" fill="#5cf"/></svg>

After

Width:  |  Height:  |  Size: 166 B

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Explorer</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<script src="/envs.js"></script>
</head>
<body>
<div id="root">Loading explorer…</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 64"><rect width="200" height="64" fill="#0b1220"/><text x="20" y="42" fill="#5cf" font-family="ui-sans-serif,system-ui,sans-serif" font-weight="700" font-size="28">Explorer</text></svg>

After

Width:  |  Height:  |  Size: 243 B

+423
View File
@@ -0,0 +1,423 @@
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/evm"
"github.com/luxfi/indexer/explorer"
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
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/* (no slug prefix)
}
type chainState struct {
cfg ChainConfig
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewChainSupervisor creates a supervisor rooted at dataDir.
func NewChainSupervisor(dataDir string) *ChainSupervisor {
return &ChainSupervisor{
dataDir: dataDir,
state: make(map[string]*chainState),
}
}
// 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,
}, store)
if err != nil {
log.Printf("[%s] evm indexer: %v", cfg.Slug, err)
return
}
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)
}
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
}
if cfg.Type == "" || cfg.Type == "evm" {
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='evm_blocks'").Scan(&n)
db.Close()
if n == 0 {
if time.Now().After(deadline) {
log.Printf("[%s] api waiting for evm_blocks", cfg.Slug)
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,
})
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.New(cfg.RPC, store)
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.
func (s *ChainSupervisor) dispatchIndexer(w http.ResponseWriter, r *http.Request) {
slug, rest, ok := splitSlug(r.URL.Path, "/v1/indexer/")
if !ok {
s.serveDefault(w, r, "")
return
}
if h, found := s.indexerRoutes.Load(slug); found {
h.(http.Handler).ServeHTTP(w, r)
return
}
s.serveDefault(w, r, rest)
}
// serveDefault forwards an unprefixed /v1/indexer/* request to the default
// chain. The standalone server is mounted under its slug, so we rewrite the
// URL to include the slug before delegating.
func (s *ChainSupervisor) serveDefault(w http.ResponseWriter, r *http.Request, rest string) {
s.mu.Lock()
def := s.defaultSlug
s.mu.Unlock()
if def == "" {
http.Error(w, `{"error":"no default chain"}`, http.StatusNotFound)
return
}
h, ok := s.indexerRoutes.Load(def)
if !ok {
http.Error(w, `{"error":"chain not ready"}`, http.StatusServiceUnavailable)
return
}
r2 := r.Clone(r.Context())
r2.URL.Path = "/v1/indexer/" + def + "/" + strings.TrimPrefix(rest, "/")
h.(http.Handler).ServeHTTP(w, r2)
}
// 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
}