# v2 git bundle
6d9764e6ea1e2f689eb9a47ec743655f8d766555 refs/heads/bundle
6d9764e6ea1e2f689eb9a47ec743655f8d766555 HEAD

PACK     $�-xÝ"ýtree 533a223a4a6a05fc74cb6c11ab0d3684a0272400
parent a3477f6bfa40f7f4b1ffdf445de792f2422d756f
author Zach Kelling <z@zeekay.io> 1781115662 -0700
committer Zach Kelling <z@zeekay.io> 1781115662 -0700

fix(spa): brand merging + env-tag canonicalisation

- BRAND is now a merge of SERVER_BRAND (chains.yaml-supplied) and
  INFERRED_BRAND (host parser table). Server wins on name/coin/accent
  when it's non-default; inferred fills in iconUrl/logoUrl when the
  chain config doesn't carry per-brand SVG paths. Stops Zoo rendering
  with the default Lux favicon when chains.yaml omits icon_url.
- networkFromHost now canonicalises "test"â†’"testnet", "dev"â†’"devnet"
  so the dropdown label matches the server-side VITE_CURRENT_NETWORK.
ÖóÎ�8x�rütree c961de9403495557c9b2fdb3af810c6ae6457625
parent b65e5ce9c6064e82b14e734ef028d2165d1916e0
author Zach Kelling <z@zeekay.io> 1781114859 -0700
committer Zach Kelling <z@zeekay.io> 1781114859 -0700

fix(spa): client-side network derivation fallback for older images

The Go-side networksForHost was added in v1.2.20 but the deployed
image is still v1.2.18 (CI pool gap â€” lux-build AutoscalingRunnerSet
is missing from arc-system). The SPA now also parses location.host
client-side so the network switcher works when /envs.js doesn't ship
VITE_CURRENT_NETWORK / VITE_NETWORK_HOSTS.

Same shape rules as the Go side: explore.{brand}.{tld} (mainnet,
preserve tld), explorer.{brand}-test.network (testnet, .network is
canonical). Numeric and *.local hosts skip the dropdown.

This lets us ship the cross-network switcher via an EXPLORER_STATIC_DIR
overlay (ConfigMap-mounted SPA) without rebuilding the image.
™ß3žš^xêútree c80d86484e1d9fae913ea8a910bfaf7c79fbd98a
parent d7baefc3b8b37e83376aaf50c4d0aec1db4efd9e
author Zach Kelling <z@zeekay.io> 1781114715 -0700
committer Zach Kelling <z@zeekay.io> 1781114715 -0700

feat(frontend): cross-network switcher (mainnet â†” testnet) in SPA

handleEnvs now returns VITE_CURRENT_NETWORK + VITE_NETWORK_HOSTS so
the SPA can render a Network dropdown next to the Chain picker. When
the user picks "testnet" from explore.zoo.network the SPA redirects
to explorer.zoo-test.network (and vice-versa).

Sibling host derivation lives entirely in Go:
- parseBrandEnv extracts (brand, env, dotted, ok) from any of our
  deployed host shapes â€” explore.{brand}.tld, explorer.{brand}-test.network,
  explore-{brand}.lux.network, explore-{brand}-test.lux.network.
- networksForHost composes the sibling map. Mainnet TLD is preserved
  when the user is on a brand-owned host (explore.hanzo.ai stays .ai);
  testnet is always {brand}-test.network because that's the canonical
  shape.
- isValidBrand rejects numeric stems so an IP host (10.0.0.1) doesn't
  fall through to "explore.10.0.0.1" / "explorer.10-test.network".

SPA renders the dropdown above the chain picker; hidden when the host
yields fewer than two siblings (local dev, admin tooling).

Tests cover all four canonical surfaces (lux+zoo Ã— mainnet+testnet),
hanzo's TLD asymmetry (.ai mainnet â†’ .network testnet), the
explore-{slug}.lux.network prefix shape, and the unparseable-host
guards (IPs, in-cluster service names, localhost).
ÜŸ’>xâütree c08b7d0a8bbefd9faf6dd8fe59ba3379c6698e95
parent 8f103933932a783da0a8f82bd117d473e0f0dd47
author Zach Kelling <z@zeekay.io> 1781113587 -0700
committer Zach Kelling <z@zeekay.io> 1781113587 -0700

fix(brand): per-host whitelabel + default-chain â€” covers *-test.network

- host matcher now accepts explorer.* (alias of explore.*) and
  {prefix}.{slug}-{env}.tld shapes â€” so explorer.zoo-test.network
  resolves to the Zoo brand (was falling through to Lux default).
- new chainSlugForHost helper drives a default-chain promotion in
  handleEnvs: when the host pins a chain, the SPA opens on that
  chain instead of the config-default cchain. explore.zoo.network
  now defaults to Zoo, not C-Chain.
- factored host matching into pure hostMatchesSlug predicate so
  brand and slug paths share one ground truth.
- tests cover both regressions (explorer.zoo-test.network â†’ Zoo,
  explore.zoo.network â†’ slug=zoo) plus the existing
  cross-contamination guards (zoofoo must not match zoo).
çZ&›xôþtree ea82f477bc4ab46c6f59af4e9fe06a4e51ffc64e
parent e5156b7cb5efc829ca0f0c92443a2a825b175ed5
author Zach Kelling <z@zeekay.io> 1780808721 -0700
committer Zach Kelling <z@zeekay.io> 1780808721 -0700

go: 1.26.3 â†’ 1.26.4 (security: crypto/x509, mime, net/textproto)
?ÝQ– xùýtree c0d884c572efe675e44d5dd11a3e6f6dad153f95
parent 9631f27f4b2dd58a73b8c35792482be85fb9d880
author Zach Kelling <z@zeekay.io> 1780700385 -0700
committer Zach Kelling <z@zeekay.io> 1780700385 -0700

docs: document the unified explorer bundle deployment

luxfi/explorer + luxfi/graph + luxfi/explore now ship as ONE Deployment
per env on lux-k8s. CLAUDE.md describes the 2-container sidecar layout
(explorer Go + explore Next.js), image versions, manifests, and verified
public URLs across mainnet / testnet / devnet.
3Ý«€”cx4Ëùtree f8d20596c9169308b7ce9ea28fd7c64ba70b8c7e
parent b76299d500648b717a45ab35d024af21173ec3db
author Zach Kelling <z@zeekay.io> 1780697122 -0700
committer Zach Kelling <z@zeekay.io> 1780697122 -0700

fix(frontend): match per-chain Brand for explore-{slug} / api-explore-{slug} hosts

brandStruct only matched bare "{slug}" and "{slug}.tld" host shapes â€”
but the unified explorer ships behind IngressRoutes whose Host()
patterns are exclusively `explore-{slug}.lux.network` and
`api-explore-{slug}.lux.network` plus brand-owned roots like
`explore.hanzo.ai`, `explore.zoo.ngo`, `explore.pars.network`. With
the old matcher every per-chain host fell through to BrandDefault
("Lux Explorer"), so per-chain theming silently no-op'd in prod
(verified 2026-06-05: explore-hanzo.lux.network /envs.js returned
"VITE_BRAND":{"name":"Lux Explorer"} instead of Hanzo).

New matcher recognises the four prefix shapes the IngressRoutes
produce, both with and without an env suffix (-test / -dev):
  - explore-{slug}.tld...        UI host on lux.network
  - api-explore-{slug}.tld...    API host on lux.network
  - explore-{slug}-{env}.tld...  UI host for testnet / devnet variants
  - api-explore-{slug}-{env}...  API host for testnet / devnet variants
  - explore.{slug}.tld...        UI on brand-owned domain
  - api-explore.{slug}.tld...    API on brand-owned domain
Plus the prior bare-{slug} and {slug}-prefix shapes for backward
compat.

Test coverage: TestBrandStructHostMatching, 18 cases incl. the
explore-spcfoo cross-contamination guard ensuring partial-slug-
suffix matches don't bleed.
Ó­%štxJµøtree 5ec2f38deccfc4f8f9b7f7dfeceab992e4d43f15
parent 0acc759fe9f1788964f87ca4718250d19f036709
author Zach Kelling <z@zeekay.io> 1780682753 -0700
committer Zach Kelling <z@zeekay.io> 1780682753 -0700

feat(frontend): runtime static-dir overlay + substantive SPA shell

Two related changes that get the explorer SPA rendering real chain
data without waiting for upstream luxfi/explore to ship a Next.js
static export.

1. Runtime SPA overlay (config.go, frontend.go)

   New cfg.StaticDir (also EXPLORER_STATIC_DIR env var). When set,
   the frontend prefers the on-disk copy of any static asset over the
   embedded FS â€” so a ConfigMap-mounted SPA shell replaces the
   embedded 60-byte placeholder without rebuilding the binary. The
   embedded FS remains the source of truth at build time and the
   fallback when no overlay is configured.

   handleSPA + serveBrandFile both check overlay > embedded. Tests
   cover overlay-present, overlay-missing-file fallback, no-overlay,
   and env-var-only configuration.

2. Substantive SPA shell (static/index.html)

   Replaced the 60-byte stub (<div id="root"></div>) with a vanilla
   JS SPA â€” ~7KB, zero dependencies, no React/Next.js. Reads
   window.ENV from /envs.js, fetches /v1/indexer/{slug}/{stats,blocks,
   transactions}, renders KPIs + Latest Blocks + Latest Transactions
   with per-chain switching and brand color/title overrides. Polls
   every 15s. Stores selected chain in URL hash + localStorage.

   This is the explorer's default UI until upstream luxfi/explore
   ships output: 'export' and the Dockerfile's optional pnpm-build
   overlay activates.

Dockerfile: drop the leftover merge-conflict markers around the SPA
build stage, keep the explanatory comment.

Tests: 4 new in frontend_test.go covering overlay precedence + env-var
fallback. All existing tests (registry, realtime) still pass.
¹T‘x1Îþtree e28c6b94f71317e534e00002089e579f8952751c
parent a1094dbac24bdbedc528ca2c48103a3fdfc9b2c4
author Zach Kelling <z@zeekay.io> 1780423898 -0700
committer Zach Kelling <z@zeekay.io> 1780423898 -0700

chore(deps): go get -u ./...

Updated dependencies to latest minor versions via go get -u + go mod tidy.
0]W—xèþtree 203ccfdb424dfcebde8e5c8c3a0f5c3f6aca776e
parent 7f3f1b64a235cd1e8af879aae47eef154f5981d1
author zooqueen <a@zoo.ngo> 1780289380 -0700
committer zooqueen <a@zoo.ngo> 1780289881 -0700

Add Claude Code project docs (CLAUDE.md, LLM.md)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
“V˜x÷þtree 840eafe94434197f464f52e254277f7f558dea7d
parent ef2c3f454e18b8daaf495c8361c4c090855ff35c
author Zach Kelling <z@zeekay.io> 1779747200 -0700
committer Zach Kelling <z@zeekay.io> 1779747203 -0700

chore: brand-neutral cleanup â€” remove cross-tenant references
ÒaS—xœ�ŽKjÄ0÷>EC6“Å„ÖÇ–†�EîºÕíDddY&süøY=(Šâõ¦
¢*V‚Oc¢½3l)2ë…%ÄÑø8ºàý°QÓ¥ƒÏnæÑj@ÎŽ•ÕEq§6%žCbkd £¯>¨ýœ»kØ;µn¿\–„“Å÷¯Jåþ’×ú&„494hàŠq8i-½ë¿ƒè¶¿uƒ²ˆ>ÎÔ|þ)ûg^—Þ(wÈëý¨Ëµ)	Ìå—'cž‡?
wUžŸxœ��]NÄ Çß9Å$>W(Ý²cÜè6ñÝF
P·Þ^V÷¾Ì÷ÿ735�èz½çJjé»e¯•–¢×FXgCï…±’-˜)V8ØN‹¨4:+½1ýž³Š¤Ò¹TMÕõ×zNÞ16_H•Š¹ÀóÅLQó¡ã¯ãŒSØÙ4¿€PJ�ÒZÂWœ³V�§Zéß æh)O`Öy�):ÚÊ·{Ð¹L¥€Mk¼â§h3Í×Ïîa¤ú–AHá‹;­!ëæ§ÇäNpHjZ y¸Ó;8ÞˆŽT~{gÚ–�rãœŽ,¦”s3™0@Ýþ–ÀèZ˜3Ù¾Á[kF[ËŽý }¤…µŸ2x/Ðütree 68a63bc88e9feb5fbd10abd209fe87bf4f419ded
parent 3372680a90d8599a2193f2d564a7b06c06bf5233
author Darkhorse7stars <wbin90620@gmail.com> 1779620243 -0700
committer Darkhorse7stars <wbin90620@gmail.com> 1779620243 -0700

deps: bump indexer to 0.20260524105612-65220f1104cd

Pulls luxfi/indexer#9 â€” formatAddress + addrCounters now read
both the legacy `transactions_count` / `contract_code` and the
SQLite-backed `tx_count` / `code` column names, and coerce nil
counters to int64(0) so the Blockscout-derived SPA can call
.toLocaleString() without crashing.

Repros against rpc.dev.redacted.com:
  GET /v1/indexer/evm/addresses/0x58a86aafb6cdd0989c799353c891e420fb530e0a
returned transactions_count: null + is_contract: false for a real
deployed AMM pool â†’ now returns the actual tx count + is_contract: true.
-OW–/xö	ýtree b32d9907be943012c2b933d9f5964669730e2a6f
parent 18e50c5b7af2fbea4f3c0579ff6fe96704ff8229
author Darkhorse7stars <92065535+Darkhorse7stars@users.noreply.github.com> 1779617218 -0700
committer GitHub <noreply@github.com> 1779617218 -0700

ci: drop arm64 QEMU build (2h -> ~5 min) (#11)

The previous matrix did linux/amd64,linux/arm64 with arm64 via
docker/setup-qemu-action. QEMU-emulated builds of this image
(Next.js SPA + Go cgo-sqlite) ran for ~2 hours per tag â€” fast
enough to bottleneck every hotfix tag.

Drop arm64. Cluster runners are amd64; Mac devs already run native
arm64 under OrbStack with its own arm64 base story. If we ever
need an arm64 multi-arch index, the right path is a second matrix
entry on a native arm64 runner â€” not QEMU.¦8û–]xÖ)útree 2d465242887e048e5300993fd4cb9bdebd7d736f
parent b851be4241a812d3b94ad7bd53cdb1f5bf36e762
author Darkhorse7stars <92065535+Darkhorse7stars@users.noreply.github.com> 1779608652 -0700
committer GitHub <noreply@github.com> 1779608652 -0700

indexer: wire chains[].indexer.start_block + default devnet chainId (#10)

Two related fixes for running the explorer against a mainnet-fork
devnet:

1. Honor the per-chain `indexer.start_block` field from chains.yaml
   when the indexer DB is fresh. Without this the indexer always
   backfills from block 0, which on a mainnet fork is 25M+ blocks of
   irrelevant pre-fork history. Now an operator can set:

       chains:
         - slug: evm
           rpc: http://anvil:8545
           indexer:
             start_block: 25162000

   and the indexer skips to that block on first run. Once any rows
   exist in evm_blocks, start_block is ignored and indexing resumes
   from MAX(number)+1 as before â€” backward-compatible for
   fresh-genesis chains.

2. Default the bundled SPA's NETWORK_ID to 8675312 (the Liquid
   devnet chain ID). The Dockerfile previously inherited
   luxfi/explore's default of 8675311 â€” a stale leftover from before
   the devnet chainId migration. Adds NETWORK_ID / NETWORK_NAME /
   CURRENCY_SYMBOL as build-args so the same Dockerfile produces
   correct images for devnet/testnet/mainnet.

Requires luxfi/indexer #8 (StartBlock field on evm.Config) â€” bumped
via `go mod edit` to the post-merge commit d5e65d4b50c1.¤¨ëjœGx|ƒûtree abfe5dc2b9cd0a7a48fa5dd67a21a5394276ab87
parent a1fcc8fd445de5a2cf5e5aa90fb91db0c47ee21e
author Darkhorse7stars <92065535+Darkhorse7stars@users.noreply.github.com> 1779420692 -0700
committer GitHub <noreply@github.com> 1779420692 -0700

fix(sse): minimal HEAD response to avoid GCP/Traefik 502 on streaming content-type (#9)

GCP HTTP/2 load balancers (and Traefik in some configs) return 502 to
clients when a HEAD response carries `Connection: keep-alive` plus a
streaming content-type â€” because they expect HEAD to be a closed-body
response, not an opened SSE stream.

Symptom: external `curl -I https://.../v1/base/realtime` â†’ 502 even
though in-cluster `curl -I http://localhost:8090/v1/base/realtime` â†’
200 OK with full SSE headers. SPA's `fetch(..., {method:'HEAD'}).then(r => r.ok)`
gets false â†’ live channel `disabled=true` â†’ no live updates ever fire.

Fix: HEAD pre-flight returns ONLY `Content-Type: text/event-stream` +
200, without the streaming-only headers. The full SSE header set still
ships on the actual GET stream.

Applies to both the per-channel HandleSSE() and the multiplexed
HandleMultiplexedSSE() handlers. `…ë‘fxažùtree de3bf981ef4194c6bd1abf733996872270b27eef
parent 5e985266ac87102df8f9be4181088dddcdeacfda
author Darkhorse7stars <92065535+Darkhorse7stars@users.noreply.github.com> 1779420479 -0700
committer GitHub <noreply@github.com> 1779420479 -0700

feat(realtime): add /v1/base/realtime multiplexed SSE channel (#8)

The bundled SPA opens a single EventSource at /v1/base/realtime and
routes incoming frames via msg.event off a handler map â€” see
realtime_sse.go for the wire shape. Per-channel /blocks /transactions
etc. stay for external consumers; the SPA only uses this one path.

  client.es.onmessage = o => {
    const c = JSON.parse(o.data);
    const d = this.handlers.get(c.event);
    if (d) for (const m of d) m(c.data);
  };

Wire:
  - HandleMultiplexedSSE() attaches a wildcard sseClient (channel=`*`)
  - Broadcast() now builds two SSE payloads per event:
      perChannel: existing `event:<type>\ndata:<msg>\n\n` framing for
                  /blocks /transactions /etc.
      wildcard:   `data:{"event":<type>,"chain":<slug>,"data":<...>}\n\n`
                  for /v1/base/realtime â€” no `event:` line so SPA's
                  onmessage gets the JSON envelope and dispatches.
  - sseRegistry.fanout dispatches both payloads to matching clients
    based on channel ("*" â†’ wildcard, "blocks" â†’ perChannel)
  - main.go mounts GET + HEAD /v1/base/realtime (HEAD is the SPA's
    pre-flight to decide whether to disable the live channel).

Without this path the SPA's HEAD pre-flight got a 404 (the explorer
SSE-HEAD-silencer from #4) and the client set `disabled=true`, so
no live updates ever reached the home page.xÕ#ŸGx€ûtree fdc48f3ac2b455d3f4ae846fa534011dbaa212b7
parent b8e7e84bec3e5e7274b75464b59d2c46bcc9ce9e
author Darkhorse7stars <92065535+Darkhorse7stars@users.noreply.github.com> 1779419718 -0700
committer GitHub <noreply@github.com> 1779419718 -0700

fix(supervisor): preserve unrecognized first-segment when default-routing /v1/indexer (#7)

dispatchIndexer's splitSlug peels off the first segment as a "slug" if
it matches the slug regex ([a-z0-9][a-z0-9-]{0,63}). When that segment
turns out NOT to be a registered chain, the previous code forwarded only
the REMAINDER to the default chain â€” so `/v1/indexer/main-page/blocks`
silently became `/v1/indexer/evm/blocks` server-side, a completely
different endpoint that returns a paginated envelope instead of the raw
array the SPA expects.

The SPA's home page widget does `data.slice(0, 8).map(...)` on the
result and threw "o.slice is not a function" against the envelope shape.

Fix: when the parsed slug isn't a registered chain, re-join it with
the rest before delegating so the original path is preserved under
the default chain (`/v1/indexer/main-page/blocks` â†’ `/v1/indexer/evm/main-page/blocks`).yÄ�B‘>xáütree da4ba9c87520199170911fa37074680541f61043
parent 3c14ffa4375073f712712ed026925b3c31dbf4b5
author Darkhorse7stars <92065535+Darkhorse7stars@users.noreply.github.com> 1779415778 -0700
committer GitHub <noreply@github.com> 1779415778 -0700

feat(realtime): bridge per-chain block events into the realtime hub (#6)

When ChainSupervisor.AttachRealtime is called (from main), each chain's
indexer.Subscriber.OnBroadcast is wired to push block events into the
RealtimeHub on the chain's slug. The hub then fans out to WebSocket
clients (existing path) and SSE subscribers (new in #5).

Requires luxfi/indexer v1.4.4 â€” pulls in the exported Subscriber()
accessor.

End-to-end chain:
  evm.Indexer.indexBlock â†’ Subscriber.BroadcastBlock â†’ OnBroadcast â†’
  RealtimeHub.Broadcast â†’ ws.send + SSE fanout â†’ SPA EventSource

Now /blocks, /transactions, /token-transfers, etc. on the explorer
host actually stream live events as blocks land instead of just
returning HEAD 200 with no payload.î`Tâ˜ïxœ¥WÝrÛÆ¾ÇSœQ.HÆ$DýPe×�R«qfâÄ#©I/2S.€±!°@w¦Ø$3¹Êdò}0?I¿³’�ìv2SßÈvÏÏw¾ó�g¤¤ÓÓ$Kç2™.NÏfI’/’Ë|¶¸Ìf§é"»ÈNò¹ÈÓÙü,j„‘ÚÑå|.æ³ËÅB\¤'—‹“¹Èfód&³‹“éåt&òÓéâ,‰DëŠÚÐ+aÖøkåÜ:a,½XœN/f³³Ù³'o>k­46Öµ‘M¹�WÊm§uõ’NæóÅùÉlzzN“é|:�ð´RÎIC_(÷ºMèEwí³ÿy-Ê¥pC#EéT%GW$²Œi&i!´–%ÝÝÝ�ÔYS+í,åß’’Vg¥Ìèîí5?™�¢èSÊÕÃ07µv8CçÓsª5½¾¹~å¯áè$e™ˆtM�p…�¢û'¦êFjK7ï€ê]ÝšTÒþGÔü—ên•5Ì“{ ãÈÕk©É:¤QY9ÉKµ*³iA€àc¹Š"¢\º´¶¦Ó�•DQ²+ðÛÁÏ£ùé¡¡?½$×kú³�ªÓpDW”)+’ÿ=�¢ïpÃÃÂiQVK«Ž*'$ˆÁ%a­t>4ÆÀâ´©ÛUA®&¥3ù®*ÇñˆéZ,N§SzÿËïdkï€AjL�J™Y¾Ë¡ù½øbº/„‹TUÉL	'Ë-‰¤6@"‘© ¥BëlKÊÒÒÉwÌÞ—cÒµ‹ÂÉö&R¼àÀý-SoÀI*ë•õHöü,¢¶M­á¡¨½ùòÍ¹mƒ2ön#XŽ}³;¢Á1]sÄJ¯¼×´FõS§jGÑû_þÍ,Ày³Ýƒ†žv0ÑZ”÷€_ž»¿g„¶9:j­„åŸéZš1ˆ#½¥Ê„Cû�búºVvû|o¿+w ¶@s¸ñÕòÐ"¬[_0ÏzÛÈTå*E¡·ž±]EÿÙJ‹*ÔzWÊÇýÓ-˜É*‘YÆ®˜2Ñ©²$€ÖçJË]S=Ç`ŸMD;‹ÑŠïòKD4¦M¡ÀÅr÷¾Q-Ö¡S(-¥`“¹På®âÑH9+Ëœ¬*Q/ˆ}qsüö›»ûcôT¼KÎŠ*tCâï$N•/¼Ê$¡%|Í™ðº¦wÊ*vù·¿û:¬dìeæÿ¬Ç²ó­rò¿jm&w,òþ…‘X	¥­‹Œ,…c%
YúfçØ�Ïž5¥²Î'³<ü\Ž£å±§¡ðtÆ¼{ÂNÿ¬ÏãÃ¡` G^ÿê@\ÿÓÓyÉf²äœ>:R> ä[kO[æP³°<n¥!†KO‘œâËåÐ£’=å:P‰}ªtÏ¾ñ^¬XL=´2Ð©‡ïGUe%3¯+\YŽöÊrôTYˆŽ>Ð–£?¢-<1vx€Ñ F¬$BYLooé“óÐ©O.“-·Úv*ÞÃòã#�µwao…©Aykp×\E•X™>0ùöæú«þ ä[»ß+S”R	NÚˆŠ,,7ÉU0Åà´o»æÁ–€œ¯³Rþ)-w=õèN¼ª—�¹øq+W¸i¶KZ‰�3å`õ;™Ü�Ú’ñ½Œ^hF âkÁ†vvPŽ– H
žn»ñÆÁß¼Î5q¸gþÚjèÉ�/E°ë%MÐÅù$“²ÁC#ñÎúsÜ·e½aHl[¡‘(3uC•´•…cËÑžþTÇÚÖ yEbúà|¾CqÙIÒ	@$ SLÿ¤Æ´ßçßEiÆp5ç5&dâõ+#[ˆFŽèúëW^§º+4ÔrãÏŽb\"Æƒ-Ó�ùžÕ™ï^î›�Mƒ_J{+¹;ãy€ën9âxŠw„Á1Ó•Ñ§YAÅöE¾‹Ëû_Û³5lpc1\“Æ{÷ÆªöÁïSð%šR¥Êº?ãaÐãèGfÌ³~_Ã€_`<�¬ìGG8 zÞñ0ÅåŸ "¦êãy¸ä7¥gûdÿâ÷P7¹‡Fð^ã÷î6vB[~ldÓì€ nZHß³h®07[T„áÑPp'Ãd·šXýKòfJ^Š:A™õšôØžq7�ßa|`gËÛÒ/¾Ü€ð¬0¼-ìtP/¡�ªÌ{|–0×¬m8œo<õ÷k4kG€ÞïÅgSKk´ÔDøÝš?%8JÀí“.¯ˆÉð½þ^c
pî¡‚¤ý6Áf¾åðë‘Þ’Ä'	è:äÍ+kÏC<À›µ>ÕG±C8’Ö÷”µAYœã6E€ñM) 8ÃÂÍpØ�ì’†'hZ»Tˆt¾Â àî8ú¡Å„ÞÔfm�Àµo¥ÁÞ†ûÖ¼¼yY\�;­*3Š—¡C¸È4<;ó9%J³uÇèHñqÇ41­¦ÁNK~‚˜üV‚…z�÷}E>ˆˆm›•H|Ûìq¡œuÙí…„«Â¤F"k6ûO‚ž€�QM› Aéúí—„j1:\6l+Pšh²ûýÅü$d�Sxœ�TÛŽÛ6}çWÐÛ€-Kkymí¶i¨ÑA.ˆ·hÑ§PäÐ"L‘.IïZ	ä#òý°|I‡Üu²m_ú$‰äÌ9<çŒ¢G„/y©*¾\­±–­Xªö²^_¢Xµ�ª[YU¼iÖªaîÑF¨äzqQ_”rÝ®«ËEÝ,%6!W‹FTj¹jÔ¢©ê†ñcìœ‡?¸èà9£í¾}÷Ã;Ä=
ížAµZ5uµXV5ÌÊeY2áú^Çˆÿ¿jEULéÓXyg#Z9¹‚º¬ÁYøysó#(b°}}3SÜ˜–‹=xìŒ¿©'ŒÝvíÑJƒ2w@`sG×Üº£ãív3ÑqkÑ„ÜÍè;ª2ŽzÍ!ž`Î¢Û£…=ò> ·gÊè]`ºÉ½Ž]&tÅ€Â(ºñÑ›)¼ï‘T’W0J»£“"vhÇ¾{¾p{ø>³zÂi<�+�:ðÖÐûäš±ß¨¨,ß¤Ã`GzN À�Xà!`ÌÔ’�N{wÜuh+ñTt±7S–šdÙ<Æ£'-.Ê>üÁe€$ÒÁ;�(CªMÔòÆ~Üv<2Ý÷(5�hà­ó¤D‹‚æ‚ÖÉt€·OqžÐßNÁºÈ0õ›=HJ‰x®òî>P:ŒÛ…¬äÜQ Öáà,!tœ|€¿¼Ø@dãèÌhB�xLØ	`ôÀQ7‰qŠ^BŽÜQ;[0öùã_)tÞç`À8ç!LIL
¯åfOô•ƒq~Î¢ç6(ôaÊv<¤O±G?¥àðH‡î¸Ñ’GçÃ¤€—N‡áúKÿG»%	1ÜsTLv+KK´ÞdÃrôÃ…VZ�ÑCNì££1�Îž­üçPð†’Â°oQÊ•"ˆ�6HtJ½ÒÏ“uM/Ô?µúš 8wd»T›6‰Ñî;MY4ç5Â&·2­¯“Â O-×æì8û— c@£ hC~™¡€Ÿ6·ó×¯¶·sš©â|¹À{|œôÌŸ=ðJôá¿@ :¯%�Dö<Þ:¸ÓA'È_Ï>ì°øJÀ@�xÐ/þtree 4a3520bf2f2184bb8c1c19162cfdba7471d8af38
parent 9f018edb60cfc9aae09ffaf5ef87483d7b2837fa
author Zach Kelling <z@zeekay.io> 1779222063 -0700
committer Zach Kelling <z@zeekay.io> 1779222063 -0700

ci: switch workflows to lux-build (luxfi-scoped ARC pool)

Cross-org runs-on dispatch failure: hanzo-build-linux-amd64 scale set is
registered to github.com/hanzoai and silently rejects luxfi/* jobs.
Switch to lux-build (githubConfigUrl=github.com/luxfi, max 20).
i•™ë™x	öþtree 2f2a5c66dc9a24f0e4d8b934ddcf08160dfbdf2f
parent c5227405f31443affb57c898a31069635e9a659c
author Zach Kelling <z@zeekay.io> 1779205220 -0700
committer Zach Kelling <z@zeekay.io> 1779205220 -0700

ci: rip orphan replace block (graph + indexer sibling-dir shims)
;©Q`›xäþtree fe946c3655d13d622306adf8142fa975c2226355
parent 1d853ed3289a0d44dc82c28eb1a9710af612e18b
author Zach Kelling <z@zeekay.io> 1779204815 -0700
committer Zach Kelling <z@zeekay.io> 1779204815 -0700

ci: rip ../graph + ../indexer replace shims; bump to graph v1.2.0 + indexer v1.4.1
¶ S˜œx|ƒþtree bb7107d16c636d6f9d677cd8ef75176724440b5a
parent 34b016037668f9eaff4cc89ea5be3886c6c3d868
author Zach Kelling <z@zeekay.io> 1779202927 -0700
committer Zach Kelling <z@zeekay.io> 1779203016 -0700

ci: fix runner label (hanzo-build pool)

Imaginary runner label `lux-build-linux-amd64` does not exist. The live
amd64 pool is `hanzo-build-linux-amd64`.

- docker.yml: docker job
KUx›–xv‰þtree 1b601c107e5b2b5127157e64b2b0a7da33b69766
parent b043993d03df852a3fcaab07308bcdd43877b6b1
author Zach Kelling <z@zeekay.io> 1778953747 -0700
committer GitHub <noreply@github.com> 1778953747 -0700

chore: add LICENSE (BSD 3-Clause License) + LICENSING.md pointer (#3)

* chore: add BSD 3-Clause License

* docs: add LICENSING.md pointer to canonical IP/licensing strategy„(qz“xã ÿtree 840c342b50cfd524a73f5fcd9413e28594e3e367
parent a858082e3bfaf4e0ddb3bc690719f420130614fe
author Zach Kelling <z@zeekay.io> 1778698780 -0700
committer Zach Kelling <z@zeekay.io> 1778698780 -0700

deps: luxfi/crypto v1.19.0
›HBÚ�x ÿþtree 0397891d3eeabb15b99bfad03a75e845514d49b6
parent f5cfd03bd4478d758cc45733b691d77016f46643
author Zach Kelling <z@zeekay.io> 1778646558 -0700
committer Zach Kelling <z@zeekay.io> 1778646558 -0700

go.mod: bump go directive to 1.26.3 (security advisory)
Ð>Lñ‘xáþtree a84952a30b356e5c7a51e88fcc1b6daf11828d32
parent 4acc65c6d4f84d0c5c2faad94fdd7deaec5ac542
author Zach Kelling <z@zeekay.io> 1778089515 -0700
committer Zach Kelling <z@zeekay.io> 1778089515 -0700

ci: build multi-arch (amd64+arm64) â€” Mac devs run native arm64, no Rosetta

Matches lux/graph + lux/indexer docker.yml. The unified explorer binary
bundles SPA + indexer + graph in one image, so this is the canonical
multi-arch artifact for the Liquidity local stack and devnet.
Šêž‰œxóþtree b1c7ed692ab2271e3f30a430260c0dacb79a9888
parent 3c6e210360ac381d850852d8e658d4e027363878
author Zach Kelling <z@zeekay.io> 1778045579 -0700
committer Zach Kelling <z@zeekay.io> 1778045579 -0700

ci(docker): amd64-only â€” multi-arch QEMU stalls on heavy SPA + Go
ÜP^’xýþtree 5fb3ec9b9f4ec2dcf69999cd72a5a5b490e7e538
parent 494e3fe64555b1487ba2c8547140abc3d94e6954
author Zach Kelling <z@zeekay.io> 1778043245 -0700
committer Zach Kelling <z@zeekay.io> 1778043245 -0700

ci(docker): go build -mod=mod populates go.sum on the fly
 4NÆ™x	öþtree 7b59cbbaa57ea015fe68d85dac4d25345445750b
parent d41699bb3361f78f0a9ee3ade771360809d5ce9e
author Zach Kelling <z@zeekay.io> 1778043048 -0700
committer Zach Kelling <z@zeekay.io> 1778043048 -0700

ci: drop ci.yml â€” Docker workflow handles cgo build via alpine
YR�“xüþtree a2df3925c59af70f8680d33b1bc8888e18b55537
parent d17d22721eeed9cceaa3265313eb8b54dbbdd046
author Zach Kelling <z@zeekay.io> 1778042662 -0700
committer Zach Kelling <z@zeekay.io> 1778042662 -0700

ci: ARC has no_new_privs (no sudo); GOSUMDB=off everywhere
*œNÆ�xð ÿtree bad4bf082412b6cb2f5223dd0be204330b9850ee
parent 7f0765d1127a67fb76e6922a8c672fcf75cb4087
author Zach Kelling <z@zeekay.io> 1778042082 -0700
committer Zach Kelling <z@zeekay.io> 1778042082 -0700

chore: gitignore .claude/ runtime state
OHž˜x÷þtree 860487c5a72962cf6ab735df02dc6c8e3e119326
parent c979f90b2a92166c1875cf80308c9ea7b3c1986c
author Zach Kelling <z@zeekay.io> 1778042065 -0700
committer Zach Kelling <z@zeekay.io> 1778042065 -0700

ci: regenerate go.sum at build time (proxy cache inconsistency)
PÝ—xøþtree abf969e54029a2f0e07558ff0fe2fe26203f078e
parent 33658ed9cfea935b4f1b0921d86f6556ae030f91
author Zach Kelling <z@zeekay.io> 1778041088 -0700
committer Zach Kelling <z@zeekay.io> 1778041088 -0700

config: full chain list â€” all Lux native + subnet + external
`ÜQ ™x	öþtree ab972f511d87cebfd24c8fec81cf93ffc1e27d23
parent 89c9cd2762bb86f349e695ff8291b924aa83a45b
author Zach Kelling <z@zeekay.io> 1778040880 -0700
committer Zach Kelling <z@zeekay.io> 1778040880 -0700

ci: refresh go.sum (proxy hash drift), install gcc on ARC runner
Ê`QÂ“#x3Ìýtree 0fc515d6feb88821662d1b110955301bde2fcc95
parent 9b8044b665e0e12d0ce4808d7b5a3194b62a0939
author Zach Kelling <z@zeekay.io> 1778040555 -0700
committer Zach Kelling <z@zeekay.io> 1778040555 -0700

fix(go.sum): align hanzoai/replicate hash with proxy.golang.org

Local go env had GONOSUMDB=...hanzoai/* which made `go mod tidy` use the
GitHub-direct hash (YxjIynlâ€¦). CI fetches from proxy.golang.org which
returns a different hash (5Rl3â€¦) because the proxy cached a slightly
different zip layout. Replace ours with the same value indexer/graph
already use.
úN»¥”!xëýtree aa632e6aa6b40cbaced617b1d2f1945e97b3087a
parent 9c1c9c7683028ed3cc2f0d7f364be29f98adc080
author Zach Kelling <z@zeekay.io> 1778040340 -0700
committer Zach Kelling <z@zeekay.io> 1778040340 -0700

ci: align with luxfi org's actual ARC pool (lux-build-linux-amd64)

Mirrors the luxfi/indexer change: switch all workflows from the
nonexistent hanzo-* labels to lux-build-linux-amd64, inline the multi-
arch Docker build (arm64 via QEMU), drop the cross-org reusable, drop
the workflow-sanity job that referenced cross-org actions.
±µ±Ÿ\xÏ0útree db2b2f2448cf0d23ad32c220273256d39a0a16d4
parent 77c543529838e6db25f92f47f88291585cb379b8
author Zach Kelling <z@zeekay.io> 1778040232 -0700
committer Zach Kelling <z@zeekay.io> 1778040232 -0700

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.
#¥Ö›xôþtree e9ef4f120b79eaea5dcdbc67fc3b7a64d84a2f64
parent 0c29cc0a65548b9d3700f3d43928b4cff4c6c153
author Zach Kelling <z@zeekay.io> 1778036879 -0700
committer Zach Kelling <z@zeekay.io> 1778036879 -0700

chore(.gitignore): proper Go ignores, drop rebuild trigger comment
¨íSÒ–xf™þtree a1f2c5fd9e7fc38a9e762f7514b9a9a8e6b4a78d
parent 630ffa251a6398d79e96d2b39ec5855cbd3df0d6
parent 755b76ac22240f7aa06d5d0b0b940f3eb1cade0c
author Zach Kelling <z@zeekay.io> 1778036368 -0700
committer GitHub <noreply@github.com> 1778036368 -0700

Merge pull request #2 from luxfi/ci/multi-arch-native

ci: build multi-arch (amd64 + arm64) on native runners4_oØ˜xø ÿtree a1f2c5fd9e7fc38a9e762f7514b9a9a8e6b4a78d
parent dd387f3433d8725948027b5520c0d610b339a48e
author Zach Kelling <z@zeekay.io> 1778036307 -0700
committer Zach Kelling <z@zeekay.io> 1778036307 -0700

ci: register hanzo runner labels for actionlint
Á‰KíŸxï ÿtree 02aed7906e41349de231ca755ff5d96fc6f8daf9
parent 8e4e88c1d09a11263d6b873ae8274b2598cc07b5
author Zach Kelling <z@zeekay.io> 1778035887 -0700
committer Zach Kelling <z@zeekay.io> 1778035887 -0700

ci: use hanzo-* runner labels (sanity)
>jGñœx,Óþtree d5a98873ed8293956340c809fffad804ae7a3d36
parent 0531a94d58c5b3881b12a8ef1bcb9a86a00ff550
parent 630ffa251a6398d79e96d2b39ec5855cbd3df0d6
author Zach Kelling <z@zeekay.io> 1778034163 -0700
committer Zach Kelling <z@zeekay.io> 1778034163 -0700

merge(main): keep multi-arch + semver-only triggers
¨|Z¬�xÀ?þtree d5a98873ed8293956340c809fffad804ae7a3d36
parent b35ef6b747fe94d1dd2b2d9c5ac4bef9f7ec1e92
author Zach Kelling <z@zeekay.io> 1778034119 -0700
committer Zach Kelling <z@zeekay.io> 1778034119 -0700

explorer: remove Liquidity branding cross-contamination

Drop the _lqd._tcp / lqd / liquid daemon entry from the mDNS service
discovery list. The lqd daemon is the Liquidity-side white-label node
binary; the OSS Lux explorer must not advertise it.
"q”Ø–x¦Yþtree 0b695143561ce964ded453a18b176ef8db3bddc2
parent 579d74c09a8664b3e10c1ade5217a0697471e10a
author Zach Kelling <z@zeekay.io> 1778033309 -0700
committer Zach Kelling <z@zeekay.io> 1778033309 -0700

ci: build multi-arch (amd64 + arm64) on native runners

Drop branch triggers, semver-only tags. Pin lux-build-linux-{amd64,arm64}
ARC runners. Eliminates the amd64-only manifest that crashed Apple Silicon
local k3d pulls.
‡™‡˜xøþtree 7fd06b2687f41531672d96953f410837b9ed557f
parent 5e1d5c3f0337d04b4711bc2e5ca5af160f549757
author Zach Kelling <z@zeekay.io> 1776997394 -0700
committer GitHub <noreply@github.com> 1776997394 -0700

ci: migrate to canonical docker-build reusable (#1)

- Add docker.yml using hanzoai/.github/.github/workflows/docker-build.yml@main
- Add workflow-sanity.yml to enforce canonical CI contract
- Remove bespoke docker build steps from existing workflows

Refs: hanzoai/.github canonical Docker CI contract.ÑP¦û“xóþtree 7fd06b2687f41531672d96953f410837b9ed557f
parent 5e1d5c3f0337d04b4711bc2e5ca5af160f549757
author Zach Kelling <z@zeekay.io> 1776997280 -0700
committer Zach Kelling <z@zeekay.io> 1776997280 -0700

ci: migrate to canonical docker-build reusable

- Add docker.yml using hanzoai/.github/.github/workflows/docker-build.yml@main
- Add workflow-sanity.yml to enforce canonical CI contract
- Remove bespoke docker build steps from existing workflows

Refs: hanzoai/.github canonical Docker CI contract.
÷·¥mžxþ ÿtree 05ec30403163c08342bb6aadcf78f72d50d2ce55
parent e8c2d51752df366e487b76a5f33d9b5eb357747c
author Zach Kelling <z@zeekay.io> 1776374133 -0700
committer Zach Kelling <z@zeekay.io> 1776374133 -0700

ci: docker multi-arch build + semver release workflow
\ýM­œxü ÿtree ae8c1cbf01a3c4ef3b64f9442bedf313a5697b83
parent 90248d6c7bb2cae223a57b231d8d9bffb614e78f
author Zach Kelling <z@zeekay.io> 1776077660 -0700
committer Zach Kelling <z@zeekay.io> 1776077660 -0700

fix: use lux-build runners, not generic self-hosted
ÁON<’xíþtree 67410a2f862abb06a0545f03e1f65d5d0e6869e3
parent 63a8610d3ebacf64a2634f38cccdf744d28990a6
author Zach Kelling <z@zeekay.io> 1776073381 -0700
committer Zach Kelling <z@zeekay.io> 1776073381 -0700

feat: use info.getChains for single-call discovery (no per-chain probing)
·›TÂšxåþtree 0ff63ccea78bbb19a3597efae560484f7e17f0da
parent 82926899b7f341b085a4b12cfe0794dfe7a49a6e
author Zach Kelling <z@zeekay.io> 1776071785 -0700
committer Zach Kelling <z@zeekay.io> 1776071785 -0700

fix: probe RPC before registering â€” only index chains this node actually tracks
xƒY¶šx
õþtree f80df6b97507f299c3c70d1c113711d00044b827
parent 672dc81e5757d00125d361f4dd4c58f5dd324fe3
author Zach Kelling <z@zeekay.io> 1776070690 -0700
committer Zach Kelling <z@zeekay.io> 1776070690 -0700

fix: fully dynamic chain discovery â€” zero hardcoded chain lists
£}R~™x	öþtree 5c2106f955decb2bce0e4bed364ab821991d67a8
parent cca50859f0645a03a0885679c96d588eda838b20
author Zach Kelling <z@zeekay.io> 1776069740 -0700
committer Zach Kelling <z@zeekay.io> 1776069740 -0700

fix: per-daemon chain sets â€” liquid has evm/dex/fhe, not C/P/X
½Q*Ÿxÿ  ÿtree 3a06e8dfee3ba02cf9b38c1052b2b0a3bca08186
parent bf584f6b48f9d265640f12d162b47250d18b1699
author Zach Kelling <z@zeekay.io> 1776069577 -0700
committer Zach Kelling <z@zeekay.io> 1776069577 -0700

feat: discover luxd, zood, lqd, hanzod, parsd via mDNS
ÓZMŸ'x€ýtree 453428f8c721029ab777cb81e7d366459207548d
parent ed8fb4b646ba370fab3b53583760b376f53d15b1
author Zach Kelling <z@zeekay.io> 1776069391 -0700
committer Zach Kelling <z@zeekay.io> 1776069391 -0700

feat: runtime chain registry, mDNS discovery, /v1/explorer/realtime

Runtime-mutable chain registry with admin CRUD API, mDNS auto-discovery
of local luxd nodes, and multi-chain WebSocket realtime subscriptions.

Config sources (priority order):
  1. Admin API (POST/PUT/DELETE /v1/explorer/admin/chains)
  2. --config chains.yaml
  3. CHAINS env var
  4. mDNS _luxd._tcp discovery (lowest, never overrides manual)

21 tests, all passing.
2Î˜›xë ÿtree 200ce12a53e92c05fa83f229e3fa078c9b474bce
parent 1a6d559ccfc01cdd2521511839f04334772dc735
author Zach Kelling <z@zeekay.io> 1776010884 -0700
committer Zach Kelling <z@zeekay.io> 1776010884 -0700

ci: trigger combined image rebuild
ÆUFŽ�xòþtree 80e2a7ada6b68d378a94574f2119a05b13b746d3
parent 7ee5150d270ca574c8e8f555a7048da9564df2bf
author Zach Kelling <z@zeekay.io> 1775943083 -0700
committer Zach Kelling <z@zeekay.io> 1775943083 -0700

docs: full README, compose.yml, chains.example.yaml, healthcheck, CI
ÑâQ¬“$xC¼ýtree 7dff837e60515e0be8a2360b832f716818c1a67a
parent cd4378e3f92324cd031ab01fcd584be7242e23d2
author Zach Kelling <z@zeekay.io> 1775940607 -0700
committer Zach Kelling <z@zeekay.io> 1775940607 -0700

feat: include graph engine (subgraph replacement) in combined image

4 repos, 4 images:
  indexer  â†’ chain data (blocks, txs, tokens, contracts)
  graph    â†’ subgraph events (swaps, pools, DeFi, GraphQL)
  explore  â†’ Next.js frontend
  explorer â†’ all three combined (this)

Single image contains 3 binaries: indexer, graph, explorer.
Each can run standalone or together.
†ß¿_˜x÷þtree 30ec1903c50c309e778ff050ac500e7a227274c6
parent a2b779c20cbdce6ac8edd2ebf2a5e89e307f588f
author Zach Kelling <z@zeekay.io> 1775940007 -0700
committer Zach Kelling <z@zeekay.io> 1775940007 -0700

fix: use pre-built indexer image as base, layer frontend on top
øõQÀ”%xT«ýtree 65df319ce49c0c030046fb802b76864890d0b6bb
author Zach Kelling <z@zeekay.io> 1775939328 -0700
committer Zach Kelling <z@zeekay.io> 1775939328 -0700

feat: combined explorer image (indexer backend + explore frontend)

Three repos, three images:
  luxfi/indexer  â†’ ghcr.io/luxfi/indexer  (Go backend, API + indexing)
  luxfi/explore  â†’ ghcr.io/luxfi/explore  (Next.js frontend)
  luxfi/explorer â†’ ghcr.io/luxfi/explorer (this: combined single image)

Dockerfile pulls both repos at build time, embeds the Next.js static
export into the Go binary via go:embed, produces a single container.
uùËVª;xœ31 ½ôÌ’ŒÒ$†ÏÖV?L÷õœiYx[‡kî7“N¶­†f&&`%™éyùE©kæ©¿âì¼~¸á¯¢Ù…CsÏÝfUåìãêâª—›Â ±W>aþ­wÿnjT™=+SÞõ"ùT‘K~rvjQZfN*CýŠƒ¥K6×’�)	ªŠœ}?ÏtT•�§³«_°+Ã?›5ÒV,;|·Šn[šÒtòÇl%ž~î ¥ãã•bh_ÚÀnßûøÿúus¯GÞ44{ÏÇÇ¤"CXðõœ/Á~Ýý¼êÔ›âð—Õ+^BM
rutñ»;¿YÍ:I(»õÅÉ;³›o‡Í.ûoU”œ‘˜™W¬—Z‘˜[�“ªW™˜›Ã z)Z¥(�cÊæ¬e7'U<v�¦<?· ¿¨¨ìÁe¹¯vS4ƒ™Û}(Úyh–ÂsÞ‹peyi™ézéùª¬¾[—Må9xñ½\¹…î$�ÍÚÞC¥åç•¤æ¥€”E2d‹ñ‡ñ&v;}0šûÒb]Cá¡íhÊâKR‹K@j5*üŒj¬]½Ýý@P¬ò»šVÉÇÚPµéùz¹ù)®¦¥é5�|Ü6N³œ®S¸„½uÙ=„ŠâÒ\†šà÷­šN“4XŒ¦kô»ÕtGt§'TE.0@@ÖðÜw_¥}Áùâ¿[„¾ß¾Ó¸îŒ,Ýä¦ ƒ¨$þZG®Qî�ðrCµ2¾;‹"üÄ7@•¥&æ”dæ¦‚”UE.�8ýªÝáúEI,&|é—{î¾FS_\V%§ÕÄòc’ð­mª/YE¦NPk=…®�5ïÐ]¼úÒçúž7î�}O+ÁÕ¦g—U‚”}Ï·:ºkcÔÊ¬kfÝ×vÓd)hôAS7òË“ÈâM;LD1ˆH}Þë,¾‘ÍœÜŠ“‹2JŠî9Y-ùòo—_�m¹J¿mKGœ¶ë&¨Š’Ä’Ìd†K½9_¿pÖ0v'òý¼|óÁ±hXª(.-H-*Ë,Î/Ù5�ÃŸŸ5ÚÎ')=â¡f?g¦dm’ léwWà…x  ßÿºº°}…V,êôÔ‚ã•yíAñùXt¦oo“‘)ú
³¯xO °ÿ100644 actionlint.yaml !U¡išQà›â‡Fµ°˜!>F40000 workflows 4:õÌ9ž?H‹,Qs�x÷áßÆ\yŽé\x æÿOO�;®…–“M9æ¯ßg|çR%¬“æŽâé'x æÿOO�;uÛzµ]8œÇÖ4m“©0WÊ­‚”
ëé'x æÿOO�;©’ó'ôMaÂ:Cu:íëjh|ã
Né'x æÿOO�;ùHzYÔÿÌ¥?•øü„{Um·šoFé'x æÿOO�;ÕY ƒ\`¶0èÕè®†�î›òÍ† šé'x æÿOO�;báUíå_þ`½¾,vÜ¿F€Š¬˜Ñ…é'x æÿOO�;Ô¤�èvÍß«­4k­åo*ï™ÞÜé'x æÿOO�;–KÞ"0Þëpj¦;(dŒÇ
ñ8î
8é'x æÿOO�;—vÔÁh˜³&½äÄ:U–QÁ¨‘�^ºx: Åÿself-hosted-runner:
  labels:
    - lux-build-linux-amd64
D8ô¦x& Ùÿ100644 docker.yml ‘&bceÿ™Âõ€J‰ÙÄàéê£º`x
õùname: Docker
on:
  workflow_dispatch:
  push:
    tags: ['v*']

permissions:
  contents: read
  packages: write
  id-token: write

# amd64-only on luxfi's native ARC pool â€” the SPA build (Next.js + 8GB
# heap) + Go cgo-sqlite Docker build under QEMU arm64 emulation pushes
# build time from ~5 min to ~2 hours. Cluster runners are amd64; Mac
# devs use OrbStack which has its own arm64 base image story. arm64
# rebuilds can come back via a separate matrix entry on a native
# arm64 runner â€” not via QEMU.
jobs:
  docker:
    runs-on: lux-build
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
        with:
          driver: docker-container
          driver-opts: network=host
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/luxfi/explorer
          flavor: latest=auto
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=sha-
      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          labels: ${{ steps.meta.outputs.labels }}
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha,scope=explorer
          cache-to: type=gha,scope=explorer,mode=max
          provenance: false
          push: true
¨Máê¸x8Çþ# Binaries
bin/
explorer
explorer-unified
*.exe
*.test

# Coverage
coverage.out
coverage.html

# Go workspace
go.work
go.work.sum

# Editor
.idea/
.vscode/
*.swp
*.swo

# OS
.DS_Store

# Local
.env
.env.local
*.local

# AI assistant context (not committed)
AGENTS.md
CLAUDE.md
GEMINI.md
QWEN.md

# Runtime
logs/
8Tcì�Ex, Óÿ¸±# Binaries
bin/
/explorer±)	.claude/
=Û·Êx§Xó# explorer

**Org:** luxfi  Â·  **Ecosystem:** lux  Â·  **Path:** `/Users/a/work/lux/luxfi/explorer`
**Origin:** https://github.com/luxfi/explorer.git

## Discovery

This file (`CLAUDE.md`) is the canonical agent-facing readme; `LLM.md` is a symlink to it. Update either name and both stay in sync.

## Where to look first

- `README.md` â€” human-facing overview (if present)
- `package.json` / `Cargo.toml` / `pyproject.toml` / `go.mod` â€” language & deps
- `.github/workflows/` â€” CI surface
- `docs/` â€” extended docs (if present)

## Sibling repos

See the org-level `LLM.md` at `/Users/a/work/lux/luxfi/LLM.md` for the full inventory of sibling repos and inter-repo dependencies.

## Unified explorer bundle (2026-06-05)

luxfi/explorer + luxfi/graph + luxfi/explore are rolled into ONE Deployment
per env. Architecture:

- **luxfi/explorer (this repo)** â€” the Go binary `cmd: explorer`. Imports
  `github.com/luxfi/indexer` and `github.com/luxfi/graph` as libraries.
  Serves on `:8090`:
    /v1/indexer/{slug}/*          â€” Blockscout-compatible JSON API
    /v1/graph/{slug}/{subgraph}/  â€” GraphQL per-chain per-subgraph
    /v1/explorer/admin/*          â€” Runtime chain registry CRUD
    /v1/explorer/realtime         â€” WebSocket + SSE live feed
    /                             â€” Embedded SPA shim (fallback only)
  Image: `ghcr.io/luxfi/explorer:1.2.18`

- **luxfi/explore** â€” the Next.js SSR app (Blockscout fork). Serves the
  rich SPA at `:3000`. Runs as a **sidecar container in the same Pod**;
  shares the network namespace with the explorer Go binary so it can hit
  `http://localhost:8090` for data if needed (currently it uses the
  public `api-explore.<env>.network` host).
  Image: `ghcr.io/luxfi/explore@sha256:ddb15bc28ff38c7619bc08df9337e02b32ef886c33b9993a9b9cb3c475ad75de`

- **luxfi/graph (formerly graphd)** â€” NOT a separate Deployment. Linked
  into the explorer binary as a library. Per-chain GraphQL engines are
  spawned by the supervisor when `chains[].graph.enabled: true` in the
  chains.yaml ConfigMap.

The legacy `lux-indexer` Deployment (same Go `indexerd` binary, only
indexed EVM) has been **deleted**. Its routes are served by the unified
explorer Deployment.

Per env: 1 Pod, 2 containers, 1 Service exposing `:80 / :8090 / :3000`.

Manifests:
  ~/work/lux/universe/k8s/lux-mainnet/explorer.yaml
  ~/work/lux/universe/k8s/lux-mainnet/explore-ingress.yaml
  ~/work/lux/universe/k8s/lux-testnet/explorer.yaml
  ~/work/lux/universe/k8s/lux-testnet/explore-ingress.yaml
  ~/work/lux/universe/k8s/lux-devnet/explorer.yaml
  ~/work/lux/universe/k8s/lux-devnet/explore-ingress.yaml

Public URLs (all 200 OK, verified 2026-06-05):
  https://explore.lux.network              (Next.js SPA)
  https://api-explore.lux.network/v1/indexer/cchain/blocks
  https://api-explore.lux.network/v1/graph/cchain/amm/graphql
  https://explore.lux-test.network         (Next.js SPA)
  https://api-explore.lux-test.network/v1/indexer/cchain/blocks
  https://api-explore.lux-test.network/v1/graph/cchain/amm/graphql
  https://explore.lux-dev.network          (Next.js SPA)
  https://api-explore.lux-dev.network/v1/indexer/cchain/blocks
  https://api-explore.lux-dev.network/v1/graph/cchain/amm/graphql
*ãLr²¦xb�í# 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

# ---- Stage 1: build the SPA ----
#
<<<<<<< Updated upstream
# Frontend chain knobs are build-time, not runtime: the Blockscout-fork
# SPA reads NEXT_PUBLIC_* at `pnpm build` and bakes them into the
# emitted JS. Default to the Liquid devnet shape; downstream rebuilds
# override via --build-arg NETWORK_ID=â€¦ (etc.) for testnet/mainnet.
=======
# luxfi/explore is a Next.js `output: 'standalone'` SSR app, not a static
# export â€” so `pnpm build` does NOT produce /app/out/. The unified
# explorer needs static assets to embed via go:embed; until upstream
# explore ships `output: 'export'`, we ship a substantive runtime SPA
# shell from explorer/static/. The shell wires /envs.js (window.ENV)
# to render the chain list client-side. When upstream explore lands
# static-export support, this stage gains `pnpm build && cp .next/static
# /app/out/_next/static` and the shell can hydrate.
>>>>>>> Stashed changes
FROM node:${NODE_VERSION}-alpine AS frontend
ARG EXPLORE_REPO
ARG EXPLORE_REF
ARG NETWORK_ID=8675312
ARG NETWORK_NAME="regulated EVM L1"
ARG NETWORK_CURRENCY_SYMBOL=LQDTY
RUN apk add --no-cache git
WORKDIR /app
# Best-effort: clone explore and try to produce a static export. If
# anything fails (missing pnpm-lockfile entry, missing env vars, SSR-only
# Next config), don't fail the image â€” explorer/static/ still ships.
RUN git clone --depth=1 --branch=${EXPLORE_REF} ${EXPLORE_REPO} . || true
ENV NEXT_PUBLIC_API_BASE_PATH=/v1/explorer
ENV NEXT_PUBLIC_NETWORK_ID=${NETWORK_ID}
ENV NEXT_PUBLIC_NETWORK_NAME=${NETWORK_NAME}
ENV NEXT_PUBLIC_NETWORK_CURRENCY_NAME=${NETWORK_CURRENCY_SYMBOL}
ENV NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=${NETWORK_CURRENCY_SYMBOL}
ENV NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN if [ -f package.json ]; then \
      corepack enable && pnpm install --frozen-lockfile --ignore-scripts && pnpm build || true; \
    fi
# /app/out is created only if static export succeeded. The builder stage
# below COPYs it over explorer/static/ â€” but only if non-empty. The
# fallback path is the local explorer/static/index.html (substantive
# 372-byte SPA shell with id="root" + /envs.js bootstrap).
RUN mkdir -p /app/out

# ---- 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
ARG VERSION=dev
RUN apk add --no-cache gcc musl-dev sqlite-dev git

WORKDIR /src
RUN git clone --depth=1 --branch=${INDEXER_REF} ${INDEXER_REPO} indexer && \
    git clone --depth=1 --branch=${GRAPH_REF}   ${GRAPH_REPO}   graph

WORKDIR /src/explorer
COPY . .
# explorer/static/ already contains the substantive SPA shell. Only
# overlay frontend /app/out/ if it has the static-exported assets (an
# index.html that's NOT the 60-byte stub). When upstream explore ships
# static-export, the cp succeeds and overlays the real assets.
RUN --mount=type=bind,from=frontend,source=/app/out,target=/tmp/frontend-out \
    sh -eu -c '\
      if [ -s /tmp/frontend-out/index.html ] && [ "$(wc -c </tmp/frontend-out/index.html)" -gt 100 ]; then \
        echo "[frontend] overlaying static export onto explorer/static/"; \
        cp -R /tmp/frontend-out/. /src/explorer/static/; \
      else \
        echo "[frontend] static export not produced, keeping committed explorer/static/ SPA shell"; \
      fi'

# proxy.golang.org caches inconsistently for hanzoai/replicate@v0.6.0
# (different POPs serve different zip hashes). -mod=mod populates go.sum
# from whatever the proxy serves at build time and GOSUMDB=off skips
# sum.golang.org cross-checks.
RUN rm -f go.sum && CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOSUMDB=off \
    go build -trimpath -mod=mod \
      -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 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
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
  CMD wget -qO- http://localhost:8090/health || exit 1
ENTRYPOINT ["explorer"]
|Tsï£pxðþâ$Ÿ$°³> h#
# Build-arg defaults shape the optional Next.js static export attempt
# (only used if explore.git land“  upstream). Real
# branding comes from the runtime chains.yaml + brand assets mount on
# the explorer Deployment â€” no rebuild needed to swap.³uíÎ`º_xúúBSD 3-Clause License

Copyright (c) 2024-2026 Lux Industries Inc.
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
   list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
   contributors may be used to endorse or promote products derived from
   this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.“ÏÞ¿%x_ ý# Licensing

This repository is licensed under the **BSD 3-Clause License** (see [LICENSE](LICENSE)). It belongs to the **public** tier of the Lux three-tier IP strategy: anyone may use, fork, or redistribute it, including for commercial purposes, subject to the BSD 3-Clause License terms.

For the canonical Lux IP and licensing strategy, see:
<https://github.com/luxfi/.github/blob/main/profile/README.md>

For commercial inquiries that go beyond the public tier (e.g. private moat acceleration kernels, Eco-tier patent-protected primitives outside Authorized Networks), contact `licensing@lux.network`.
À…Õ�9xœsöquqÕËM ¹®µÖxešê# Explorer

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/explorer
  -> indexes one or more EVM chains  (luxfi/indexer)
  -> exposes per-chain GraphQL       (luxfi/graph)
  -> serves embedded SPA + REST API  (luxfi/explore)
```

## Routes

| Path                                              | Purpose                          |
|---------------------------------------------------|----------------------------------|
| `/`                                               | SPA (embedded; SPA-routing fallback) |
| `/envs.js`                                        | Runtime config `window.ENV = {...}` |
| `/icon.svg`, `/logo.svg`                          | Per-host brand assets (disk override) |
| `/health`                                         | Service health                   |
| `/v1/indexer/*`                                   | Default chain explorer API       |
| `/v1/indexer/{slug}/*`                            | Per-chain explorer API           |
| `/v1/explorer/{slug}/*`                           | Legacy alias for `/v1/indexer/{slug}` |
| `/v1/graph/{slug}/{subgraph}/graphql`             | Per-chain, per-subgraph GraphQL  |
| `/v1/explorer/admin/chains[/{slug}]`              | Runtime registry CRUD            |
| `/v1/explorer/realtime`                           | WebSocket realtime hub           |
| `/v1/explorer/realtime/stats`                     | Realtime stats                   |

## Quick Start

```bash
docker run -p 8090:8090 \
  -v $(pwd)/chains.example.yaml:/etc/explorer/chains.yaml:ro \
  -v explorer-data:/data \
  ghcr.io/luxfi/explorer:latest \
  --config /etc/explorer/chains.yaml
```

Or with compose:

```bash
docker compose up
```

## Configuration

`chains.yaml` is the single source of truth. Every aspect of the deploy --
chains, branding, GraphQL subgraphs, network switcher links -- is per-chain
customizable. See [`chains.example.yaml`](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
# Add
curl -X POST http://localhost:8090/v1/explorer/admin/chains \
  -H 'Content-Type: application/json' \
  -d '{"slug":"new","name":"New","chain_id":123,"rpc":"http://node:8545","type":"evm"}'

# List
curl http://localhost:8090/v1/explorer/admin/chains

# Remove
curl -X DELETE http://localhost:8090/v1/explorer/admin/chains/new
```

Adding a chain spawns its indexer + (optional) graph workers; removing
cancels them and frees the routes.

## mDNS Discovery

Set `EXPLORER_MDNS=true` to auto-register chains advertised by local Lux
nodes (`_luxd._tcp`, `_zood._tcp`, `_hanzod._tcp`, `_parsd._tcp`). The
explorer queries `info.getChains` on the discovered node and registers each
tracked chain. mDNS-sourced chains never override config-sourced entries.

## Storage Layout

```
/data/
  cchain/
    query/indexer.db         (SQLite WAL, indexer)
    {zapdb}/                 (KV store)
    graph/
      amm/graph.db           (SQLite WAL, per-subgraph)
  zoo/
    ...
```

Each chain (and each subgraph) owns an isolated SQLite + KV store. WAL is
optionally streamed to S3 with PQ encryption when `REPLICATE_S3_ENDPOINT`
is set -- see `luxfi/indexer/daemon`.

## Local Build

```bash
# Sibling layout: ~/work/lux/{indexer,graph,explorer}
cd ~/work/lux/explorer
go build -o explorer .
```

`go.mod` uses local `replace` directives pointing at `../indexer` and
`../graph`. Production CI clones all three repos into the same layout
inside the Docker build.

## Endpoints (curl examples)

```bash
curl http://localhost:8090/health
curl http://localhost:8090/v1/indexer/cchain/blocks/latest
curl -X POST http://localhost:8090/v1/graph/cchain/amm/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ swaps(first:5) { id amount0 amount1 } }"}'
```

## Related

- [`luxfi/indexer`](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
ÎÀö»ûx»Dè# chains.example.yaml â€” full per-network customization for the unified Lux
# explorer. Mount as /etc/explorer/chains.yaml or pass --config=chains.yaml.
#
# Every chain gets its own SQLite file under {data_dir}/{slug}/ and its own
# /v1/indexer/{slug}/* + /v1/graph/{slug}/{subgraph}/* routes. Branding is
# per-chain; the SPA reads it at runtime via /envs.js so a deploy can
# rebrand by remounting this file + assets â€” no image rebuild.

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
  - label: Testnet
    domain: testnet.explorer.lux.network
    chain_id: 96368
  - label: Devnet
    domain: devnet.explorer.lux.network
    chain_id: 96370

chains:
  # â”€â”€ Lux native chains â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€

  - slug: cchain
    name: Lux C-Chain
    chain_id: 96369
    type: evm
    rpc: ${LUX_CCHAIN_RPC:-https://api.lux.network/ext/bc/C/rpc}
    coin: LUX
    enabled: true
    default: true
    indexer: { poll_interval: 30s }
    graph:
      enabled: true
      subgraphs:
        - { name: amm,        schema: amm,        enabled: true }
        - { name: securities, schema: securities, enabled: false }
    brand:
      name: Lux C-Chain
      coin: LUX
      accent_color: "#5cf"

  - slug: pchain
    name: P-Chain
    type: linear
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/P
    coin: LUX
    enabled: true

  - slug: xchain
    name: X-Chain
    type: dag
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/X
    coin: LUX
    enabled: true

  - slug: achain
    name: A-Chain
    type: dag
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/A
    coin: LUX
    enabled: true

  - slug: bchain
    name: B-Chain
    type: dag
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/B
    coin: LUX
    enabled: true

  - slug: qchain
    name: Q-Chain
    type: dag
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/Q
    coin: LUX
    enabled: true

  - slug: tchain
    name: T-Chain
    type: dag
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/T
    coin: LUX
    enabled: true

  - slug: zchain
    name: Z-Chain
    type: dag
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/Z
    coin: LUX
    enabled: true

  - slug: kchain
    name: K-Chain
    type: dag
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/K
    coin: LUX
    enabled: true

  # â”€â”€ Lux subnet chains (EVM) â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€

  - slug: zoo
    name: Zoo
    chain_id: 200200
    type: evm
    rpc: ${ZOO_RPC:-https://api.lux.network/ext/bc/Zoo/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/ext/bc/Hanzo/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/ext/bc/SPC/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/ext/bc/Pars/rpc}
    coin: PARS
    enabled: true
    indexer: { poll_interval: 30s }

  - slug: dex
    name: Lux DEX
    type: evm
    rpc: ${LUX_RPC:-https://api.lux.network}/ext/bc/D/rpc
    coin: LUX
    enabled: true
    indexer: { poll_interval: 15s }
    graph:
      enabled: true
      subgraphs:
        - { name: amm,  schema: amm,  enabled: true }
        - { name: clob, schema: all,  enabled: true }

  # â”€â”€ External chains (bridge / MPC counterparties) â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€

  - slug: ethereum
    name: Ethereum
    chain_id: 1
    type: evm
    rpc: ${ETH_RPC:-https://eth.llamarpc.com}
    coin: ETH
    enabled: false
    graph:
      enabled: false
      subgraphs:
        - { name: uniswap-v3, schema: uniswap-v3, enabled: true }

  - slug: arbitrum
    name: Arbitrum One
    chain_id: 42161
    type: evm
    rpc: ${ARB_RPC:-https://arb1.arbitrum.io/rpc}
    coin: ETH
    enabled: false

  - slug: optimism
    name: Optimism
    chain_id: 10
    type: evm
    rpc: ${OP_RPC:-https://mainnet.optimism.io}
    coin: ETH
    enabled: false

  - slug: base
    name: Base
    chain_id: 8453
    type: evm
    rpc: ${BASE_RPC:-https://mainnet.base.org}
    coin: ETH
    enabled: false

  - slug: polygon
    name: Polygon
    chain_id: 137
    type: evm
    rpc: ${POLYGON_RPC:-https://polygon-rpc.com}
    coin: MATIC
    enabled: false

  - slug: bsc
    name: BNB Chain
    chain_id: 56
    type: evm
    rpc: ${BSC_RPC:-https://bsc-dataseed.binance.org}
    coin: BNB
    enabled: false

  - slug: avalanche
    name: Avalanche C-Chain
    chain_id: 43114
    type: evm
    rpc: ${AVAX_RPC:-https://api.avax.network/ext/bc/C/rpc}
    coin: AVAX
    enabled: false

  - slug: solana
    name: Solana
    type: solana
    rpc: ${SOL_RPC:-https://api.mainnet-beta.solana.com}
    coin: SOL
    enabled: false

  - slug: bitcoin
    name: Bitcoin
    type: bitcoin
    rpc: ${BTC_RPC:-}
    coin: BTC
    enabled: false

  - slug: cosmos
    name: Cosmos Hub
    type: cosmos
    rpc: ${ATOM_RPC:-https://cosmos-rpc.publicnode.com}
    coin: ATOM
    enabled: false
_Î—l¼xUQAnƒ0¼óŠUîà[K¢€RÔ"ŠÒ#rÌ6A1¶eJ_pj/«ÑÎxwfmÑ-GK µPÍŒ.}+šàJ:…Ès �âw4_­@
ÉŠŸ$3W?ppNË�¬È)48x�VÆ­|›—íë–ÎeãéA‰¾{8šŸ‡«¯°aŽQ2×ç¢"Âo¬•6Â‘uZ`ôÃ:A	:N–8‹Â3Fù§(‡Ö(Ù¡tKÄÊÓ¾NóäTdyßœÓ–Ât‰~Œ$ºoeîÄ¡už¦;rádOŒæ«›<­>‹ò=~ŠÖ~²«vu’•ñóoUuªwIRÆ>þ*?‡ú˜žÓcÜà¥¿ú¾™ö23ý@/ZZ§´Æ&þœkIü8Tð•ÄŠGä�%x„ {ÿÜ¿�timage: ghcr.io/luxfi/explorer:‘lžWDATA_DIR: /data
      HTTP_ADDR: :8090
      EXPLORER_CONFIG: /etc/explorer/chains.yaml“¤8)U)ý²£x2Íí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"`
	StaticDir    string        `yaml:"static_dir"` // overlay dir for SPA; empty = embedded only
	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
}
†=
æ¹ŒxÉ06Ï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.
//
// SPA override: if cfg.StaticDir (or $EXPLORER_STATIC_DIR) points at an
// existing directory, file lookups prefer the on-disk copy over the
// embedded FS. This lets a ConfigMap-mounted SPA replace the embedded
// 60-byte placeholder without a binary rebuild â€” useful when the
// upstream `explore` Next.js app hasn't shipped a static export yet but
// we want a richer in-cluster default than `<div id="root"></div>`.
type Frontend struct {
	cfg      Config
	registry *ChainRegistry
	root     fs.FS    // embedded SPA (always present)
	overlay  fs.FS    // disk overlay (nil if cfg.StaticDir not set)
	index    []byte   // resolved index.html (overlay > embedded > builtin)
}

// 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
	}
	f := &Frontend{cfg: cfg, registry: r, root: root}

	dir := cfg.StaticDir
	if dir == "" {
		dir = os.Getenv("EXPLORER_STATIC_DIR")
	}
	if dir != "" {
		if st, err := os.Stat(dir); err == nil && st.IsDir() {
			f.overlay = os.DirFS(dir)
		}
	}

	if data := f.readStatic("index.html"); data != nil {
		f.index = data
	} else {
		f.index = []byte("<!doctype html><title>Explorer</title>")
	}
	return f, nil
}

// readStatic returns the bytes for `name` from the overlay if present,
// otherwise from the embedded FS. Returns nil if neither has the file.
func (f *Frontend) readStatic(name string) []byte {
	if f.overlay != nil {
		if data, err := fs.ReadFile(f.overlay, name); err == nil {
			return data
		}
	}
	if data, err := fs.ReadFile(f.root, name); err == nil {
		return data
	}
	return 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).
//
// HEAD-method note: the bundled SPA opens EventSource (Server-Sent Events)
// channels for live block / token / tx streams and pre-flights each URL
// with a HEAD request:
//
//	fetch(url, {method: 'HEAD'}).then(r => r.ok ? openEventSource() : disable());
//
// If we serve a 200 (because of the SPA fallback to index.html), the SPA
// proceeds to open the EventSource â€” which then immediately aborts because
// the response body is text/html, not text/event-stream. Browser logs
// "EventSource's response has a MIME type ('text/html') that is not
// 'text/event-stream'" for every channel on every page load â€” noisy and
// disables the channel anyway.
//
// Fix: return 404 for HEAD requests on the SPA-fallback path. Real assets
// (anything in `f.root`) still serve normally on HEAD; only the index.html
// fallback gets the 404 so the SSE pre-flight cleanly fails and the SPA
// disables that channel on its own. Visible browser load is unchanged
// because the SPA only HEAD-probes its SSE endpoints, never the SPA URL
// itself.
func (f *Frontend) handleSPA(w http.ResponseWriter, r *http.Request) {
	p := strings.TrimPrefix(r.URL.Path, "/")
	if p == "" {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		f.writeIndex(w)
		return
	}
	// Overlay wins if it has the file (any non-directory entry); falls
	// back to embedded; falls back to index.html (SPA-routing).
	if f.overlay != nil {
		if file, err := f.overlay.Open(p); err == nil {
			st, statErr := file.Stat()
			file.Close()
			if statErr == nil && !st.IsDir() {
				http.ServeFileFS(w, r, f.overlay, p)
				return
			}
		}
	}
	file, err := f.root.Open(p)
	if err != nil {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		f.writeIndex(w)
		return
	}
	defer file.Close()
	stat, err := file.Stat()
	if err != nil || stat.IsDir() {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		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)
	hostSlug := f.chainSlugForHost(r.Host)
	chains := f.chainListJSON()
	// When the host pins a specific chain (e.g. explore.zoo.network â†’
	// zoo, explorer.zoo-test.network â†’ zoo), promote that chain to
	// default and demote any other config-level default. The SPA's chain
	// switcher honours the "default" flag.
	if hostSlug != "" {
		for i := range chains {
			if chains[i]["slug"] == hostSlug {
				chains[i]["default"] = true
			} else {
				chains[i]["default"] = false
			}
		}
	}

	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 on-disk overlay (if configured)
// then the embedded SPA's named asset.
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 := f.readStatic(fallback); data != 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.
//
// See hostMatchesSlug for the list of supported hostname shapes.
func (f *Frontend) brandStruct(host string) Brand {
	if c, ok := f.matchChain(host); ok && c.Brand != nil {
		return *c.Brand
	}
	return f.cfg.BrandDefault
}

// chainSlugForHost returns the slug of the chain pinned by the request host,
// or "" if the host does not name a chain. Used by handleEnvs to promote a
// per-host chain to the SPA's default selection.
func (f *Frontend) chainSlugForHost(host string) string {
	if c, ok := f.matchChain(host); ok {
		return c.Slug
	}
	return ""
}

// matchChain finds the first configured chain whose slug appears in the
// request host. Matches these hostname shapes, in order of specificity:
//   - "{slug}"                             â€” bare slug (curl localhost)
//   - "{slug}.tld..."                      â€” {slug}.lux.network, {slug}.hanzo.ai
//   - "explore-{slug}.tld..."              â€” explore-hanzo.lux.network
//   - "explorer-{slug}.tld..."             â€” explorer-hanzo.lux.network
//   - "api-explore-{slug}.tld..."          â€” api-explore-hanzo.lux.network
//   - "explore.{slug}.tld..."              â€” explore.hanzo.ai, explore.zoo.ngo
//   - "explorer.{slug}.tld..."             â€” explorer.hanzo.ai, explorer.zoo.network
//   - "api-explore.{slug}.tld..."          â€” api-explore.hanzo.ai
//   - "explore-{slug}-{env}.tld..."        â€” explore-hanzo-test.lux.network
//   - "explorer-{slug}-{env}.tld..."       â€” explorer-hanzo-test.lux.network
//   - "api-explore-{slug}-{env}.tld..."    â€” api-explore-hanzo-dev.lux.network
//   - "explore.{slug}-{env}.tld..."        â€” explore.zoo-test.network
//   - "explorer.{slug}-{env}.tld..."       â€” explorer.zoo-test.network
//   - "api-explore.{slug}-{env}.tld..."    â€” api-explore.zoo-test.network
//
// First chain match wins on ambiguous hosts.
func (f *Frontend) matchChain(host string) (ChainConfig, bool) {
	host = strings.ToLower(strings.SplitN(host, ":", 2)[0])
	for _, c := range f.cfg.Chains {
		if hostMatchesSlug(host, c.Slug) {
			return c, true
		}
	}
	return ChainConfig{}, false
}

// hostMatchesSlug is the pure string predicate for chainâ†”host matching.
// Kept separate from matchChain so unit tests can cover it directly.
func hostMatchesSlug(host, slug string) bool {
	if slug == "" {
		return false
	}
	if host == slug {
		return true
	}
	// Direct slug-as-subdomain: zoo.lux.network, hanzo.ai.
	if strings.HasPrefix(host, slug+".") || strings.HasPrefix(host, slug+"-") {
		return true
	}
	// Brand-owned domains: explore.zoo.network, explorer.zoo-test.network,
	// api-explore.zoo.ngo, etc. Each "explore"/"explorer" prefix combines
	// with "." or "-" as the slug separator.
	prefixes := []string{
		"explore-", "explorer-",
		"api-explore-", "api-explorer-",
		"explore.", "explorer.",
		"api-explore.", "api-explorer.",
	}
	for _, p := range prefixes {
		// {prefix}{slug}.tld  â€” e.g. explore.zoo.network, explore-zoo.lux.network
		if strings.HasPrefix(host, p+slug+".") {
			return true
		}
		// {prefix}{slug}-{env}.tld â€” e.g. explore-zoo-test.lux.network,
		// explorer.zoo-test.network
		if strings.HasPrefix(host, p+slug+"-") {
			return true
		}
	}
	return false
}

// 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)
}
—®íîïÃàWx?ÀãÉa”<�net/http"
	"net/http/httptest‘T&testing"
)

// TestFrontendStaticOverlay verifies that NewFrontend prefers the on-disk
// static dir over the embedded FS when cfg.StaticDir points at a real
// directory. The overlay use-case: ConfigMap-mounting a richer SPA shell
// into an existing image without rebuilding.
func TestFrontendStaticOverlay(t *testing.T) {
	dir := t.TempDir()
	want := "<!doctype html><title>OVERLAY</title>"
	if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(want), 0644); err != nil {
		t.Fatal(err)
	}

	cfmg := Config{StaticDir: dir, BrandDefault: Brand{Name: "Test"}}
	f, err := NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	req := httptest.NewRequest("GET", "/", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "OVERLAY") {
		t.Fatalf("expected overlay index.html, got %q", got)
	}
}

// TestFrontendNoOverlay verifies that with no StaticDir, the embedded
// index.html is served (current behaviour â€” pre-overlay).
func TestFrontendNoOverlay(t *testing.T) {
	cfg := Config{BrandDefault: Brand{Name: "Tes3t"}}
	f, err := NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}
	if f.overlay != nil {
		t.Fatal("expected nil overlay when StaticDir empty")
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	req := httptest.NewRequest("GET", "/", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "doctype html") {
		t.Fatalf("expected fallback index.html, got %q", got)
	}
}

// TestFrontendOverlayMissingFileFallsBack covers the case where the
// overlay dir exists but a particular file is missing â€” the request
// falls through to the embedded FS (and ultimately to index.html for
// SPA-routing).
func TestFrontendOverlayMissingFileFallsBack(t *testing.T) {
	dir := t.TempDir()
	// Only index.html in overlay; no other assets.
	if err := os.WriteFile(filepath.Join(dir, "index.html"),“?OVERLAY"), 0644); err != nil {
		t.Fatal(err)
	}

	cfg := Config{StaticDir: dir, BrandDefault: Brand{Name: "Test"}}
	f, err := #NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	// Request a path the overlay doesn't have. With SPA-routing, the
	// handler returns index.html â€” which IS in the overlay.
	req := httptest.NewRequest("GET", "/some/spa/route", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "OVERLAY") {
		t.Fatalf("expected overlay index.html (SPA fallback), got %q", got)
	}
}

// TestFrontendEnvStaticDir verifies $EXPLORER_STATIC_DIR is the env-var
// equivalent of cfg.StaticDir. Empty cfg + env-var-set overlay should
// still pick up the overlay.
func TestFrontendEnvStaticDir(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("ENV-OVERLAY"), 0644); err != nil {
		t.Fatal(err)
	}
	t.Setenv("EXPLORER_STATIC_DIR", dir)

	cfg := Config{BrandDefault: Brand{Name: "Test"1}}
	f, err := NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}
	if f.overlay == nil {
		t.Fatal("expected overlay populated from env var")
	}
	if !strings.Contains(string(f.index), "ENV-OVERLAY") {
		t.Fatalf("expected env-overlay index, got %q", string(f.index))
	}
}

// TestBrandStructHostMatching covers every hostname shape the unified
// explorer's IngressRoutes hand it: the per-chain brand must win over
// BrandDefault for both UI hosts (explore-{slug}, explore.{slug}.tld)
// and API hosts (api-explore-{slug}). The matcher must NOT bleed across
// chains â€” a hanzo host must not pick zoo's brand or vice versa.
func TestBrandStructHostMatching(t *testing.T) {
	hanzoBrand := Brand{Name: "Hanzo", Coin: "AI", AccentColor: "#0b1220"}
	zooBrand := Brand{Name: "Zoo", Coin: "ZOO", AccentColor: "#7c3aed"}
	cfg := Config{
		BrandDefault: Brand{Name: "Lux Explorer"},
		Chains: []ChainConfig{
			{Slug: "cchain", Brand: &Brand{Name: "Lux C-Chain", Coin: "LUX"}},
			{Slug: "hanzo", Brand: &hanzoBrand},
			{Slug: "zoo", Brand: &zooBrand},
		},
	}
	f, err :%= NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}

	cases := []struct {
		host string
		want string
	}{
		// Bare slug + slug-prefix shape.
		{"hanzo", "Hanzo"},`
		{"hanzo.lux.network", "Hanzo"},
		// explore-{slug} prefix shape (lux.network UI hosts).
		{"“="", "Hanzo"},
		{"“$?", "Hanzo"},
		{"explore-hanzo-dev.lux.network", "Hanzo"},
		{"“ *Q", "Zoo"},
		// api-explore-{slug} prefix shape (lux.network API hosts).
		{"api-“="", "Hanzo"},
		{"api-“$", "Hanzo"},
		{"api-“ *", "Zoo"},
		// explore.{slug}.tld shape (brand-owned domains).
		{"explore.hanzo.ai", "Hanzo"},
		{"explore.hanzo.network", "Hanzo"},
		{"explore.zoo.network", "Zoo"},
		{"explore.zoo.ngo", "Zoo"},
		{"api-explore.hanzo.ai", "Hanzo"},
		{"api-explore.zoo.ngo", "Zoo"},
		// explorer.{slug}.tld shape (alias of explore.* for brand-owned domains).
		{"explorer.zoo.network", "Zoo"},f
		{"explorer.hanzo.ai", "Hanzo"},
		// {slug}-{env}.tld shape â€” brand-owned testnet/devnet domains.“º*% is the primary case this fixes.
		{"“À*", "Zoo"},
		{"’%?", "Zoo"},
		{"explorer.hanzo-test.network", "Hanzo"},
		{"api-’%", "Zoo"},
		// Mismatched hosts fall back to BrandDefault.
		{"explore.lux.network", "Lux Explorer"},
		{"unknown.example.com", "Lux Explorer"},
		// Cross-contamination guard: explore-spcfoo must NOT match spc.
		{"explore-spcfoo.lux.network", "Lux Explorer"},
		// Cross-contamination guard: zoofoo must NOT match zoo.
		{"explore.zoofoo.network", "Lux Explorer"},
		{"explorer.zoofoo.network", "Lux Explorer"},
	}

	for _, tc := range cases {
		t.Run(tc.host, func(t *testing.T) {
			got := f.brandStruct(tc.host).Name
			if got != tc.want {
				t.Fatalf("host=%q: got brand=%q, want %q", tc.host, got, tc.want)
			}
		})
	}
}

// TestChainSlugForHost covers the hostâ†’default-chain promotion that
// handleEnvs uses to set the SPA's initial chain selection. Hosts that
// pin a chain (“*“®() must
// return the slug; generic hosts (explore.lux.network) must return "".
func TestChainSlugForHost(t *testing.T) {
	zooBrand := Brand{Name: "Zoo"}
	hanzoBrand := Brand{Name: "Hanzo"}
	cfg := Config{
		BrandDefault: Brand{Name: "Lux Explorer"},
		Chains: []ChainConfig{
			{Slug: "cchain", Brand: &Brand{Name: "Lux C-Chain"}},
			{Slug: "hanzo", Brand: &hanzoBrand},
			{Slug:Q "zoo", Brand: &zooBrand},
		},
	}
	f, err := NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}
	cases := []struct {
		host string
		want string
	}{
		{"explore.zoo.network", "zoo"},
		{"explore.zoo.ngo", "z-oo"},
		{"explorer.zoo.network", "zoo"},
		{"“À*", "zoo"},
		{"’%", "zoo"},
		{"“ *", "zoo"},
		{"“�*", "zoo"},
		{"explore.hanzo.ai", "hanzo"},
		{"explorer.hanzo-test.network", "hanzo"},
		// Generic / mismatched hosts: no slug.
		{"explore.lux.network", ""},
		{"unknown.example.com", ""},
		{"explore.zoofoo.network", ""},
	}
	for _, tc := range cases/ {
		t.Run(tc.host, func(t *testing.T) {
			got“�ttc.host)
			if got != tc.want {
				t.Fatalf("host=%q: got slug=%q, want %q", tc.host, got, tc.want)
			}
		})
	}
}
,g5îçþ·OxçG¸”<�’�;bytes"
	"embed"
	"encoding/json"
	"fmt"
	"io/fs"
	"net/http‘6&sync"
)

// staticFS is the embedded SPA. The Dockerfile populates static/ from a
// luxfi/explore build before the Go build runs; for local dev a placeholder
// index.html is committed so go:embed always succeeds.
//
//go:embed all:static
var staticFS embed.FS

// Frontend serves the embedded SPA, runtime config (/envs.js), and per-host
// brand assets (/icon.svg, /logo.svg). Brand assets read from disk on every
// request so a deploy can swap them without rebuilding the binary.
//
// SPA override: if cfg.StaticDir (or $EXPLORER_STATIC_DIR) points at an
// existing directory, file lookups prefer the on-disk copy over the
// embedded FS. This lets a ConfigMap-mounted SPA replace the embedded
// 60-byte placeholder without a binary rebuild â€” useful when the
// upstream `explore` Next.js app hasn't shipped a static export yet but
// we want a richer in-cluster default than `<div id="root"></div>`.
type Frontend struct {
	cfg      Config
	registry *ChainRegistry
	root     fs.FS    // embedded SPA (always present)
	overlay  fs.FS    // disk overlay (nil if cfg.StaticDir not set)
	index    []byte   // resolved index.html (overlay > embedded > builtin)
}

// 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
	}
f	f := &Frontend{cfg: cfg, registry: r, root: root}

	dir := cfg.StaticDir
	if dir == "" {
		dir = os.G“))
	}
	if dir != "" {
		if st, err := os.Stat(dir); err == nil && st.IsDir() {
			f.overlay = os.DirFS(dir)
		}
	}

	if data := Xf.readStatic("index.html"); data != nil {
		f.index = data
	} else {
		f.index = []byte(“¾Explorer</title>")
	}
	return f, nil
}

// readStatic returns the bytes for `name` from the overlay if present,
// otherwise from the embedded FS. Returns nil if neither has the file.
func (f *Frontend) readStatic(name string) []byte {
	if f.overlay != n“@if data, err := fs.ReadFile(f.overlay, name); err == nil {
			return data
		}
	}
	if data, err := fs.ReadFile(f.root, name); err == nil {
		return data
	}
	return 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 falls back to index.html for
// any path the SPA owns (client-side routing).
//
// HEAD-method note: the bundled SPA opens EventSource (Server-Sent Events)
// channels for live block / token / tx streams and pre-flights each URL
// with a HEAD request:
//
//	fetch(url, {method: 'HEAD'}).then(r => r.ok ? openEventSource() : disable());
//
// If we serve a 200 (because of the SPA fallback to index.html), the SPA
// proceeds to open the EventSource â€” which then immediately aborts because
// the response body is text/html, not text/event-stream. Browser logs
// "EventSource's response has a MIME type ('text/html') that is not
// 'text/event-stream'" for every channel on every page load â€” noisy and
// disables the channel anyway.
//
// Fix: return 404 for HEAD requests on the SPA-fallback path. Real assets
// (anything in `f.root`) still serve normally on HEAD; only the index.html
// fallback gets the 404 so the SSE pre-flight cleanly fails and the SPA
// disables that channel on its own. Visible browser load is unchanged
// because the SPA only HEAD-probes its SSE endpoints, never the SPA URL
// itself.
func (f *Frontend) handleSPA(w http.ResponseWriter, r *http.Request) {
	p := strings.TrimPrefix(r.URL.Path, "/")
	if p == "" {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		f.writeIndex(w)
		return
	}
	// Overlay wins if it has the file (any non-dirRectory entry); falls
	// back to embedded; falls back to index.html (SPA-routing).“-if file, err := f.overlay.Open(p); err == nil {
			st, statErr := file.Stat()
			file.Close()
			if statErr == nil && !st.IsDir() {
				http.ServeFileFS(w, r, f.overlay, p)
				return
			}
		}
	}
	file, err := f.root.Open(p)
	if err != nil {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		f.writeIndex(w)
		return
	}
	defer file.Close()
	stat, err := file.Stat()
	if err != nil || stat.IsDir() {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		f.writeIndex(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 returns runtime config the SPA reads instead of build-time env
// vars. This is what makes per-network customization possible without 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)
	hostSlug := f.chainSlugForHost(r.Hoost)
	chains := f.chainListJSON()
	// When the host pins a specific chain (e.g. explore.zoo.network â†’
	// zoo“! â†’ zoo), promote that chain to
	// default and demote any other config-level default. The SPA's chain
	// switcher honours the "default" flag.
	if hostSlug != "" {
		for i := range chains {
			if chains[i]["slug"] == hostSlug {
				chains[i]["default"] = true
			} else {
				chains[i]["default"] = false
			}
		}
	}

	current, peers := f.networksForHost(r.Host)
	env := map[string]any{
		"VITE_CHAINS":          chains,
		"VITE_NETWORKS":        f.cfg.Networks,
		"VITE_BRAND":           hostBrand,
		"VITE_CURRENT_NETWORK": current,
		"VITE_NETWORK_HOSTS":   peers,
	}
	body, _ := json.Marshal(env)

	var buf bytes.Buffer
	buf.WriteString("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 { return b.IconFile }, "icon.svg")
}

// handleLogo serves the per-host or default logo.svg from disk.
func (f *Frontend) handleLogo(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 on-disk overlay (if configured)
// then the embedded SPA's named asset.
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("Cache-Control", "public, max-age=300")
			w.Write(data)
			return
		}
	}
	if data := f.readStatic(fallback); data != nil {
		w.Header().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 *Frontend) 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,
	}
}

// brandStruct returns the Brand for a request host: a chain whose name or
// slug matches the hostname wins; otherwise the global default.
//
// See hostMatchesSlug for the list of supported hostname shapes.
func (f *Frontend) brandStruct(host string) Brand {
	if c, ok := f.matchChain(host); ok && c.Brand != nil {
		return *c.Brand
	}
	return f.cfg.BrandDefault
}

// chainSlugForHost returns the slug of the chain pinned by the request host,
// or "" if the host does not name a chain. Used by handleEnvs to promote a
// per-host chain to the SPA's default selection.
func (f *Frontend) chainSlugForHost(host string) string {
	if c, ok := f.matchChain(host); ok {
		return c.Slug
	}
	return ""
}

// matchChain finds the first configured chain whose slug appears in the
// request host. Matches these hostname shapes, in order of specificity:
//   - "{slug}"                             â€” bare slug (curl localhost)
//   - "{slug}.tld..."                      â€” {slug}.lux.network, {slug}.hanzo.ai
//   - "explore-{slug}.tld..."              â€” “Á
//   - "explorer-{slug}.tld..."             â€” explorer-hanzo.lux.network
//   - "api-explore-{slug}.tld..."          â€” api“À
//   - "explore.{slug}.tld..."              â€” explore.hanzo.ai, explore.zoo.ngo
//   - "explorer.{slug}.tld..."             â€” explorer.hanzo.ai, explorer.zoo.network
//   - "api-explore.{slug}.tld..."          â€” api-explore.hanzo.ai
//   - "explor"e-{slug}-{env}.tld..."        â€” “ï
//   - "explorer-{slug}-{env}.tld..."       â€” explorer-hanzo-test.lux.network
//   - "api-explore-{slug}-{env}.tld..."    â€” api-“+1
//   - "explore.{slug}-{env}.tld..."        â€” “ß0
//   - "explorer.{slug}-{env}.tld..."       â€”“!1
//   - "api-explore.{slug}-{env}.tld..."    â€” “Û
//
// First chain match wins on ambiguous hosts.
func (f *Frontend) matchChain(host string) (ChainConfig, bool) {
	host = strings.ToLower(strings.SplitN(host, ":", 2)[0])
	for _, c := range f.cfg.Chains {
		if hostMatchesSlug(host, c.Slug) {
			return c, true
		}
	}
	return ChainConfig{}, false
}

// hostMatchesSlug is the pure string predicate for chainâ†”host matching.
// Kept separate from matchChain so unit tests can cover it directly.
func hostMatchesSlug(host, slug string) bool {
	if slug == "" {
		return false
	}
	if host == slug {
		return true
	}
	// Direct slug-as-subdomain: zoo.lux.network, hanzo.ai.
	if strings.HashPrefix(host, slug+".") || strings.HasPrefix(host, slug+"-") {
		return true
	}
	// Brand-owned domains: “.,
	// api-explore.zoo.ngo, etc. Each "explore"/"explorer" prefix combines
	// with "." or "-" as the slug separator.
	prefixes := []string{
		"explore-", "explorer-",
		"api-explore-", "api-explorer-",
		"explore.", "explorer.",
		"api-explore.", "api-exMplorer.",
	}
	for _, p := range prefixes {
		// {prefix}{slug}.tld  â€” e.g. “-zoo.lux.network
		if strings.HasPrefix(host, p+slug+".") {
			return true
		}
		// {prefix}{slug}-{env}.tld â€” e.g. explore-z“I,“
		if strings.HasPrefix(host, p+slug+"-") {
			return true
		}
	}
	return false
}

// networksForHost returns (current network label, sibling host map). The
// SPA renders a "Network: mainnet â–¾" dropdown â€” when the user picks
// another label, the SPA redirects to the corresponding host. Hosts are
// derived purely from the request host: explore.zoo.network (mainnet)
// has sibling“!(testnet); explore.lux.network
// has sibling explorer.lux-test.network; and the reverse.
//
// Host shape rules:
//   - mainnet: prefix is "explore.", brand domain is "<brand>.<tld>"
//     (e.g. zoo.network, lux.network, hanzo.ai). Sibling is
//     "explorer.<brand>-test.network".
//   - testnet: prefix is "explorer.", brand domain is "<brand>-test.network".
//     Sibling is "explore.<brand>.network" (or .ai / .ngo per brand
//     when known; we default to .network because all the actual
//     deployed testnet hosts are *.network).
//   - lux.network (no brand prefix, generic explore.lux.network) is
//     special-cased: mainnet=explore.lux.network, testnet=explorer.lux-test.network.
//
// If we can't classify the host (admin tooling on localhost, internal
// service-mesh hostnames), we return ("", nil) and the SPA omits the
// dropdown.
func (f *Frontend) networksForHost(host string) (string, map[string]string) {
	host = strings.ToLower(strings.SplitN(host, ":", 2)[0])

	// Find the brand stem and current env from the host. The two
	// shapes we support are "{prefix}.{brand}.{tld}" (mainnet) and
	// "{prefix}.{brand}-{env}.{tld}" (non-mainnet).
	brand, env, dotted, ok := parseBrandEnv(host)
	if !ok {
		return "", nil
	}

	// All deployed testnet/devnet hosts are *.network. Mainnet
	// brand hosts vary: zoo.network, lux.network, hanzo.ai, hanzo.network,
	// zoo.ngo, etc. For the round-trip from testnet back to mainnet
	// we need to know which mainnet TLD the brand uses. When the user
	// is already on a brand-owned domain (explore.{brand}.{tld}),
	// preserve that TLD so the mainnet option is idempotent. When the
	// user is on the lux.network-prefix shape (explore-{brand}.lux.network)
	// or any non-brand-owned shape, default to ".network" â€” every brand
	// we ship has an X.network mainnet alias.
	mainnetTLD := ".network"
	if env == "" && dotted {
		if dot := strings.Index(host, brand+"."); dot >= 0 {
			mainnetTLD = host[dot+len(brand):]
		}
	}

	mainnetHost := "explore." + brand + mainnetTLD
	testnetHost := "explorer." + brand + "-test.network"

	peers := map[string]string{
		"mainnet": mainnetHost,
		"testnet": testnetHost,
	}
	switch env {
	case "":
		return "mainnet", peers
	case "test", "testnet":
		return "testnet", peers
	case "dev", "devnet":
		// Optional devnet entry; only surfaces when present.
		peers["devnet"] = "explorer." + brand + "-dev.network"
		return "devnet", peers
	default:
		return env, peers
	}
}

// parseBrandEnv extracts the brand stem and env tag from a host. Recognizes:
//
//	explore.{brand}.{tld}                  â†’ ({brand}, "", dotted=true)
//	explorer.{brand}.{tld}                 â†’ ({brand}, "", dotted=true)
//	explore.{brand}-{env}.{tld}            â†’ ({brand}, {env}, dotted=true)
//	explorer.{brand}-{env}.{tld}           â†’ ({brand}, {env}, dotted=true)
//	explore-{brand}.lux.network            â†’ ({brand}, "", dotted=false)
//	explore-{brand}-{env}.lux.network      â†’ ({brand}, {env}, dotted=false)
//
// Returns ok=false on hosts we can't classify (localhost, IPs, internal
// service-mesh names like *.svc.cluster.local).
//
// `dotted` distinguishes brand-owned hosts (explore.zoo.network â€” TLD
// belongs to the brand) from lux-prefix hosts (explore-zoo.lux.network â€”
// brand is just a subdomain on lux.network) so the network switcher can
// pick the right mainnet TLD for the sibling URL.
func parseBrandEnv(host string) (brand, env string, dotted, ok bool) {
	if host == "" || strings.HasSuffix(host, ".svc.cluster.local") || strings.HasSuffix(host, ".local") {
		return "", "", false, false
	}
	stripPrefix := func(s, p string) (string, bool) {
		if strings.HasPrefix(s, p) {
			return s[len(p):], true
		}
		return s, false
	}

	// "explore.{brand}{maybe-env}.{tld}" / "explorer.{brand}{maybe-env}.{tld}"
	for _, p := range []string{"explore.", "explorer.", "api-explore.", "api-explorer."} {
		rest, matched := stripPrefix(host, p)
		if !matched {
			continue
		}
		dot := strings.Index(rest, ".")
		if dot < 1 {
			return "", "", false, false
		}
		stem := rest[:dot]
		brand, env = splitBrandEnv(stem)
		if !isValidBrand(brand) {
			return "", "", false, false
		}
		return brand, env, true, true
	}

	// "explore-{brand}{maybe-env}.lux.network" / "explorer-{brand}{maybe-env}.lux.network"
	for _, p := range []string{"explore-", "explorer-", "api-explore-", "api-explorer-"} {
		rest, matched := stripPrefix(host, p)
		if !matched {
			continue
		}
		dot := strings.Index(rest, ".")
		if dot < 1 {
			return "", "", false, false
		}
		stem := rest[:dot]
		brand, env = splitBrandEnv(stem)
		if !isValidBrand(brand) {
			return "", "", false, false
		}
		return brand, env, false, true
	}

	return "", "", false, false
}

// isValidBrand restricts brand stems to alphabetic (or digit-containing) tokens
// at least 2 chars long. Rejects numeric-only stems (IP addresses), empty,
// and stems beginning with a digit (so "10" from "10.0.0.1" never matches).
func isValidBrand(s string) bool {
	if len(s) < 2 {
		return false
	}
	if s[0] < 'a' || s[0] > 'z' {
		return false
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if !(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') {
			return false
		}
	}
	return true
}

// splitBrandEnv splits "zoo-test" into ("zoo", "test"); "zoo" stays as ("zoo", "").
// Only recognises known env suffixes (test, testnet, dev, devnet, local) so
// hyphenated brand names ("api-zoo-exchange") still parse as one stem.
func splitBrandEnv(stem string) (brand, env string) {
	for _, e := range []string{"-test", "-testnet", "-dev", "-devnet", "-local"} {
		if strings.HasSuffix(stem, e) {
			return strings.TrimSuffix(stem, e), strings.TrimPrefix(e, "-")
		}
	}
	return stem, ""
}

// chainListJSON returns a SPA-friendly chain list from the live registry,
// not the static config â€” chains 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.ChainID,
			"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 string) string {
	switch strings.ToLower(filepath.Ext(path)) {
	case ".svg":
		return "image/svg+xml"
	case ".png":
		return "image/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")
	if err != nil {
		return "0"
	}
	count := 0
	_ = fs.WalkDir(root, ".", func(_ string, _ fs.DirEntry, _ error) error {
		count++
;		return nil
	})
	return fmt.Sprintf("assets=%d", count)
}
/~•î¿€Žwxþ#Ü�’†N�net/http"
	"net/http/httptest‘T&testing"
)

// TestFrontendStaticOverlay verifies that NewFrontend prefers the on-disk
// static dir over the embedded FS when cfg.StaticDir points at a real
// directory. The overlay use-case: ConfigMap-mounting a richer SPA shell
// into an existing image without rebuilding.
func TestFrontendStaticOverlay(t *testing.T) {
	dir := t.TempDir()
	want := "<!doctype html><title>OVERLAY</title>"
	if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(want), 0644); err != nil {
		t.Fatal(err)
	}

	cfmg := Config{StaticDir: dir, BrandDefault: Brand{Name: "Test"}}
	f, err := NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	req := httptest.NewRequest("GET", "/", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "OVERLAY") {
		t.Fatalf("expected overlay index.html, got %q", got)
	}
}

// TestFrontendNoOverlay verifies that with no StaticDir, the embedded
// index.html is served (current behaviour â€” pre-overlay).
func TestFrontendNoOverlay(t *testing.T) {
	cfg := Config{BrandDefault: Brand{Name: "Tes3t"}}
	f, err := NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}
	if f.overlay != nil {
		t.Fatal("expected nil overlay when StaticDir empty")
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	req := httptest.NewRequest("GET", "/", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "doctype html") {
		t.Fatalf("expected fallback index.html, got %q", got)
	}
}

// TestFrontendOverlayMissingFileFallsBack covers the case where the
// overlay dir exists but a particular file is missing â€” the request
// falls through to the embedded FS (and ultimately to index.html for
// SPA-routing).
func TestFrontendOverlayMissingFileFallsBack(t *testing.T) {
	dir := t.TempDir()
	// Only index.html in overlay; no other assets.
	if err := os.WriteFile(filepath.Join(dir, "index.html"),“?OVERLAY"), 0644); err != nil {
		t.Fatal(err)
	}

	cfg := Config{StaticDir: dir, BrandDefault: Brand{Name: "Test"}}
	f, err := #NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	// Request a path the overlay doesn't have. With SPA-routing, the
	// handler returns index.html â€” which IS in the overlay.
	req := httptest.NewRequest("GET", "/some/spa/route", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "OVERLAY") {
		t.Fatalf("expected overlay index.html (SPA fallback), got %q", got)
	}
}

// TestFrontendEnvStaticDir verifies $EXPLORER_STATIC_DIR is the env-var
// equivalent of cfg.StaticDir. Empty cfg + env-var-set overlay should
// still pick up the overlay.
func TestFrontendEnvStaticDir(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("ENV-OVERLAY"), 0644); err != nil {
		t.Fatal(err)
	}
	t.Setenv("EXPLORER_STATIC_DIR", dir)

	cfg := Config{BrandDefault: Brand{Name: "Test"1}}
	f, err := NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}
	if f.overlay == nil {
		t.Fatal("expected overlay populated from env var")
	}
	if !strings.Contains(string(f.index), "ENV-OVERLAY") {
		t.Fatalf("expected env-overlay index, got %q", string(f.index))
	}
}

// TestBrandStructHostMatching covers every hostname shape the unified
// explorer's IngressRoutes hand it: the per-chain brand must win over
// BrandDefault for both UI hosts (explore-{slug}, explore.{slug}.tld)
// and API hosts (api-explore-{slug}). The matcher must NOT bleed across
// chains â€” a hanzo host must not pick zoo's brand or vice versa.
func TestBrandStructHostMatching(t *testing.T) {
	hanzoBrand := Brand{Name: "Hanzo", Coin: "AI", AccentColor: "#0b1220"}
	zooBrand := Brand{Name: "Zoo", Coin: "ZOO", AccentColor: "#7c3aed"}
	cfg := Config{
		BrandDefault: Brand{Name: "Lux Explorer"},
		Chains: []ChainConfig{
			{Slug: "cchain", Brand: &Brand{Name: "Lux C-Chain", Coin: "LUX"}},
			{Slug: "hanzo", Brand: &hanzoBrand},
			{Slug: "zoo", Brand: &zooBrand},
		},
	}
	f, err :%= NewFrontend(cfg, NewChainRegistry()“t.Fatal(err)
	}

	cases := []struct {
		host string
		want string
	}{
		// Bare slug + slug-prefix shape.
		{"hanzo", "Hanzo"},`
		{"hanzo.lux.network", "Hanzo"},
		// explore-{slug} prefix shape (lux.network UI hosts).
		{"“\#@", "Hanzo"},
		{"explore-hanzo-test.lux.network", "Hanzo"},
		{"“8%", "Hanzo"},
		{"explore-zoo.lux.network", "Zoo"},
		// api-explore-{slug} prefix shape (lux.network API hosts).
		{"api-explor“b#", "Hanzo"},
		{"api-explore-hanzo-test.lux.network", "Hanzo"},
		{"api-explore-zoo.lux.network", "Zoo"},
		// explore.{slug}.tkld shape (brand-owned domains).
		{"explore.hanzo.ai", "Hanzo"},
		{"explore.hanzo.network", "Hanzo"},
		{"“‘*-", "Zoo"},
		{"explore.zoo.ngo", "Zoo"},
		{"“P$", "Hanzo"},
		{"api-explore.zoo.ngo", "Zoo"},
		// explorer.{slug}.tld shape (alias of explore.* for brand-owned domains).
		{"explorer.zoo.network", "Zoo"},
		{"explorer.hanzo.ai", "Hanzo"},
		// {slug}-{env}.tld shape â€” brand-owned testnet/devnet domains.“@+% is the primary case this fixes.
		{"“Ï%", "Zoo"},
		{"“&;", "Zoo"},
		{"explorer.hanzo-test.network", "Hanzo"},
		{"“&@", "Zoo"},
		// Mismatched hosts fall back to BrandDefault.
		{"“�/", "Lux Explorer"},
		{"unknown.example.com", "Lux Explorer"},
		// Cross-contamination guard: explore-spcfoo must NOT match spc.
		{"explore-spcfoo.lux.network", "Lux Explorer"},
		// Cross-contamination guard: zoofoo must NOT match zoo.
		{"explore.zoofoo.network", "Lux Explorer"},
		{"explorer.zoofoo.network", "Lux Explorer"},
	}

	for _, tc := range cases {
		t.Run(tc.host, func(t *testing.T) {
			got := f.brandStruct(tc.host).Name
			if got != tc.want {
				t.Fatalf("host=%q: got brand=%q, want %q", tc.host, got, tc.want)
			}
		})
	}
}

// TestNetworksForHost covers the network dropdown payload the SPA reads
// from /envs.js. Mainnet hosts return ("mainnet", siblings) and testnet
// hosts return ("testnet", siblings); unparseable hosts return ("", nil).
func TestNetworksForHost(t *testing.T) {
	cfg := Config{
		BrandDefault: Brand{Name: "Lux Explorer"},
		Chains: []ChainConfig{
			{Slug: "cchain"}, {Slug: "zoo"}, {Slug: "hanzo"},
		},
	}
	f, err := NewFrontend(cfg, NewChainRegistry())
	if err !=“+	t.Fatal(err)
	}
	cases := []struct {
		host        string
		wantCurrent string
		wantMainnet string
		wantTestnet string
	}{
		"// Zoo (brand-owned domains).
		{"“‘*", "mainnet", "“‘*", "“Ï%"},
		{"“Ï%", "testnet", "“‘*", "“Ï%("},
		// Lux (brand-owned domains).
		{"“�/", "mainnet", "“�/", "“ž/"},
		{"“ž/", "testnet", "“�/", "“ž/"},
		// Hanzo brand on .ai mainnet, .network testnet.
		{"explore.hanzo.ai", "mainnet", "explore.hanzo.ai", "explorer.hanzo-test.network"},
		{"explorer.hanzo-test.network", "testnet", "explore.hanzo.network", "explorer.hanzo-test.network"},
		// lux.ne]twork prefix shape â€” explore-{slug}.lux.network.
		{"explore-zoo.lux.network", "mainnet", "“‘*", "“Ï%3"},
		{"explore-zoo-test.lux.network", "testnet", "“‘*", "“Ï%O"},
	}
	for _, tc := range cases {
		t.Run(tc.host, func(t *testing.T) {
			cur“‰tc.host)
			if cur != tc.wantCurrent {
				t.Errorf("current: got %q want %q", cur, tc.wantCurrent)
			}
			if peers["mainnet"] != tc.wantMainnet {
				t.Errorf("mainnet: got %q want %q", peers["mainnet"], tc.wantMainnet)
			}
			if peers["testnet"] != tc.wantTestnet {
				t.Errorf("testnet: got %q want %q", peers["testnet"], tc.wantTestnet)
			}
		})
	}

	// Unparseable hosts return empty.
	for _, h“á=Mlocalhost", "10.0.0.1", "explorer.lux-mainnet.svc.cluster.local", ""} {
		cur“‰h)
		if cur != "" || peers != nil {
			t.Errorf("expected empty result for %q, got cur=%q peers=%v", h, cur, peers)
		}
	}
}

// TestChainSlugForHost covers the hostâ†’default-chain promotion that
// handleEnvs uses to set the SPA's initial chain selection. Hosts that
// pin a chain (“‘*“Ö%*) must
// return the slug; generic hosts (“�/) must return "".
func TestChainSlugForHost(t *testing.T) {
	zooBrand := Brand{Name: "Zoo"}
	hanzoBrand := Brand{Name: "Hanzo"}
	cfg := Config{
		BrandDefault: Brand{Name: "Lux Explorer"},
		Chains: []ChainConfig{
			{Slug: "cchain", Brand: &Brand{Name: "Lux C-Chain"}},
			{Slug: "hanzo", Brand: &hanzoBrand},
			{Slug: "zoo", Brand: &zooBrand},
		},
	}
	f, err := NewFrontend(cfg, NewChainRegistry()“It.Fatal(err)
	}
	cases := []struct {
		host string
		want string
	}{
		{"“‘*P", "zoo"},
		{"explore.zoo.ngo", "zoo"},
		{"explorer.zoo.network", "zoo"},
		{"“Ï%", "zoo"},
		{"“&", "zoo"},
		{"explore-zoo.lux.network", "zoo"},
		{"explore-zoo-test.lux.network", "zoo"},
		{"explore.hanzo.ai", "hanzo"},
		X{"explorer.hanzo-test.network", "hanzo"},
		// Generic / mismatched hosts: no slug.
		{"“�/", ""},
		{"unknown.example.com", ""},
		{"explore.zoofoo.network", ""},
	}
	for _, tc := range cases {
		t.Run(tc.host, func(t *testing.T) {
			got“�ttc.host)
			if got != tc.want {
				t.Fatalf("host=%q: got slug=%q, want %q", tc.host, got, tc.want)
			}
		})
	}
}
K'±Q±šx¡^îmodule github.com/luxfi/explorer

go 1.26.4

require (
	github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
	github.com/luxfi/graph v1.2.2
	github.com/luxfi/indexer v1.4.5-0.20260526184203-3b72d71cc8fd
	github.com/luxfi/mdns v0.1.1
	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/cloudflare/circl v1.6.3 // 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.3.0 // 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/hablullah/go-hijri v1.0.2 // indirect
	github.com/hablullah/go-juliandays v1.0.0 // indirect
	github.com/hanzoai/ltx v0.5.1 // indirect
	github.com/hanzoai/lz4/v4 v4.1.22 // indirect
	github.com/hanzoai/replicate v0.7.2 // indirect
	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
	github.com/jalaali/go-jalaali v0.0.0-20250521085720-bf793ab67800 // indirect
	github.com/klauspost/compress v1.18.6 // indirect
	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
	github.com/klauspost/crc32 v1.3.0 // indirect
	github.com/lib/pq v1.12.3 // indirect
	github.com/lmittmann/tint v1.1.3 // indirect
	github.com/luxfi/accel v1.1.9 // indirect
	github.com/luxfi/age v1.5.0 // indirect
	github.com/luxfi/cache v1.2.1 // indirect
	github.com/luxfi/codec v1.1.4 // indirect
	github.com/luxfi/compress v0.0.5 // indirect
	github.com/luxfi/concurrent v0.0.3 // indirect
	github.com/luxfi/crypto v1.19.17 // indirect
	github.com/luxfi/database v1.19.0 // indirect
	github.com/luxfi/ids v1.2.13 // indirect
	github.com/luxfi/log v1.4.3 // indirect
	github.com/luxfi/math v1.4.1 // indirect
	github.com/luxfi/math/big v0.1.0 // indirect
	github.com/luxfi/metric v1.5.8 // indirect
	github.com/luxfi/mock v0.1.1 // indirect
	github.com/luxfi/zap v0.7.0 // indirect
	github.com/luxfi/zapdb v1.10.0 // indirect
	github.com/markusmobius/go-dateparser v1.2.4 // indirect
	github.com/mattn/go-colorable v0.1.15 // indirect
	github.com/mattn/go-isatty v0.0.22 // indirect
	github.com/miekg/dns v1.1.72 // indirect
	github.com/minio/crc64nvme v1.1.1 // indirect
	github.com/minio/md5-simd v1.1.2 // indirect
	github.com/minio/minio-go/v7 v7.2.0 // indirect
	github.com/mr-tron/base58 v1.3.0 // indirect
	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
	github.com/ncruces/go-strftime v1.0.0 // indirect
	github.com/philhofer/fwd v1.2.0 // indirect
	github.com/prometheus/client_golang v1.23.2 // indirect
	github.com/prometheus/client_model v0.6.2 // indirect
	github.com/prometheus/common v0.68.0 // indirect
	github.com/prometheus/procfs v0.20.1 // indirect
	github.com/psanford/sqlite3vfs v0.0.0-20260519004904-f9180fa2acc9 // indirect
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
	github.com/rs/xid v1.6.0 // indirect
	github.com/tetratelabs/wazero v1.12.0 // indirect
	github.com/tinylib/msgp v1.6.4 // indirect
	github.com/wasilibs/go-re2 v1.11.0 // indirect
	github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb // indirect
	github.com/zeebo/xxh3 v1.1.0 // indirect
	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
	go.opentelemetry.io/otel v1.44.0 // indirect
	go.opentelemetry.io/otel/metric v1.44.0 // indirect
	go.opentelemetry.io/otel/trace v1.44.0 // indirect
	go.uber.org/mock v0.6.0 // indirect
	go.yaml.in/yaml/v3 v3.0.4 // indirect
	golang.org/x/crypto v0.52.0 // indirect
	golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
	golang.org/x/mod v0.36.0 // indirect
	golang.org/x/net v0.55.0 // indirect
	golang.org/x/sync v0.20.0 // indirect
	golang.org/x/sys v0.45.0 // indirect
	golang.org/x/text v0.37.0 // indirect
	golang.org/x/tools v0.45.0 // indirect
	google.golang.org/protobuf v1.36.11 // indirect
	gopkg.in/ini.v1 v1.67.2 // indirect
	gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
	modernc.org/libc v1.72.5 // indirect
	modernc.org/mathutil v1.7.1 // indirect
	modernc.org/memory v1.11.0 // indirect
	modernc.org/sqlite v1.51.0 // indirect
)
Szµd·ßx÷�rc2sp.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/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/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/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.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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/ltx v0.5.1 h1:jWamJSHg1RtvcarevSJ/W5lyPJfRB4vBYXDuxyyOup0=
github.com/hanzoai/ltx v0.5.1/go.mod h1:rWgoDyQiB5d+GOGYncKrQu6uS7Gnoh0xlm1hCU5rq5E=
github.com/hanzoai/lz4/v4 v4.1.22 h1:NjK1Umwaxb5lnoFB44iGPtGj58y/PLucNcCc2gE8q4I=
github.com/hanzoai/lz4/v4 v4.1.22/go.mod h1:/iX6TGHL94Q9MkMmvHKKHlyopHn06RjEW7Bz3h6TJEk=
github.com/hanzoai/replicate v0.7.2 h1:PulFMIVVbvxz/LmBSKlj/o+T7nZmPGd2L2YjYVv5V1U=
github.com/hanzoai/replicate v0.7.2/go.mod h1:U0WdEoFFNw4lS9CRysXtsWOqtkzjIi2Ggv+C8Nsz8+4=
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-20250521085720-bf793ab67800 h1:lvIuaX7hO0eO3Rlev+cVnlsoExR3i/JXxu88zt4JHPg=
github.com/jalaali/go-jalaali v0.0.0-20250521085720-bf793ab67800/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
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.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/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/accel v1.1.9 h1:Tsk6gXj2uKE19501bD0ajRYdeCHIlTGb6jYyLc+F8hc=
github.com/luxfi/accel v1.1.9/go.mod h1:K00BcnLzEYMPHwCFq8Tf/450ApmTs9xBVvYOnofJCkc=
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
github.com/luxfi/age v1.5.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
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/codec v1.1.4 h1:Yl8ZalMNkqo7cD6R9AjczAajkLOmsjyZ9+DASVYHrvg=
github.com/luxfi/codec v1.1.4/go.mod h1:oGQ3j6E8c2P0pL0irYtWkrB1hmDUFIE0puXHK4gV5KI=
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.7 h1:e/Qs+DQP3pugle3Zncq6fZCxKgqqtbyD/z7Gm4ZjsYg=
github.com/luxfi/constants v1.4.7/go.mod h1:hOszZ2NDQ8gMZKncfcZ67PXkb5OIbnwAzXC3oFbQwW0=
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.19.17 h1:l2LLu7UFyICtJVfraLDLRi+lFGiDXKHSL18M9/m1gsQ=
github.com/luxfi/crypto v1.19.17/go.mod h1:INjdZtke85k8hX/QAmTMAY8bbZ4gzGZQLqURg3xf6Gk=
github.com/luxfi/database v1.19.0 h1:sySH5eS8ihJtyc+yb0LgTignGwJ9H4W9bv+nboCuIm0=
github.com/luxfi/database v1.19.0/go.mod h1:o1ccjgVKMYbVBfi+6+sDvx4swGFgGnCGFQ4gL/oJ+kY=
github.com/luxfi/geth v1.16.98 h1:w187TtKuGStf3tm2bshuHVKBv2Frjx0lT54kQVXyNHA=
github.com/luxfi/geth v1.16.98/go.mod h1:6kEzSExdk9CPQDPXALt6P3HfQqBq7KF1Jrrv9gBpxbU=
github.com/luxfi/graph v1.2.2 h1:APDihqCknjMeo06M3Xi9WAuyIMYOWMwsGmvKw9JElEU=
github.com/luxfi/graph v1.2.2/go.mod h1:/wuzuD5jiziLSecONTi3GOeuzLhAsflOqMerEawuFgw=
github.com/luxfi/ids v1.2.13 h1:lEotq0WUpMLcc/5X5MbE3n73jdfNo8IlFvTDfUXg+9A=
github.com/luxfi/ids v1.2.13/go.mod h1:QWjJvggX/nRkmrz1AGSaF2Y6mnOvz3g7lFOzs3Zd+JM=
github.com/luxfi/indexer v1.4.5-0.20260526184203-3b72d71cc8fd h1:RBI4m3Y+MmIsb1JO7JzBi/HffCVavhzt814qQbToGFk=
github.com/luxfi/indexer v1.4.5-0.20260526184203-3b72d71cc8fd/go.mod h1:h5U3td/esXhbkDojcpvFQtSCQ9USrYMnnmtNk56ei+Y=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
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.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
github.com/luxfi/metric v1.5.8 h1:axPwfq+erOlIue7IJp5g+hMcMtVhYHja9gJAaT3+KNA=
github.com/luxfi/metric v1.5.8/go.mod h1:fO2giazkg4NDtr72JM/QXJBYebplAMeWC1JoZyNDvKw=
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/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
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.4 h1:8OY0jXCkpp6OotN1Y++6DATJkvtEwzq2k0p56imJlAk=
github.com/luxfi/utils v1.1.4/go.mod h1:c3yz1RjzrB+cs5GZm+q1T3/2cCKElO9vxm9yRRtgSEM=
github.com/luxfi/zap v0.7.0 h1:wiOXfctxiNk0EAiaEPURTXemvO8fyaGViIkoQVWs7Kw=
github.com/luxfi/zap v0.7.0/go.mod h1:WOBoeqEh0BCApg1wfxoadOLMXZUC9T1ksOABGoEp8os=
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
github.com/luxfi/zapdb v1.10.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
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.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
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.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs=
github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA=
github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/psanford/sqlite3vfs v0.0.0-20260519004904-f9180fa2acc9 h1:9bBMbcwroL46feESdJWjRX0GV+k8o/P9gAg9UX6Vz7U=
github.com/psanford/sqlite3vfs v0.0.0-20260519004904-f9180fa2acc9/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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/wasilibs/go-re2 v1.11.0 h1:9j4uCAJyO3IJHygeOdUJNzm6sX3P1+3xuKTbCITQrvc=
github.com/wasilibs/go-re2 v1.11.0/go.mod h1:cDOlK/pNYGbGKRNa+NnXDJ1c8R63tWjnTjFKzoSbLv0=
github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb h1:gQ+ZV4wJke/EBKYciZ2MshEouEHFuinB85dY3f5s1q8=
github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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.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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
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/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.2 h1:mxsy2FdrB6+qG3NfXefz1AmWv0ehOSDO4jxgxd7h9yo=
modernc.org/ccgo/v4 v4.34.2/go.mod h1:1L7us56+kAKu04p25EATpmBBvhbcqqZ85ibqWVwVgog=
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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/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.72.5 h1:m2OGx9Ser1VvTS4Z9ZJlWs+CBMxutLaTiAWkNz+NB9U=
modernc.org/libc v1.72.5/go.mod h1:np0N7KDJ7eUtMZmOqVZNldrZyG+DHLl2B5pg8Hbar3U=
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.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/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.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
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=
ÂËt¼èxŒsá// 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.
//
// Build: go build -o explorer .
// Run:   explorer --config /etc/explorer/chains.yaml
//
// Routes:
//
//	/                                       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 (
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"syscall"
	"time"
)

var version = "dev"

func main() {
	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"); cancel() }()

	registry := NewChainRegistry()
	supervisor := NewChainSupervisor(cfg.DataDir)
	registry.AttachSupervisor(supervisor)
	// Bridge indexer block events into the realtime hub so WebSocket +
	// SSE subscribers see new blocks as they're ingested. Must happen
	// before any chain starts indexing â€” runChain installs the
	// Subscriber.OnBroadcast callback off the hub set here.
	supervisor.AttachRealtime(registry.hub)

	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)
		}
	}

	if chains := os.Getenv("CHAINS"); chains != "" {
		registry.LoadFromEnv(chains)
	}

	if *enableMDNS {
		go registry.StartMDNSDiscovery()
	}

	go registry.hub.Run(ctx)
	go supervisor.Wait(ctx)

	frontend, err := NewFrontend(cfg, registry)
	if err != nil {
		log.Fatalf("[explorer] frontend: %v", err)
	}

	mux := http.NewServeMux()

	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",
			"version": version,
			"chains":  registry.Count(),
		})
	})

	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)

	mux.HandleFunc("/v1/explorer/realtime", registry.hub.HandleRealtime)
	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())
	})

	// /v1/base/realtime â€” multiplexed SSE channel the bundled SPA opens.
	// One stream carries every broadcast in a JSON envelope `{event, chain, data}`
	// that the SPA routes off via its handler map. See realtime_sse.go.
	muxRealtimeSSE := registry.hub.HandleMultiplexedSSE()
	mux.HandleFunc("GET /v1/base/realtime", muxRealtimeSSE)
	mux.HandleFunc("HEAD /v1/base/realtime", muxRealtimeSSE)

	// SSE endpoints at the URLs the bundled SPA opens EventSource on.
	// See realtime_sse.go for the channel mapping + the back-story on
	// why each path needs its own handler. Registering HEAD + GET
	// explicitly so the SPA's pre-flight (HEAD-then-EventSource) hits
	// the same SSE handler â€” net/http's ServeMux otherwise returns 405
	// "Method Not Allowed" for HEAD on a GET-only route, which the
	// SPA's `u.ok` check would reject and disable the channel.
	for path, channel := range map[string]string{
		"/blocks":          "blocks",
		"/transactions":    "transactions",
		"/token-transfers": "token_transfers",
		"/internal-txs":    "internal_transactions",
		"/tokens":          "tokens",
		"/gas-tracker":     "gas_tracker",
		"/validators":      "validators",
		"/stats":           "stats",
	} {
		h := registry.hub.HandleSSE(channel)
		mux.HandleFunc("GET "+path, h)
		mux.HandleFunc("HEAD "+path, h)
	}

	supervisor.MountRoutes(mux)
	frontend.Mount(mux)

	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 := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("[explorer] server: %v", err)
		}
	}()
	<-ctx.Done()
	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
	}
	return fallback
}

func envBool(key string, fallback bool) bool {
	switch strings.ToLower(os.Getenv(key)) {
	case "true", "1", "yes":
		return true
	case "false", "0", "no":
		return false
	}
	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 ""
}
«$²¡xÅYínÛÈýM=Å„€w©D¦œ`·X¨ð�$n±)Çµ“…a$9²¸¦Hf†’­ÍØ‡Ø'ì“ôœ;CŠò×nŠÍ‹ÎÜ¹çž{gR'éer¡Õ"ÉËÁ _Ô•iT4B]¦U–—ãŸlU†˜-þÕJÝŒçMSóÙ6-›|¡Ã.òf¾œÆiµËëY>^d%fƒñXe‰^Tå©6«<åÖµU‹ƒ£SeýH³®µUM¥š¹Î�Ÿ®òrVÅƒUÒøõVí«³sè°Lõe´b�V
ÛÝ‘=Ž’…Vø·™ätR%?Ì*£`§¯LRf[ó¦2R=Ë¯eb:‡ë”-–˜ƒý¿„aqlÒ:©�/þ7¼É÷Ÿ«jó�/üŽßö{ñyóÏ²:ÿ¼Ì³vÂ<)î‰p¯œ&Oí¬:1v#HÞ8‡œr#±8mÓ¼…÷r›V+mÖjjª+‹ ÐIQ¨Ë²º*Û(¸ØÐ+É²©v�¾Èm£�•¸z:Sâ«WE•^Zµ,›¼`8•[`ÖßZ€¨´*}Ý¨Üª4)S]:‹³e™ªÈ¨§¯éÜÙÃ¬‡ê®²Ñ�1P`s$<%ÒâÎ€‘v|)»JÕd_!~Àû-eþ; š4©›¥ÑÏ ˜Eî‘¾Š0#ö(ƒ;õu]TF¸vá™÷Oàÿ•øñ4«¤ˆžï=ez`)ŒÎ†ÛsaY¡ßãsµl¢?Ý�‰ÄqšÄïÊc­MDE5ž”3˜ƒ#õS•—ðþ´ª
qLä3õÄ�ÒÈ 0v•|¼áà<>Fþ6³(<£êçŠ	´cUÒàïd‡(¤áÌœ‘âÆñË,Ã–òxÚRœ‰kSMõQ•iÑîö$'DÒŠóoÄ.è¨�¡�¹u,QŽ†–Á'ûªtD÷{t…jj–ä@ùÂÌµ³òÊvQ‚xQ�pËË%ÃJë¹ù#©k]f‘¼ŽD	ÌF~ÜçÉð�¤ÇN¶•Ø¶Ð¥“3¤i ’d 'k¸TíXÀ¸QfY‚âæÀ=Ø#Q•Aä¼A`u¡Ie7>C;‡ªÏKmr$f¢JøDJTÌÅ)ÓÌ1©�‚ÆŽ˜’H¹Òç’s×ýðvYÚ#5È2ûÊªÜ�y#íÃ™Ù)Í+Ûx^8XN Ì¤B=ß
4§‰Õ9êJ|Z{²¤LÆãp”å„xgþX‹õbû  F-zL�¾†w#Š~ŽÁ+c~GTB)º!0ß` BŒ=òz »cbSÁÍã‹›ÈÎV›TÄcóæ`¤>Ò:î{º¡ð<Ž¼îEvàÄ.é ¥xÊz‘ÜÓ{â¼×%,iÕ¿ý¡fv[õîèð_ž„ÊD2Ð–¤M¾ÒÅZ5¥8Ž¥ïJ­NŽ_:¹Ÿ¢Ñüª�åH.€”…L­žÇ/¾‹_üðËz¾ÆûNÑG¼ÄþœBµ‰ªŠ‰BÅ­ºŸ"Aî3Ù…G'éÜŒpÜDÅ-•U£’È#™å3¯¹9É¤.’i»WhC¼¿Åq‡ú"I×8¬Ûh'¢FŽTuID8¯�…î—˜8;GZ:0>ÁRÝ,KT�,;o"ä¶®J»¥¡çxÂÑW¿^íëT’­Ò·IÝéGèÆÎH½VRA¼ZÁ-þd·ä�N1 :¶`äÙÖô0ý,V‹.Uœô§ÀHÒ<Sûû*]¸¥¾KÛÖ›áÞ±ìlòÃ9DÐi�pæÙø}uX]¡”¶ï'!OõK0ˆ3,Tˆz¸‚ÊEÇa 6u^ºïéíg«gB í®âí÷èh=h#qãHä¬çf²ƒ=Ð³dY4œùÄÄ?&í@$eR*n$«_Wå,¿�¢x
«&ÒËâAšŠ#È“±zÈlØ&Û+9MÁ·cp§‡ønsÝ	u”u�"ÿ´Zš”Bö¡ŒyÍ‘c­ÎFyËmç,ƒø¢;äþ¬èhŠËê )wŠ¡Ë<µd£ÚfjŒ¤�r+úZ¢ÖfW^„ÜS0!zÔâª$ºœ¬*¿EåZÖ¾há´Ñ%Ù(|w© _íœF�q%ÂpL¸=`>}EÉ"Û¡Ÿ&Ã±iXw.RDÊNñ"_‘ì–<£tÄÖ£�MGa;v`3nÏÂÞ§è«_=ÿL{Ô 	=ísÐ4ýïIH=ÊB~Æ#4ägÜæ!! &”'¢_~yŒ” {$Ô‡“C¦òV‡óXÒa¸ì‰À)9¾‘ß"=Ìüÿ°Ùÿ€ƒœUÂÁ 2?À‹¿Ï8·˜ò+y§�
îjü-ºM;ÒÍ5Ï³Sâø	]8ÇÛ®›Y´XKôëMs¡ªrÓ…Ådœß§áÚ}ÜŒ}úF†«èXg†RztîìrA]øÈÁf¯™ƒ¶:‘“„¦XÇuÐAº;�ãn'´7lg<r—è!rè„[h¦ –GS6X9£[íNâ· Ùñ�™Là´Èu)Uë6ðñkyÿâ�³õB=ùþè‹“×´ÊÖL�O_BÞ/±øLÂñ^8
‘š“ç£p¡Ñðá1ÄïG1ëŽ ¼¼H6œœ�ß| í¶è]üÙÑiãÌà3lŠ“]‘§	­u7Y(.îº*ÆQþD'h1#jÃçPVï4àˆŒ­¡ÅÉŠsÇøæÃÈÊjfžñ^âXõãû÷Ç]/¶‰Ê��Hä$®ßíí�¿ßÛÛÌY’ô5ºF\¡¼+¥^Ðx
ÁÍ„_ÖZr¯/QåO�Û™¥2�ÛˆÎ™¸o‘‹«†�ÉÌT�à?Þª7r¢t_…ðŒfEL¦85 §ìÈÄG•š'&ÃE!>ºuöà+Ñ½H¬Íg<¹^±­\. BQŽQÉ}‘ÝÖ¸×#u¸s$ò
6pRÔ¶§#}£þ£ƒÃ_Z�@Y"pH `ÑÉwKÐK5,l‘ëUàp8!�K¦¢.‹�¸ä||q²Z„C…êÐjtGüµëH¶¥g	¯U3×ÿõ·Õ�·i6-! æNz-ƒðhïb ¿/s‰˜$—–òáf#wimF›x±ŒOq©@„Co,dìCÉ«ŽúÂ.}­»T3±(à®Ó˜L1ŒGŸûîPŠ}ëPÁ¨\ÃHZÑŸghÖ‘SàæaÊ%€“¨¿�¾;Ú
æ(/:Èµ¢°"N/ÞR¿<Zšb„T!™ø�àBAH„—!CuëÀ#lRwCáIjûHÄ¦{CZÊ±ÇH\ìÃ•zÎÔ1˜òÛc–Â-~ûÍ�€ÎZn;Ç…”›Âƒ@nâ5ºbÏs$Ìø-.qç¸Têz„¹Ð3ÊB'ê÷ˆúû;Dý ÃŠ_ÿ½ºLˆ¼ÃG™¶¯o‹»;”ë.|}!„tóœ¸Ò¸/õ‰›„nzˆªüE
©zÚ[¼ÕÖò?Bœ²í¢…•U7í âP¹pöÃ‚šr€ë]zN*ÅðçPô�SáîÅ¦§˜-Û�7=vÚöÐÒŸÎÞR&àú�w=n¨p,ÛZ{û�·›tóœˆÛä|�¢ñþ¡Kxl“sx óÿ’4ñ3°•³¶\%°¿xÍX]sÓF}¶~ÅV3eä`dhfê6`˜&ÃC&²´±ÕÈ+#­pRà¿÷œ»+YvH™>4I¼wï=÷Ü³w½NÒ‹d¡Õ*ÉMä«uYYƒ0-�Õ—6Ä¿Ú¤e–›Åø¯º4(ÊÿmÇKk×ü¿¾2)ÿÚ|¥Ã ÿ,r»læqZ®Æ‹²Ê‹"oô¼.Ó£Ã �Õ±N
nxÑÌá€�µZ5z�.á�z§ç3Ù êf^§U¾¶yiê8°Wk½³»¶U“Zõ9¬%?t(>~wÔ Š`�¹6Ö“õéÁ¦žÊÇ3·ëó×àkàL¶3jk8<èœ�§	ôã4ˆÅ@çl^–…BTá¼€Óu8R¡­S'©xÍÏnf’JxÐ6�;—ÅãÎß²Ö™¢I:×ÃêH×5–×Ê.µÚä•VçeµJ,ÿ¨ÊCªô'¼U»yÞ	‘t€Iê“<	‰pø!L%\à¢lç%‚Q¹Ê­^­í—>OlâL%æŠÿxSÆwWž çµMVk•ûäq·’\�q˜sQÏ\ÞçúXl0Ç°7KÄÚ&´Ö&S¶l	2×>âk·!wS{!1£àØq3�.W#ÕÏ!>•Ú¼—±s]Õß–øk¥P7)”cv]4‹ß•@§U¦ÏÐÞÇüJoú…‘"¥…‘(£7Š5œ7&U»Ë¢¡:èïB%TÚ6•Q÷zÃmKaê^èèæ‚Ž‚�T‰× •T¨n©ñï.©¢,×±š&&ÕË^J,ÆœÑrÇ£!E\æ•…uD…²jm]¨ÔäP‘1b;‘‘è×‡êÀ�Í46fÃ` ´°Ôíˆg80‚šH~†WëB;%¤I­Õpfü¼4:N0?XÆi�âzZØ‡Ï&ã–{ÃS¿¸‹WVõ¸|J*Õ¬U’Á•Cµ‡·~®L—:½x]å‹ÜLSUê€z{2¥¾ÅkŸ+¨˜&[¦¯�gj™˜¬€ êUs©
Tt�ã‘š˜©—H©lìD=Úƒ	‹�öìYs¬fùßz>zøËcL¼«P´;3nÂUm(7§Ì­Ì¼89yãªDgÆŸ�õåº(+]�+¿ú¶ÌïÚŒÀb‡B½†škñ§©kè C¤Ü@&+¡FxìAŽ6Ø4R&/�Èü\–ýtÈÏ=L™°VÿÉ¯{­Æ“,4?!!pIï•EOÖ]=àÎª‰_BÝIIn•S÷÷Dp*óù+î·ø­áe E^gT+Š"³P;jyë^L1‹Zè½VÓMŠ4ˆÞËnwH—;%�øÂ)7Ñ0~kòË#\¹y$Ž³<XÈ „íÄgr+šãÆtê7^ã2£÷Èjö¦Y­#ž·2-tbšõªÎê¨ƒdäZ`fy<e¤F´ìšVlÏÆÍë¯kŠ…1ñL[†ó2Ç=~øÛ´;õÓEŽªw‚"¸<Í²èÉ^¥wv¾)ÍÂ±µŠ¤d	†ä–˜?¦Ud¬ì‹Öû‘ZÕ‹ŽèþFèY âs�â[ñß~ ì¥ Uú£ê.G¯Q˜òÇ¢vØ •«¤‚ú‘¸|»†¸ÊP¢½Úcšmnp[êoPor›.yDÜ!U*ªû�–iïÂE–/ôkyÇN¢«sMÜ	C9c0à†½õê¾
'!~w;h�8È^™ó#‹ã6XâÔènQÇk7r[ï–pWj™ÔðÀ5Y®À;gXÝé©|å÷:MGÊÏ!z	ˆANv‘à�Ÿ»h&ÝÝ²òÀù-Ú¾ðAÜù8¹í.žUe’�Vñ@t9Æu®l&’}…oÜ·˜q†�È"G‡ËvŠÜ—¶ê¶û§;$’®˜r
�’ÎÖA9RlRqø•ˆˆMz¡þäyr³oM¿)�¹D‰]f€ý°Ï�´ÀºF¼®äÅ¥³N¤ðŽ¶eÍº.m#C�û£7Qúï�&‰šxMœ—
šéOË5n#qÊ+ûq{Í¹vKn¾ãÝÙo¹äH%&o’Nþ]eKêvJ	¯ˆ  u;]]u@ž©/_nž¹�Ju)>#Y;Ã=ˆ3Ï³]CR'›È
ÜXáB¾›/ž^»Ç îÄ„°Ût÷;9r�þW0K½Â]„ø�ð¶­/£ÛigzyîÁzç4ËµÜÞëyK†Ão>5Ú—žMðÂp®Û	¯}¾=l^£+ÇcöæœÍ¸ žBâ]Ùø=&SZÈÉ‡�<"þ5©eÿýC4èxß8jrˆ\»�©€¦{ší:LÞ¶í¿æ€¬+1Ú!Én_µ¸wªû.‹ø½¬§ý¦©%Æ¾œùvªwÓ9Èå×1€e…v„ýZ&U½U™^¿!-üm×þÛ„—W/*�kèÄÂŸ³×¯ØU\ï':ºûßAÅoð½‹=�ÂS´‹¢ÔgjÃÇ�ëÖ&êçOüŽ¥ªvkâ:ÑÿÓ³ù•ÕÿC`Ú.pûÎ<ÁËÙ�ºËë˜‘!ÿ £Ë[[îq�qxáøð'ƒ5�´, plus a
// parallel set of SSE subscribers (see realtime_sse.go) so the bundled
// SPA â€” which opens EventSource against /blocks, /transactions, etc.
// â€” can subscribe to the same broadcast feed without speaking
// WebSocket.
type RealtimeHub struc‘Ì:	sse     *sseRegistry³	sse:     newSSERegistry(³©
// channel+chain â€” both WebSocket (`HandleRealtime`) and SSE
// (`HandleSSE`).
//
// WebSocket clients receive the JSON-encoded RealtimeMessage as a TEXT
// frame. SSE clients receive the same payload wrapped in the SSE
// `event: <type>\ndata: <json>f\n\n` framing so the EventSource
// onmessage / addEventListener('<type>', ...) handlers fire cleanly.“€“åõ4WebSocket subscribers (existing path).
	h.mu.RLock()“ž+“b•	h.mu.RUnlock()

	// SSE subscribers â€” wrap in SSE framing once, fan out cheaply.
	//
	// Two shapes, built up-front so fanout() is allocation-free:
	//   - perChannel: `event: <type>\ndata: <RealtimeMessage JSON>\n\n`
	//     for the per-channel /blocks /transactions /etc. endpoints.
	//   - wildcard:   `data: {"event":<type>,"chain":<slug>,"data":<RealtimeMessage.Data JSON>}\n\n`
	//     for the multiplexed /v1/base/realtime endpoint the SPA opens.
	//     No `event:` line â€” the SPA uses `onmessage` and routes off the
	//     envelope's `event` field.
	if h.sse != nil {
		perChannel := append([]byte("event: "+eventType+"\ndata: "), encoded...)
		perChannel = append(perChannel, '\n', '\n')

		envBody, err := json.Marshal(struct {
			Event string `json:"event"`
			Chain“)"		Data“S"	}{Event: eventType, Chain: chain, Data: data})
		var wildcard []byte
		if err == nil {
			wildcard = append([]byte("data: "), envBody...)
			wildcard = append(wildcard, '\n', '\n')
		}
		h.sse.fanout(eventType, chain, perChannel, wildcard)
	}
}

func (h“à³àXó\wº¤xJ*µÕpackage main

import (
	"fmt"
	"net/http"
	"strings"
	"sync"
	"time"
)

// SSE endpoints â€” Server-Sent Events at the URLs the bundled Vite SPA
// expects (/blocks, /transactions, /token-transfers, /internal-txs,
// /tokens, /gas-tracker, /stats, /validators).
//
// Why this exists (2026-05-21):
//
// The SPA opens EventSource against `${window.location.origin}/<channel>`
// expecting `text/event-stream`. The Go backend previously had no
// handler for those paths, so every request fell through to the SPA's
// own static fallback (`handleSPA` â†’ index.html), returning
// `text/html`. Browser console then logged:
//
//     EventSource's response has a MIME type ("text/html") that is not
//     "text/event-stream". Aborting the connection.
//
// â€¦for every channel, on every page load. PR #4 silenced the HEAD
// pre-flight (so the SPA cleanly disabled itself). This file goes the
// other direction: make the SSE endpoints REAL, so the SPA opens them,
// gets a proper `text/event-stream` reply, stays connected, and
// receives broadcasts.
//
// Wire shape:
//   - GET  /blocks                      â†’ SSE for hub channel "blocks"
//   - GET  /transactions                â†’ SSE for hub channel "transactions"
//   - GET  /token-transfers             â†’ SSE for hub channel "token_transfers"
//   - GET  /internal-txs                â†’ SSE for hub channel "internal_transactions"
//   - GET  /tokens, /gas-tracker, /validators â†’ SSE that stays open with
//     keep-alive pings; no broadcasters yet (placeholder so the SPA
//     channel doesn't show as broken)
//   - GET  /stats                       â†’ SSE that emits the hub.Stats()
//     map every 5s â€” cheap server-side state, useful liveness signal
//   - HEAD <any>                        â†’ 200 + the same headers so the
//     SPA pre-flight (`fetch(url, {method: HEAD}).then(r => r.ok)`)
//     succeeds before opening EventSource.
//
// Each connected client gets its own bounded channel. Slow consumers
// drop messages rather than back-pressure the hub; the SPA refreshes
// stale state via REST on reconnect.

// sseClient is a single SSE subscriber. One per connected browser per
// channel (the SPA opens one EventSource per channel it wants to follow).
type sseClient struct {
	channel string        // hub event-type to receive ("blocks" / "transactions" / ...)
	chain   string        // optional chain filter; "" = unscoped
	events  chan []byte   // buffered outbound queue; full â†’ drop
	done    chan struct{} // closed when the client disconnects
}

// sseRegistry holds the live SSE clients. Kept separate from the
// WebSocket client map so the existing readPump/writePump paths don't
// need to learn a new client shape.
type sseRegistry struct {
	mu      sync.RWMutex
	clients map[*sseClient]struct{}
}

func newSSERegistry() *sseRegistry {
	return &sseRegistry{clients: make(map[*sseClient]struct{})}
}

func (r *sseRegistry) attach(c *sseClient) {
	r.mu.Lock()
	r.clients[c] = struct{}{}
	r.mu.Unlock()
}

func (r *sseRegistry) detach(c *sseClient) {
	r.mu.Lock()
	delete(r.clients, c)
	r.mu.Unlock()
	close(c.done)
}

// fanout pushes a pre-encoded message body to every SSE client whose
// channel filter matches. Called from RealtimeHub.Broadcast.
//
// Match policy mirrors the WebSocket subs lookup:
//   - exact channel match (client.channel == eventType)
//   - chain-scoped match (channel + ":" + chain) is checked too so a
//     subscriber that filtered on "blocks:cchain" sees only cchain
//     events even if the hub broadcasts blocks across multiple chains
//   - wildcard (`*`) â€” clients attached by HandleMultiplexedSSE receive
//     every event, but in a different envelope: SSE `data:` carries a
//     JSON `{event, chain, data}` object the SPA can route off of. The
//     wildcard `body` is built here; per-channel clients keep using the
//     prebuilt per-channel framing from Broadcast for cheap reuse.
func (r *sseRegistry) fanout(eventType, chain string, body []byte, wildcardEnvelope []byte) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	for c := range r.clients {
		var payload []byte
		switch {
		case c.channel == "*":
			payload = wildcardEnvelope
		case c.channel == eventType:
			payload = body
		default:
			continue
		}
		if c.chain != "" && c.chain != chain {
			continue
		}
		if payload == nil {
			continue
		}
		select {
		case c.events <- payload:
		default:
			// Slow consumer â€” drop this event rather than block.
		}
	}
}

// HandleSSE returns an http.HandlerFunc bound to a specific channel
// name. main.go mounts one per path.
func (h *RealtimeHub) HandleSSE(channel string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// HEAD pre-flight: GCP HTTP/2 LB + Traefik can 502 on HEAD
		// responses that carry `Connection: keep-alive` together with
		// a streaming content-type. Keep the HEAD response minimal â€”
		// the SPA only checks `r.ok` (status) anyway.
		if r.Method == http.MethodHead {
			w.Header().Set("Content-Type", "text/event-stream")
			w.WriteHeader(http.StatusOK)
			return
		}

		// SSE response headers for the actual GET stream.
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		w.Header().Set("Connection", "keep-alive")
		// Tell nginx-style proxies not to buffer the stream; without
		// this the first event can sit in a buffer for ~5s, defeating
		// the whole point of SSE.
		w.Header().Set("X-Accel-Buffering", "no")

		flusher, ok := w.(http.Flusher)
		if !ok {
			// Should be impossible on Go's net/http; defensive.
			http.Error(w, "streaming unsupported", http.StatusInternalServerError)
			return
		}

		// Subscribe to the registry. `chain` query param is optional;
		// blank = no chain filter, see fanout()'s match policy.
		client := &sseClient{
			channel: channel,
			chain:   strings.TrimSpace(r.URL.Query().Get("chain")),
			events:  make(chan []byte, 64),
			done:    make(chan struct{}),
		}
		h.sse.attach(client)
		defer h.sse.detach(client)

		// Initial "connected" frame so the SPA's onopen handler fires
		// with a recognizable payload (matches the WebSocket "connected"
		// shape).
		writeSSE(w, flusher, "connected", []byte(`{}`))

		// Keep-alive ticker. Comment lines (`: ping`) don't fire the
		// EventSource onmessage handler but keep the TCP/TLS connection
		// alive through middleboxes that idle-timeout at 30s.
		keepAlive := time.NewTicker(30 * time.Second)
		defer keepAlive.Stop()

		// /stats is a special case â€” server-side periodic poll of the
		// hub's connection-count snapshot. Useful liveness signal even
		// when no hub broadcasts have been wired up yet.
		var statsTicker *time.Ticker
		if channel == "stats" {
			statsTicker = time.NewTicker(5 * time.Second)
			defer statsTicker.Stop()
		}

		ctx := r.Context()
		for {
			select {
			case <-ctx.Done():
				return
			case <-client.done:
				return
			case <-keepAlive.C:
				if _, err := w.Write([]byte(": ping\n\n")); err != nil {
					return
				}
				flusher.Flush()
			case msg := <-client.events:
				// Broadcast messages are already JSON-encoded by the
				// hub; we just prefix with the SSE framing.
				if _, err := w.Write(msg); err != nil {
					return
				}
				flusher.Flush()
			default:
				if statsTicker != nil {
					select {
					case <-statsTicker.C:
						// Synthesize a stats event from the hub.
						stats := fmt.Sprintf(
							`{"connections":%d,"subscriptions":%d}`,
							len(h.clients), len(h.sse.clients),
						)
						writeSSE(w, flusher, "stats", []byte(stats))
						continue
					default:
					}
				}
				time.Sleep(50 * time.Millisecond)
			}
		}
	}
}

// writeSSE emits one SSE event frame:
//
//	event: <name>
//	data: <payload>
//	(blank line)
//
// Per the SSE spec a frame ends with `\n\n`. Browsers tolerate `\r\n`
// too but `\n` is the canonical form Go's net/http uses elsewhere.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, payload []byte) {
	fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload)
	flusher.Flush()
}

// HandleMultiplexedSSE returns an http.HandlerFunc that fans EVERY hub
// broadcast into a single SSE stream, wrapping each event in a JSON
// envelope `{"event":"<channel>","chain":"<slug>","data":<payload>}`.
//
// Wire shape the bundled SPA expects at `/v1/base/realtime` â€” it uses
// `onmessage` (not event-typed listeners) and routes incoming frames
// via a handler map keyed off `msg.event`:
//
//	client.es.onmessage = o => {
//	    const c = JSON.parse(o.data);
//	    const d = this.handlers.get(c.event);
//	    if (d) for (const m of d) m(c.data);
//	};
//
// So the SPA gets ONE EventSource and receives all event types over it,
// instead of opening N parallel streams. This is the canonical realtime
// channel â€” the per-channel /blocks /transactions /etc. endpoints stay
// for backward compatibility and external consumers, but the SPA uses
// only this one.
func (h *RealtimeHub) HandleMultiplexedSSE() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// HEAD pre-flight: keep the response brief â€” GCP HTTP/2 load
		// balancers (and Traefik in some configs) 502 when a HEAD
		// response carries `Connection: keep-alive` + a streaming
		// content-type, because they expect HEAD to be a closed-body
		// response, not an opened SSE stream. Return only Content-Type
		// + 200, no streaming-only headers.
		if r.Method == http.MethodHead {
			w.Header().Set("Content-Type", "text/event-stream")
			w.WriteHeader(http.StatusOK)
			return
		}

		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		w.Header().Set("Connection", "keep-alive")
		w.Header().Set("X-Accel-Buffering", "no")

		flusher, ok := w.(http.Flusher)
		if !ok {
			http.Error(w, "streaming unsupported", http.StatusInternalServerError)
			return
		}

		// Subscribe to ALL channels by registering a wildcard client.
		// The empty `channel` means fanout() needs special handling â€” we
		// use a sentinel "*" so we don't accidentally collide with a real
		// channel called "" elsewhere.
		client := &sseClient{
			channel: "*",
			chain:   strings.TrimSpace(r.URL.Query().Get("chain")),
			events:  make(chan []byte, 128),
			done:    make(chan struct{}),
		}
		h.sse.attach(client)
		defer h.sse.detach(client)

		writeSSE(w, flusher, "CONNECT", []byte(`{}`))

		keepAlive := time.NewTicker(30 * time.Second)
		defer keepAlive.Stop()

		ctx := r.Context()
		for {
			select {
			case <-ctx.Done():
				return
			case <-client.done:
				return
			case <-keepAlive.C:
				if _, err := w.Write([]byte(": ping\n\n")); err != nil {
					return
				}
				flusher.Flush()
			case msg := <-client.events:
				if _, err := w.Write(msg); err != nil {
					return
				}
				flusher.Flush()
			}
		}
	}
}
d—¾Áxáëpackage main

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/gorilla/websocket"
)

func TestRealtimeHubStats(t *testing.T) {
	hub := NewRealtimeHub()
	stats := hub.Stats()
	if stats["connections"] != 0 {
		t.Fatalf("expected 0 connections, got %d", stats["connections"])
	}
}

func TestRealtimeWebSocket(t *testing.T) {
	hub := NewRealtimeHub()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go hub.Run(ctx)

	mux := http.NewServeMux()
	mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
	ts := httptest.NewServer(mux)
	defer ts.Close()

	// Connect WebSocket
	wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
	conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
	if err != nil {
		t.Fatalf("dial: %v", err)
	}
	defer conn.Close()

	// Read connected message
	var msg RealtimeMessage
	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	err = conn.ReadJSON(&msg)
	if err != nil {
		t.Fatalf("read connected: %v", err)
	}
	if msg.Type != "connected" {
		t.Fatalf("expected connected, got %s", msg.Type)
	}

	// Subscribe to blocks
	sub := SubscribeRequest{Subscribe: "blocks", Chain: "cchain"}
	conn.WriteJSON(sub)

	// Read subscribed confirmation
	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	err = conn.ReadJSON(&msg)
	if err != nil {
		t.Fatalf("read subscribed: %v", err)
	}
	if msg.Type != "subscribed" {
		t.Fatalf("expected subscribed, got %s", msg.Type)
	}

	// Broadcast a block
	hub.Broadcast("blocks", "cchain", map[string]any{"number": 1})

	// Read the broadcast
	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	err = conn.ReadJSON(&msg)
	if err != nil {
		t.Fatalf("read broadcast: %v", err)
	}
	if msg.Type != "blocks" {
		t.Fatalf("expected blocks event, got %s", msg.Type)
	}
	if msg.Chain != "cchain" {
		t.Fatalf("expected chain=cchain, got %s", msg.Chain)
	}

	// Verify stats
	stats := hub.Stats()
	if stats["connections"] != 1 {
		t.Fatalf("expected 1 connection, got %d", stats["connections"])
	}
	if stats["subscriptions"] != 1 {
		t.Fatalf("expected 1 subscription, got %d", stats["subscriptions"])
	}
}

func TestRealtimePing(t *testing.T) {
	hub := NewRealtimeHub()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go hub.Run(ctx)

	mux := http.NewServeMux()
	mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
	ts := httptest.NewServer(mux)
	defer ts.Close()

	wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
	conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
	if err != nil {
		t.Fatalf("dial: %v", err)
	}
	defer conn.Close()

	// Consume connected
	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	conn.ReadJSON(&RealtimeMessage{})

	// Send ping
	conn.WriteJSON(SubscribeRequest{Subscribe: "ping"})

	var msg RealtimeMessage
	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	err = conn.ReadJSON(&msg)
	if err != nil {
		t.Fatalf("read pong: %v", err)
	}
	if msg.Type != "pong" {
		t.Fatalf("expected pong, got %s", msg.Type)
	}
}

func TestRealtimeUnscopedSubscription(t *testing.T) {
	hub := NewRealtimeHub()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go hub.Run(ctx)

	mux := http.NewServeMux()
	mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
	ts := httptest.NewServer(mux)
	defer ts.Close()

	wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
	conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
	if err != nil {
		t.Fatalf("dial: %v", err)
	}
	defer conn.Close()

	// Consume connected
	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	conn.ReadJSON(&RealtimeMessage{})

	// Subscribe to transactions without chain scope (gets all chains)
	conn.WriteJSON(SubscribeRequest{Subscribe: "transactions"})

	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	conn.ReadJSON(&RealtimeMessage{}) // subscribed

	// Broadcast from any chain should match
	hub.Broadcast("transactions", "zoo", map[string]any{"hash": "0xabc"})

	var msg RealtimeMessage
	conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	err = conn.ReadJSON(&msg)
	if err != nil {
		t.Fatalf("read broadcast: %v", err)
	}
	if msg.Type != "transactions" {
		t.Fatalf("expected transactions, got %s", msg.Type)
	}
	if msg.Chain != "zoo" {
		t.Fatalf("expected chain=zoo, got %s", msg.Chain)
	}
}

func TestRealtimeStatsEndpoint(t *testing.T) {
	hub := NewRealtimeHub()

	mux := http.NewServeMux()
	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(hub.Stats())
	})

	ts := httptest.NewServer(mux)
	defer ts.Close()

	resp, err := ts.Client().Get(ts.URL + "/v1/explorer/realtime/stats")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	var stats map[string]int
	json.NewDecoder(resp.Body).Decode(&stats)
	if stats["connections"] != 0 {
		t.Fatalf("expected 0 connections, got %d", stats["connections"])
	}
}
£Þ}xºÑxÕYmoÛFþ,ýŠ	qÉQ9ŠñµE€ªð‰ã\zH|��\q0|ñZ\I¬ù–]Ò¶ø¿÷™Ù¥DÉfì½Í‡ˆ¢vwÞžyff]©é¹škÊUZ‡i^•¦¦p8t1-“´˜?ûÕ–E€³¼æ�¬œóG¡ëg‹º®ø¹´ü¿Ñs}%ßmm°Q^Úe1†øu^Vçó8-ž-UžÅßÃÑpøìí- x¯,féœ=KmI‘ÅþLÓ”¤º¤´HôU<¬—•ÞØIÍ´¦/ÃÁQÖÌ	ÿœl:e­'�ÅÛ€X¤>T®o®,ð¶])ÏX)ºýü
Òëç?P{¦(õ)MÚÕ«ïØqø~��ÞÒÂTÓv1?bÝ6äÆ:6¯](Ï§é‹<¢DÍ#ªDTDWò9¼Ò3Õd5�•e†Ã¼Íð"¿mj¿BêQÙ˜)änùHÞ¶ËÇ��JD"ÒÅEDyRØˆT’"×ë¨Ù×)b”ZªAªÆ™¾Ðý÷Å»·,�iŒ¦Yi\m,¡ïÑ°Žá‡ÛÒñ‰<xPœºè‰É¾ë(°_ÔfI‹2K3²‚œâTe�¥´¶dš¢Np[«Zwä6w„;vD{QŒ¡÷ª®µ)ÈhöÝçò[X^RV^j3UV“Êª…*š\cý�Ëj¡‹xx¡Œ¬nÏØÅ)œ)ñ»ÆÖ{e^Á‹áéÿŽÕø·�ñ�'þs|òe'zþýõ_N;yr¨ç)X²ÓÜn´JÆVÍtÔÚ9Î›Z�­RÇÖ¥Ù°zuÂÚð¼a$gj|øË»¦ÖWÃ�s7X¡:f‹‹ùÉSñŒóPÉöÓø ôÍòôP«Œýý¦9óî;Ð—²s%y
­kÎò‚t^ÕKv‡XgM1¥íáˆœìÕ	Hw£®‚žl�÷^ñ	4?×áíê�"¬ƒÂÖÒ::‡üÛµ×üE’xÝ´Y,¦CmIh§3ç
D$-.T–&„·*ãÐ,I_Á2K3SæØ"�/´W&-M
Ã­dž7;4[fŽ
„ÓÙ¼Ëx#/¦Bò£6ãwªž.Ž$X¼+>B„FL�­³@àñ>+=ƒVW	ããÏAD«-ì >œ_0ŸíîRô�>ƒ�>7©ÑÉ:Õ©ÿDa¾õ‘,Ä½¢ D‡:‚ü˜8oâ·åô< 0mH^},2÷Rçš•ç4AZÅµÇ­)'?ñ/ì 6upDÊP‰ÿLš ƒ9òUež4°Î›í‰’ÕdîèÉJH‹/Å~Í£Õ–s›«E)xd
GQàÆ„.RE�mÇû`\†Ì9ð‡…y‘eT‚rÁ*‚;A~Õ(ƒISeéÉEa>úX%üŽÊQÉgK>cÓÀ(ïÝÑ¸£™¸Øèq’Ú)ûx9¡Æé�)[�­Ö*{ÊÈ@ƒmKl-ìð»Ïñ"ÍZTlÇ—v=-}q‘™ðð•ø=¨«Ü�[Ž9AKàxKác;òŽÜÝŠ„O ŸâkÛÑÊ•¢C�ÃX4/™ZógŸ!¿‘�ýÙíö…’�ŽcG®’H÷K€O7�Ï‡õ�<ì½®3•Y-ií”ÛL‰DEÕã+#¦­½…°³„µPåµgK�6�ZêéÝCÙyä¯ÖçY¿KÜ]—ˆó{ðN’êt»—�n9ªÃ�«´-ÊÌÖÉÚzæ(ŸIÒMr9Š�-EÈo»‚h~Ü&Öž][i°&ã=n�ÐŽbßŽ°Ú†°öW'ÏëŠší‘é)¸ecÙ¶qzÛjºÓý·á`cMËšH«Èm®+|Wœó.»þ©kÐûU	¦;S›º Q(þpüI–IeÞRh;•Fl¼?†:’¾\G´Î=ŸŒ]—EÔI ·àýµ© ù©´ÃýÙÂÐm¶Ë-yÜeUÙÔ\3¥MÚ8!¢�ˆÀÑ+’!Ý¹‰Ûh)³ªÀ¼ØRˆ ‘Û%UUºHB|ÁJŸØ {Óñ‹�êòim,Ïè›Ï¸4Ïàn5—Ç»< ÇÀ yÖãálUÙ0Ð+õ¶TÉktj2ÖdøÂr‰ïßd²™¡€}%&�3ÂJÕ?luú6eÃÎ,mŒö3a‘²þ±ð�ÈTd�¸·¥ñ;q{‰ÎaB�/AK,HŽw<rÌkÊŒµ:eûXäÊØ…ÊB§Í“Ùè§{H­°I÷‹õ(™®Q2‹%¬˜1m{&°¥ë�Ðî0tp“ÉžØ¿ª $o�p6Fc …Q­S¤Ë“¶ø¦¾}dcÏÓ
:»&ÜÉ…Û.¸…‘¹uµ¿m`< ‘�[Pºã¤c`,µ†Ž\0 ²ÚšÉZxíiiïÍ‹ŸÑ•ÂFBÈ&$÷ôÔ—Yyˆã8psÑ-BçÈ³Ç
kÈ‹J¥‚3#IëÚ¡O¬yGDA¸!AVîú#lüÁ¤ùQ¥¦ËÔø¸5«b7€7Ñª¢pÍ)¬Â°ƒmÈ9� i—çObŸÉòý;ÁÇWØ,+—aìDyriÜVÕŒ•:ê‡_rSÄ£_«d×Rè¼s"¢ÜÝgà+“dßy÷çq�å…2çðêïcv  à7€ßM¤ÏæÄ:Nm�îÇÁM¨»;�éÍ‡ïér/ãIoä’Ç½è)K¤,ýëèßýT¸Þ^_‚élUVÿ‚éWÜYèÏôÔÿò¹ÁÕŠ ñ2~rÓ&ÅG¨ãB¨Çì?D;@™‘)'-w7	Hñm_ŒQ~Ÿ/-±õr»Çî€*–€À‘z€±)v%”]FBIÂK¼õe¯¯G¾68cøF`u}�aòÒÇì´Éò.—0}“G˜ÎÌ$w½5þ•vÆ‹Gß©«—KÌ#\]Ø!âíø%Ô‹è‡�Ÿ�F±[>ÁÑ7&‡ìómï=ý &•9Ü&¢\_ŸF.´G¸^kìK•â2€#	—:RåÆò–öÐ—º5«ß¦ÉCð0¸ŒdIbŒS‘‰ ð[Ç}5.­Wös•t·(áèèZ9„Ôo‡n¿¦¨ñµN¾ŠnÝBïœÂî§Á¾	Ÿ2Ârõç·œ‹ÿ¨¬Ñ¡»|‡úˆ²¬Xßúô‚KÖµ÷IÁ=Ñåú�?u‚tÓÙÄ�1Xæß›Ùù õçÄAY¿æ—áü‡ç„Ÿõ"ú$m!÷t2›AØC¬è×ÏÚÌ‡‡^Î8¾÷W4®d@º=2¨µ|+å<-ÝÕCœý„L°¾VùCh´�?´\Îj,Š=þhÉnâ«GW¡*)û¿º]jâêJ’4xªUûš:£:�J‘T1“;legacy `chains: [...]` YAML shape used by tests and the
// minimal config. The full Config struct in config.go is the canonical schema“p²“¨Œ“E<	sup    *ChainSupervisor // nil = registry-only mode (tests)“ZÂttachSupervisor wires a ChainSupervisor so admin/config/mdns adds also
// spawn per-chain indexer + graph goroutines, and removes cancel them“¡AttachSupervisor(s *ChainSupervisor) { r.sup = s }

// Add registers a chain. mDNS-sourced chains never override higher-prioritLy
// (config/env/admin) entries; admin/config/env duplicates return an error“¡³œ“¸.“B.mu.Unlock()“Kd“ï.mu.Unlock()“K(“0“{!.mu.Unlock()
		return nil
	}“©1r.mu.Unlock()““ñ?)
	if r.sup != nil {
		r.sup.start(cfg)
	}“/	t“¹	%.mu.Unlock()“Ú	,r.mu.Unlock()““
)
	if r.sup != nil {
		r.sup.stop(slug)
	}“5
““").mu.Unlock()“á“	“"³8$(	updated := entry.Config
	r.mu.Unlock()
““u@
	if r.sup != nil {
		r.sup.stop(slug)
		r.sup.start(updated)
	}“�““!Õ³÷>d using the legacy `chains: [...]`
// shape. Full Config (with brand, networks, etc.) loads via LoadC“n
“O„“Ôv“K\c.WS = os.Expand(c.WS³˜i³/a³I2“�&“¶N“ð[“‘5³�B“ÄÂ“‡Ø³`p“ÑÁ“‘Z“í-¿—‘ô½±xÅZûOÛÈþ9ù+¦–öÊì¦eY¤Fâ‡òèc/¥ÂÞGUQOÀ·Ž�µ·â¿ß93¶Ç±'$öJ­pÆ3gÎœÇwã©?úæ_I1ñÃ¸Û'Ó$Í…Ûí8—w¹Ì<Èx”a|ÕÿO–Ä4Ë¼�çSó™° §Á„×Mýüº?#I4L¯AÇént»ãY<CœÊ«0ËÓ»7Að&Žðìæâg=ÕnˆïÝN*»âXÎ÷¯Ád±À•ŽLù]êa¹Ë¯÷“x^aQç,š]„ÎhDëœÆŽý‰ä±ý—<›OOöiL8t¨A¿%#?ºN²|ðzç·Í¾¼Íû—£þ~?�ŽxþðnªˆÈ›	œ%³t„!gÄ›ÓØýF·Žñ÷bWÄaDçèäÞ[?÷#Ãx�`Nêí'³8w7hæ+sÞØuäíTŽràŸ¢'®’\ü8½j�&Åï3Uê±jøóæ�B{±m•Ñ<%4½]†íêtxO ¡Çñ�…íõ$ŠTZtöÞˆ³£ów`„ÕUèêÖ1$½Û”t%@È<IÅÿCÅ¢ s:Zx¤(Ü¢éí=¯/³‘?•O±?ùƒÈSÿF¦™l´Éóc˜epú)ÅÉžÚÊØ1!Ç‰b�¢ÄØÆÿ©œ$7ruÞ;V´ðÜ0†ž(}Ñ&pzX;Ù‹ÔÓœ¨Ó2®”þXY‰š$òD¤2Ÿ¥1ô4“êt_Ý4ÈpÕMí+Âç2!’€I£à$NbyÄ“qî¬ÌÐØ�2Év\[NpÒ&ÿóiàçÏ ÿ$"ªù¢* Á�5@îÅÀ¤ÙQJè‰}BfÙÚc9_$LC†›/ÔÑøª'’oj·w2wµ¾•à…‰Ãƒšˆ©c`p 56c™UM�¸cð$fL"&HÇ˜´‹	&lêµŒ3dBDbcZ:à,#‰ ³[M[ :V V’>Nò·t²5°¡+ÓÎZUö6ˆ*w±CÈÇƒã³“4LÒ0¿[ýÝN¿/”i‰Œ´Èýo2ÓTŽd€¬F"³°~‘3Yæu“/~;¦Õƒ�	xÙu2‹63 Aš†¶.Ål	9+r0	bŠÎ¥×ñoÃYVˆ‰ókZ°! €_Á¦ÐÇÑQ’�ëÂð-Í$^·[¶^nóõšDJ›N�†kØø�û³(§„luÃ°+ýñ¦!&U‰…8d�§”Ñ&�@�Fä˜¼‹‰‚ Vo?JüàmšLã›µ$a®Óyáàá4¸÷ß$Y>íßIÂù²¿µwË&€-8õÁ›In[€+Nð•FóèAÈ�'÷†r2=ST�1pQ˜x¿'aìbZy1E£Ì»ó'¡Q’yÿ æH¦<î‰Ï_¨&r¿ªiƒ®/9yèdBP<ÂÓ/Â` ^ïüºóšÇ€êñ°œy.™Ã@Àø—6’'&)X€Fø½Úâç_ÕÞ[››øÇƒo®µÇ³«í¿nôÄæÎö6éÔV’U°VÓËX¹aÞ’èÝÈ°o)Ý»d-¨�Õ‹F*À²ªr�zøß'Óøp@Ü³m&\|—g†La_a'. S7cÃ`©‚Ø®šµ€Š
6}ä½‘\¯¨^‡Tì/d€·f$2ðTþI>GOÀèËù©üs†G×yw8Ä*§ó
•ö4JR™öyiŸeIÁå3Ž6o’%i Ù§SÏ8+ò/ì¨¡gŽò:`ð…ýÛÔ†W†®Ô–f·sã§â2	îÐ!™~F§ÅØ?¾ëv¨B'9�è�€‹¹·‡ižúíþ��#=}Fs U¾óÅsÇQâç;ÛËë}ž½ûJsu)Ô¨XT­º(º¾X£�‚LK»�©æ’ô×ïÈƒ¢]Ðs[œ�SæBuDE)¾´mâŒ`.Å¿û¯À�%qòéìA“àîIo6ËôŒõâjö7 là½—>ã�Q°ŸÄTg½¤(JöæO§Q8òó0‰UckV²0J×¬fmßlmªDø‰ŒZý%³ñô	ˆõ{6šZÈ´ÒÕm!]¸øA `‹“ßƒ™:¢,P‚:Wâ·øãÿQú‹5ÝÛ>yVåÜÛ›¯mÎM¯¨ïò°¹:¼@¹ÎRÉ†¬˜Õ/“w]dŒ°Â4~þð£™t•kQÊÁ GJ›£ÿTÈæñµc[Ío|{ŒùÕè®Öéh†¡çêD|Z«QG8�hÀ§WG³˜zÕùrœêë‚b%°j·�¢Žx68ÓZyn“RqI�Ùòy8†b\C×–ÇE†ÔesvÒè.&,$H´–¡Õf—ë7J£¥¶%4C~ÔlŒÞÅ}ºˆy(Ôµ[�Iˆ!¦‘„51f¹AlonÛ$�WíÀÚ¹Â±Çˆ|YÞ°
0›)K;:›³W•ßrŒ~ù!ê{–fÅk«Ì8%ØCn÷ûÙ§ãçJÀj¥ò=!%2kçS‹!½wÌFë6Ry
é—8m×nx|d}�Â•úÒ¸þøÄ©r-&Pç8¤ÿPFSÜÀP¢Þ.Ó÷Ðz/#ê²5­+Ñ:rŽ?þóÃÙððxxñ÷Ã]¼Úúuû7Êupí]â®t…Xþ4Ñ ™A|#ŠWx@KF'¤¤Y¼ü€{NëÛ[L{»Võ3M,[Jv/I";]ºÂaÊôÐà—Ö¶‡ì�Q¥¾OÀƒ¼J¹`³mRE&®ï&³[ñK™¹‰L¦hñR;Âñ�H“Ýz{ÕUéµ¦2R}'NeB·j×Þ´§†r
g´çÇÙ-5·ðJ—ÂoqÅÎåµ°æ©Pl±—Q?·P!ßX‡¬º�Êù"ýïdÅ÷M–T@j!§ðÞÎ–•¢‚èŠèEˆþ5´Šz‚î¶Ý9«—‡Ù4‰3ÉAô
Sñ3%ëç6†º½›ëŠÓÝX½æ,Û‡ô™µ6<õèÖ(Çs?Ÿe¾3H¾‘KêþÈ Ò¡þ�€Úÿ|í–óç fUÁ¦’º°ÌA/‘L5óö£$“üAÅ(
Q%“qñ0ýàq8ªiÈ… 4›öøËÌRó=ºhÃ‚óÓ#ø‚Sˆ[�Û¯ÐôITÀt<{³Fé�Ðâ“ˆzÓ†Ø2h�¯ª¾®Žˆ£PGL¸èçÔ¡©d;�µ7µÚ	©…aK*ôl#ñ9hfÕI*Öj^ë",M&k-oªÃ8ÌC ü�Ñ>€w;~œ¬×b«D»ÛÁ×3hw/”U+7‰zŽît;‚m•S†Wêé_éhÃûÅ^Ž“³4ú=¶Äº:1…ð°ßÅž×Œ·l1ä­k¼ºñóW˜ïb£ó,tQV¥�/ëo©ÀÐíÌøº¾n¦º;B ´´+c9où¾‹ìLÑ²÷ßu2Ð#Ìj¬w�®ûÞ-1»ªÔ^ÂÕø&c+7^»sÙa‰[Òû1C^„E¶fËf÷œÍ£sì &Të‚å€ãÒë´©7ÑO¿¯>Ñ0µnæ{fQ0C†bô
LZÌ¹ê¤ÊHæ¸³d´Ä,ÊúsË(úƒdƒúÔnO«;ÐÄIÖÓ]©#O@ýCå·E‹ z�&f{J°,„þ ³hÃ%À”!P}¬¥ä£¢à}÷ÿ	�%l”Cx óÿ�VÆU°n³ÅX&²Eªx* Õÿ100755 healthcheck.sh }ðR hõþ÷«Ÿ�<,viÃëO=Ñ<»_xûú#!/bin/sh
# healthcheck.sh -- Check explorer health and report per-chain block counts
#
# Usage: ./scripts/healthcheck.sh [base_url]
#   base_url defaults to http://localhost:8090

set -e

BASE="${1:-http://localhost:8090}"

printf "Checking %s ...\n\n" "$BASE"

# Health endpoint
printf "%-20s " "/health"
status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null) || status="unreachable"
if [ "$status" = "200" ]; then
    printf "OK (200)\n"
else
    printf "FAIL (%s)\n" "$status"
    exit 1
fi

# Stats endpoint
printf "%-20s " "/v1/explorer/stats"
stats=$(curl -sf "$BASE/v1/explorer/stats" 2>/dev/null) || stats=""
if [ -z "$stats" ]; then
    printf "FAIL (no response)\n"
    exit 1
fi
printf "OK\n\n"

# Parse per-chain block counts from stats JSON
# Expected format: {"chains":[{"name":"...","chain_id":...,"block_count":...},...]}
printf "%-16s %-12s %s\n" "CHAIN" "CHAIN_ID" "BLOCKS"
printf "%-16s %-12s %s\n" "-----" "--------" "------"

echo "$stats" | \
    sed 's/},{/}\n{/g' | \
    sed -n 's/.*"name":"\([^"]*\)".*"chain_id":\([0-9]*\).*"block_count":\([0-9]*\).*/\1|\2|\3/p' | \
    while IFS='|' read -r name chain_id blocks; do
        printf "%-16s %-12s %s\n" "$name" "$chain_id" "$blocks"
    done

# If no chains parsed, show raw stats
if ! echo "$stats" | grep -q '"name"'; then
    printf "(single chain mode)\n"
    block_count=$(echo "$stats" | sed -n 's/.*"block_count":\([0-9]*\).*/\1/p')
    printf "%-16s %-12s %s\n" "default" "-" "${block_count:-unknown}"
fi

printf "\nDone.\n"
"ÖÊ!®xn ‘ÿ100644 icon.svg ^pý”`9«ƒ8(í=ëÙü0¸100644 index.html Ì|£}GÊRüHXóªøðyž¹DRe100644 logo.svg ¿ÛÃž—ìÿ6Ñ•£„µ_|nÉ%€h /?ì{x ãÿnn�6?z%.JÉ`½b<_o×�É’ó‘J$“ˆ'ì*x ãÿnn�6Ñ\œt¯)Á^>çy€¯i©¯xåú‘J$Á=×¶
x¦ Yÿ<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>
2.¼”xL!³Þ<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lux Explorer</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<script src="/envs.js"></script>
<style>
  :root{--bg:#0b1220;--card:#111a2c;--fg:#e6edf3;--mut:#7d8590;--ac:#5cf;--brd:#1f2a3d}
  *{box-sizing:border-box;margin:0;padding:0}
  body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;background:var(--bg);color:var(--fg)}
  a{color:var(--ac);text-decoration:none}
  a:hover{text-decoration:underline}
  header{display:flex;align-items:center;gap:16px;padding:14px 24px;border-bottom:1px solid var(--brd);background:#0a0f1c}
  header img{width:28px;height:28px}
  header h1{font-size:16px;font-weight:600}
  header .chip{margin-left:auto;padding:4px 10px;border:1px solid var(--brd);border-radius:6px;color:var(--mut);font-size:12px}
  header select{background:#0a0f1c;color:var(--fg);border:1px solid var(--brd);padding:6px 10px;border-radius:6px;font-size:13px}
  main{max-width:1200px;margin:0 auto;padding:24px}
  .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-bottom:24px}
  .card{background:var(--card);border:1px solid var(--brd);border-radius:8px;padding:16px}
  .card h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-bottom:8px}
  .card .v{font-size:22px;font-weight:600}
  section{background:var(--card);border:1px solid var(--brd);border-radius:8px;margin-bottom:24px;overflow:hidden}
  section h2{padding:14px 18px;border-bottom:1px solid var(--brd);font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:600}
  table{width:100%;border-collapse:collapse}
  th,td{padding:10px 18px;text-align:left;font-size:13px;border-bottom:1px solid var(--brd)}
  th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:600;background:#0a0f1c}
  td.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
  td.r{text-align:right}
  .err{color:#f85149;padding:16px;font-size:13px}
  .age{color:var(--mut);font-size:12px}
</style>
</head>
<body>
<header>
  <img id="logo" src="/logo.svg" alt="">
  <h1 id="brand">Explorer</h1>
  <select id="network" title="Network"></select>
  <select id="chain" title="Chain"></select>
  <span class="chip" id="chip"></span>
</header>
<main>
  <div class="grid" id="kpi"></div>
  <section><h2>Latest Blocks</h2><div id="blocks"></div></section>
  <section><h2>Latest Transactions</h2><div id="txs"></div></section>
</main>
<script>
(() => {
  const ENV = window.ENV || {};
  const BRAND = ENV.VITE_BRAND || {};
  const CHAINS = (ENV.VITE_CHAINS || []).filter(c => c.type === 'evm' || !c.type);
  const NETWORK_HOSTS = ENV.VITE_NETWORK_HOSTS || {};
  const CURRENT_NETWORK = ENV.VITE_CURRENT_NETWORK || '';
  const PATH_BASE = '/v1/indexer';
  const $ = s => document.querySelector(s);
  const fmt = n => (n == null || n === '') ? 'â€“' : Number(n).toLocaleString();
  const short = h => h ? h.slice(0,10) + 'â€¦' + h.slice(-6) : '';
  const age = t => {
    if (!t) return '';
    const s = (Date.now() - new Date(t).getTime())/1000;
    if (s < 60) return Math.round(s)+'s ago';
    if (s < 3600) return Math.round(s/60)+'m ago';
    if (s < 86400) return Math.round(s/3600)+'h ago';
    return Math.round(s/86400)+'d ago';
  };

  // Pick chain from URL hash (#cchain), localStorage, or default.
  const slugFromHash = location.hash.replace(/^#/, '').trim();
  const stored = localStorage.getItem('explorer.slug');
  const def = (CHAINS.find(c=>c.default) || CHAINS[0] || {}).slug;
  let current = CHAINS.find(c=>c.slug === slugFromHash)
              || CHAINS.find(c=>c.slug === stored)
              || CHAINS.find(c=>c.slug === def)
              || CHAINS[0];

  // Render branding.
  if (BRAND.name) $('#brand').textContent = BRAND.name;
  if (BRAND.iconUrl) $('#logo').src = BRAND.iconUrl;
  if (BRAND.accentColor) document.documentElement.style.setProperty('--ac', BRAND.accentColor);

  // Network picker â€” hidden unless we have at least two siblings.
  const netSel = $('#network');
  const labels = Object.keys(NETWORK_HOSTS);
  if (labels.length >= 2 && CURRENT_NETWORK) {
    labels.sort();
    labels.forEach(label => {
      const o = document.createElement('option');
      o.value = label;
      o.textContent = label.charAt(0).toUpperCase() + label.slice(1);
      if (label === CURRENT_NETWORK) o.selected = true;
      netSel.appendChild(o);
    });
    netSel.onchange = () => {
      const target = NETWORK_HOSTS[netSel.value];
      if (!target || target === location.host) return;
      // Preserve protocol + path + hash so the user lands on the same
      // chain/page on the sibling network.
      location.href = location.protocol + '//' + target + location.pathname + location.hash;
    };
  } else {
    netSel.style.display = 'none';
  }

  // Chain picker.
  const sel = $('#chain');
  CHAINS.forEach(c => {
    const o = document.createElement('option');
    o.value = c.slug;
    o.textContent = c.name + ' (' + (c.coin || '') + ')';
    if (current && c.slug === current.slug) o.selected = true;
    sel.appendChild(o);
  });
  sel.onchange = () => {
    current = CHAINS.find(c => c.slug === sel.value) || current;
    localStorage.setItem('explorer.slug', current.slug);
    location.hash = current.slug;
    render();
  };

  async function jget(path){
    const r = await fetch(path, {headers:{accept:'application/json'}});
    if (!r.ok) throw new Error(path + ' â†’ ' + r.status);
    return r.json();
  }

  async function loadStats(slug){
    return jget(PATH_BASE + '/' + slug + '/stats');
  }
  async function loadBlocks(slug){
    return jget(PATH_BASE + '/' + slug + '/blocks?items_count=10');
  }
  async function loadTxs(slug){
    return jget(PATH_BASE + '/' + slug + '/transactions?items_count=10');
  }

  function renderKpi(stats, chain){
    const items = [
      ['Chain ID', chain.chainId || 'â€“'],
      ['Blocks', fmt(stats.total_blocks)],
      ['Transactions', fmt(stats.total_transactions)],
      ['Addresses', fmt(stats.total_addresses)],
      ['Avg block', stats.average_block_time ? stats.average_block_time + 'ms' : 'â€“'],
      ['Gas used today', fmt(stats.gas_used_today)],
    ];
    $('#kpi').innerHTML = items.map(([k,v]) =>
      '<div class="card"><h3>'+k+'</h3><div class="v">'+v+'</div></div>'
    ).join('');
  }

  function renderBlocks(blocks){
    const rows = (blocks.items || []).map(b => {
      const ts = b.timestamp ? new Date(b.timestamp).toISOString() : '';
      return '<tr>'
        + '<td class="r">'+fmt(b.height)+'</td>'
        + '<td class="mono">'+short(b.hash)+'</td>'
        + '<td class="r">'+fmt(b.tx_count)+'</td>'
        + '<td class="age">'+age(b.timestamp)+'</td>'
        + '</tr>';
    }).join('');
    $('#blocks').innerHTML = rows
      ? '<table><thead><tr><th class="r">#</th><th>Hash</th><th class="r">Txs</th><th>Age</th></tr></thead><tbody>'+rows+'</tbody></table>'
      : '<div class="err">No blocks indexed yet.</div>';
  }

  function renderTxs(txs){
    const rows = (txs.items || []).map(t => {
      return '<tr>'
        + '<td class="mono">'+short(t.hash)+'</td>'
        + '<td class="mono">'+short(t.from && t.from.hash)+'</td>'
        + '<td class="mono">'+short(t.to && t.to.hash)+'</td>'
        + '<td class="age">'+age(t.timestamp)+'</td>'
        + '</tr>';
    }).join('');
    $('#txs').innerHTML = rows
      ? '<table><thead><tr><th>Hash</th><th>From</th><th>To</th><th>Age</th></tr></thead><tbody>'+rows+'</tbody></table>'
      : '<div class="err">No transactions indexed yet.</div>';
  }

  async function render(){
    if (!current){
      document.body.innerHTML = '<div class="err" style="padding:32px">No chains configured. Check window.ENV.</div>';
      return;
    }
    $('#chip').textContent = (current.coin || '') + ' â€¢ ' + (current.chainId || '');
    document.title = current.name + ' â€” ' + (BRAND.name || 'Explorer');
    try {
      const [s, b, t] = await Promise.allSettled([
        loadStats(current.slug),
        loadBlocks(current.slug),
        loadTxs(current.slug),
      ]);
      renderKpi(s.value || {}, current);
      renderBlocks(b.value || {});
      renderTxs(t.value || {});
    } catch (e) {
      $('#kpi').innerHTML = '<div class="err">'+e.message+'</div>';
    }
  }

  render();
  setInterval(render, 15000);
})();
</script>
</body>
</html>
ñ$|‡á`ÁZxþùÌB¡N°å
// Network host derivation. Prefer server-supplied values (v1.2.20+ go
  // binary fills VITE_CURRENT_NETWORK and VITE_NETWORK_HOSTS), fall back
  // to client-side parsing so the network switcher still works on older
  // images (v1.2.18) where /envs.js doesn't ship the fields.
  const networkFromHost = (host) => {
    host = (host || '').toLowerCase().split(':')[0];
    if (!host || host === 'localhost' || /^[0-9.]+$/.test(host)) return null;
    if (host.endsWith('.svc.cluster.local') || host.endsWith('.local')) return null;
    const m = host.match(/^(explore|explorer|api-explore|api-explorer)([.-])([a-z][a-z0-9]+)(?:-(test|testnet|dev|devnet|local))?(\.[^.]+(?:\.[^.]+)*)$/);
    if (!m) return null;
    const [, , sep, brand, envTag, tail] = m;
    const env = envTag || '';
    // sep==='.' â†’ brand-owned host (explore.zoo.network); preserve tail
    // on mainnet. sep==='-' â†’ lux.network-prefix host
    // (explore-zoo.lux.network); force tail to .network on mainnet
    // since brand-owned mainnet aliases are always *.network.
    const mainnetTail = (sep === '.' && env === '') ? tail : '.network';
    return {
      current: env || 'mainnet',
      hosts: {
        mainnet: 'explore.' + brand + mainnetTail,
        testnet: 'explorer.' + brand + '-test.network',
      },
    };
  };
  const fallback = networkFromHost(location.host) || {current:'',hosts:{}};
  cons“é
)[&& Object.keys(ENV.VITE_NETWORK_HOSTS).length
    ? ENV.VITE_NETWORK_HOSTS : fallback.hosts“8fallback.current³Qûº¶ðWï¨‹x�
põ¡NÝc°r
“˜
K
  // Client-side brand inference from hostname. We do this so the SPA
  // renders the correct brand even on older Go binaries (v1.2.18) where
  // the hostâ†’chain matcher hasn't been updated to recognize all our
  // explorer.{brand}-test.network shapes â€” when the server returns the
  // default Lux brand for what is actually a Zoo host, we override.
  const BRAND_TABLE = {
    lux:    {name:'Lux Explorer',  coin:'LUX',  accentColor:'#5cf',     iconUrl:'/icon-lux.svg',  logoUrl:'/logo-lux.svg'},
    cchain: {name:'Lux C-Chain',   coin:'LUX',  accentColor:'#5cf',     iconUrl:'/icon-lux.svg',  logoUrl:'/logo-lux.svg'},
    zoo:    {name:'Zoo Explorer',  coin:'ZOO',  accentColor:'#FCF006',  iconUrl:'/icon-zoo.svg',  logoUrl:'/logo-zoo.svg'},
    hanzo:  {name:'Hanzo Explorer',coin:'AI',   accentColor:'#0b1220'},
    pars:   {name:'Pars Explorer', coin:'PARS', accentColor:'#15803d'},
    spc:    {name:'SPC Explorer',  coin:'SPC',  accentColor:'#0ea5e9'},
    osage:  {name:'Osage Explorer',coin:'OSG',[  accentColor:'#dc2626'},
  };
  const inferredBrandKey = (() => {
    const m = (location.“/
      .match(/^(?:“*[.-]([a-z][a-z0-9]+)(?:-(?:“\ 9\./);
    return m ? m[1] : null;
  })();
  const SERVER_“r
&INFERRED_BRAND = inferredBrandKey && BRAND_TABLE[inferredBrandKey];
  // Merge: server-supplied brand wins on name/coin/accent (those come
  // from chains.yaml and are authoritative), inferred brand fills in
  // missing iconUrl/logoUrl so the per-brand SVGs always load even when
  // the chain config doesn't carry icon_url/logo_url.
  const BRAND = {
    name:        SERVER_BRAND.name && SERVER_BRAND.name !== 'Lux Explorer' ? SERVER_BRAND.name : (INFERRED_BRAND?.name || SERVER_BRAND.name || 'Explorer'),
    coin:        SERVER_BRAND.coin || INFERRED_BRAND?.coin || '',
    accentColor: SERVER_BRAND.accentColor || INFERRED_BRAND?.accentColor || '#5cf',
    iconUrl:     (SERVER_BRAND.iconUrl && SERVER_BRAND.iconUrl !== '/icon.svg') ? SERVER_BRAND.iconUrl : (INFERRED_BRAND?.iconUrl || '/icon.svg'),
    logoUrl:     (SERVER_BRAND.logoUrl && SERVER_BRAND.logoUrl !== '/logo.svg') ?E SERVER_BRAND.logoUrl : (INFERRED_BRAND?.logoUrl || '/logo.svg'),
  }³á
n// Normalize env shorthand (test â†’ testnet) so the dropdown label
    // matches the server-side current value.
    const envVCanon = env ? (env === 'test' ? 'testnet' : env === 'dev' ? 'devnet' : env) : 'mainnet“H"Canon³wõhost-inferred
  // brand, server default, or first chain. Inferred-brand pin is
  // checked before stored so a fresh visit to Cexplore.zoo.network
  // opens on Zoo, not whatever was last cached³v(inferredBrandKey &&“AinferredBrandKey)“›-³•Œ¥[ê³xó ÿ<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>
iQL¾ûx¾7AÈ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)

	// realtimeHub is the explorer's RealtimeHub. When set (via
	// AttachRealtime), the supervisor wires each per-chain EVM indexer's
	// Subscriber.OnBroadcast callback so block events flow into the hub
	// â†’ out to WebSocket + SSE subscribers. Nil = no live broadcast.
	realtimeHub *RealtimeHub
}

type chainState struct {
	cfg    ChainConfig
	cancel context.CancelFunc
	wg     sync.WaitGroup
}

// NewChainSupervisor creates a supervisor rooted at dataDir.
func NewChainSupervisor(dataDir string) *ChainSupervisor {
	return &ChainSupervisor{
		dataDir: dataDir,
		state:   make(map[string]*chainState),
	}
}

// AttachRealtime wires per-chain block events into the explorer's
// RealtimeHub. Called once from main.go after both the supervisor and
// the registry's hub exist. Subsequent chain spawns (runChain below)
// install the Subscriber.OnBroadcast callback automatically.
func (s *ChainSupervisor) AttachRealtime(h *RealtimeHub) {
	s.realtimeHub = h
}

// MountRoutes installs the /v1/indexer and /v1/graph dispatchers on mux. The
// dispatchers look up per-chain handlers from the supervisor's route maps so
// chains added at runtime become reachable without re-mounting.
func (s *ChainSupervisor) MountRoutes(mux *http.ServeMux) {
	mux.Handle("/v1/indexer/", http.HandlerFunc(s.dispatchIndexer))
	mux.Handle("/v1/explorer/", http.HandlerFunc(s.dispatchExplorerLegacy))
	mux.Handle("/v1/graph/", http.HandlerFunc(s.dispatchGraph))
}

// SetDefaultSlug picks which chain serves the unprefixed /v1/indexer/* route.
func (s *ChainSupervisor) SetDefaultSlug(slug string) {
	s.mu.Lock()
	s.defaultSlug = slug
	s.mu.Unlock()
}

// start spawns workers for cfg. Idempotent: starting an already-running slug
// is a no-op.
func (s *ChainSupervisor) start(cfg ChainConfig) {
	s.mu.Lock()
	if _, ok := s.state[cfg.Slug]; ok {
		s.mu.Unlock()
		return
	}
	ctx, cancel := context.WithCancel(context.Background())
	st := &chainState{cfg: cfg, cancel: cancel}
	s.state[cfg.Slug] = st
	if cfg.Default && s.defaultSlug == "" {
		s.defaultSlug = cfg.Slug
	}
	s.mu.Unlock()

	st.wg.Add(1)
	go func() {
		defer st.wg.Done()
		s.runChain(ctx, cfg)
	}()

	if cfg.Graph.Enabled {
		for _, sg := range cfg.Graph.Subgraphs {
			if !sg.Enabled {
				continue
			}
			sg := sg
			st.wg.Add(1)
			go func() {
				defer st.wg.Done()
				s.runSubgraph(ctx, cfg, sg)
			}()
		}
	}
}

// stop cancels and waits for all workers belonging to slug.
func (s *ChainSupervisor) stop(slug string) {
	s.mu.Lock()
	st, ok := s.state[slug]
	if !ok {
		s.mu.Unlock()
		return
	}
	delete(s.state, slug)
	if s.defaultSlug == slug {
		s.defaultSlug = ""
	}
	s.mu.Unlock()

	st.cancel()
	st.wg.Wait()

	s.indexerRoutes.Delete(slug)
	s.graphRoutes.Range(func(k, _ any) bool {
		if key, ok := k.(string); ok && strings.HasPrefix(key, slug+"/") {
			s.graphRoutes.Delete(k)
		}
		return true
	})
	log.Printf("[supervisor] stopped %s", slug)
}

// Wait blocks until ctx is done, then cancels every chain and waits for
// goroutines to drain.
func (s *ChainSupervisor) Wait(ctx context.Context) {
	<-ctx.Done()
	s.mu.Lock()
	slugs := make([]string, 0, len(s.state))
	for k := range s.state {
		slugs = append(slugs, k)
	}
	s.mu.Unlock()
	for _, slug := range slugs {
		s.stop(slug)
	}
}

// runChain runs one chain's indexer to completion or context cancel and then
// mounts the standalone explorer API once the SQLite schema is ready.
func (s *ChainSupervisor) runChain(ctx context.Context, cfg ChainConfig) {
	chainDir := filepath.Join(s.dataDir, cfg.Slug)
	if err := os.MkdirAll(chainDir, 0755); err != nil {
		log.Printf("[%s] mkdir: %v", cfg.Slug, err)
		return
	}
	store, err := idxstor.NewUnified(idxstor.DefaultUnifiedConfig(chainDir))
	if err != nil {
		log.Printf("[%s] storage: %v", cfg.Slug, err)
		return
	}
	defer store.Close()
	if err := store.Init(ctx); err != nil {
		log.Printf("[%s] storage init: %v", cfg.Slug, err)
		return
	}
	dbPath := filepath.Join(chainDir, "query", "indexer.db")
	log.Printf("[%s] indexing %s (%s) -> %s", cfg.Slug, cfg.Name, cfg.CoinSymbol, dbPath)

	go s.mountIndexerAPI(ctx, cfg, dbPath)

	switch cfg.Type {
	case "", "evm":
		poll := 30 * time.Second
		if cfg.Indexer.PollInterval != "" {
			if d, err := time.ParseDuration(cfg.Indexer.PollInterval); err == nil {
				poll = d
			}
		}
		idx, err := evm.NewIndexer(evm.Config{
			ChainName:    cfg.Name,
			ChainID:      cfg.ChainID,
			RPCEndpoint:  cfg.RPC,
			HTTPPort:     0,
			PollInterval: poll,
			StartBlock:   cfg.Indexer.StartBlock,
		}, store)
		if err != nil {
			log.Printf("[%s] evm indexer: %v", cfg.Slug, err)
			return
		}
		// Live broadcast bridge â€” when the supervisor has been given a
		// RealtimeHub (see AttachRealtime), every block the indexer
		// commits is re-published into the hub on the chain's slug. The
		// hub then fans out to WebSocket and SSE subscribers (see
		// realtime.go + realtime_sse.go). slug captured by value so the
		// closure doesn't see a mutated `cfg` if the supervisor loops.
		if s.realtimeHub != nil && idx.Subscriber() != nil {
			slug := cfg.Slug
			hub := s.realtimeHub
			idx.Subscriber().OnBroadcast = func(eventType string, data any) {
				hub.Broadcast(eventType, slug, data)
			}
		}
		if err := idx.Init(ctx); err != nil {
			log.Printf("[%s] evm init: %v", cfg.Slug, err)
			return
		}
		if err := idx.Run(ctx); err != nil && ctx.Err() == nil {
			log.Printf("[%s] evm run: %v", cfg.Slug, err)
		}
	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.
//
// Edge case the older code got wrong: when the first path segment matches
// the slug regex but is NOT a registered chain (e.g. `main-page` in
// `/v1/indexer/main-page/blocks`), splitSlug peels it off as a "slug" and
// the dispatcher then forwarded only the REMAINING segments to the
// default chain. That turned `/v1/indexer/main-page/blocks` into
// `/v1/indexer/evm/blocks` server-side â€” a completely different endpoint
// with a different response shape (paginated envelope vs. raw array). The
// SPA's `?.slice(0,8)` on the home page's Latest Blocks widget tripped
// over the envelope and threw "o.slice is not a function".
//
// Fix: when the parsed slug isn't a real chain, re-join it with the rest
// before delegating, so the unrecognized segment is preserved as part of
// the path under the default chain.
func (s *ChainSupervisor) dispatchIndexer(w http.ResponseWriter, r *http.Request) {
	slug, rest, ok := splitSlug(r.URL.Path, "/v1/indexer/")
	if !ok {
		s.serveDefault(w, r, "")
		return
	}
	if h, found := s.indexerRoutes.Load(slug); found {
		h.(http.Handler).ServeHTTP(w, r)
		return
	}
	fullTail := slug
	if rest != "" {
		fullTail = slug + "/" + rest
	}
	s.serveDefault(w, r, fullTail)
}

// 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
}
«ÿÉàŠ£x  ßÿºº°}èx¹€&š’Œ¯û/IÁôi-‰+#“‘)ì»âæŠ£Fxœf ™ÿºº°³@ø½°á�ÒlßËD“ÚxŽÅÑ<³100644 frontend_test.go OŽ«XÞ“æís¤…>ð‰[¾%q³óŠÃ2ÙØ¥µ=ðÌÓý=I˜“‘)‡�.®xn ‘ÿ100644 icon.svg ^pý”`9«ƒ8(í=ëÙü0¸100644 index.html !"ò±¤Ðƒ‡Îñ¯'ïêXa˜—100644 logo.svg ¿ÛÃž—ìÿ6Ñ•£„µ_|nÉ%€<†.m¾Òx.Ñâ<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lux Explorer</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<script src="/envs.js"></script>
<style>
  :root{--bg:#0b1220;--card:#111a2c;--fg:#e6edf3;--mut:#7d8590;--ac:#5cf;--brd:#1f2a3d}
  *{box-sizing:border-box;margin:0;padding:0}
  body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;background:var(--bg);color:var(--fg)}
  a{color:var(--ac);text-decoration:none}
  a:hover{text-decoration:underline}
  header{display:flex;align-items:center;gap:16px;padding:14px 24px;border-bottom:1px solid var(--brd);background:#0a0f1c}
  header img{width:28px;height:28px}
  header h1{font-size:16px;font-weight:600}
  header .chip{margin-left:auto;padding:4px 10px;border:1px solid var(--brd);border-radius:6px;color:var(--mut);font-size:12px}
  header select{background:#0a0f1c;color:var(--fg);border:1px solid var(--brd);padding:6px 10px;border-radius:6px;font-size:13px}
  main{max-width:1200px;margin:0 auto;padding:24px}
  .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-bottom:24px}
  .card{background:var(--card);border:1px solid var(--brd);border-radius:8px;padding:16px}
  .card h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-bottom:8px}
  .card .v{font-size:22px;font-weight:600}
  section{background:var(--card);border:1px solid var(--brd);border-radius:8px;margin-bottom:24px;overflow:hidden}
  section h2{padding:14px 18px;border-bottom:1px solid var(--brd);font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:600}
  table{width:100%;border-collapse:collapse}
  th,td{padding:10px 18px;text-align:left;font-size:13px;border-bottom:1px solid var(--brd)}
  th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:600;background:#0a0f1c}
  td.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
  td.r{text-align:right}
  .err{color:#f85149;padding:16px;font-size:13px}
  .age{color:var(--mut);font-size:12px}
</style>
</head>
<body>
<header>
  <img id="logo" src="/logo.svg" alt="">
  <h1 id="brand">Explorer</h1>
  <select id="chain"></select>
  <span class="chip" id="chip"></span>
</header>
<main>
  <div class="grid" id="kpi"></div>
  <section><h2>Latest Blocks</h2><div id="blocks"></div></section>
  <section><h2>Latest Transactions</h2><div id="txs"></div></section>
</main>
<script>
(() => {
  const ENV = window.ENV || {};
  const BRAND = ENV.VITE_BRAND || {};
  const CHAINS = (ENV.VITE_CHAINS || []).filter(c => c.type === 'evm' || !c.type);
  const PATH_BASE = '/v1/indexer';
  const $ = s => document.querySelector(s);
  const fmt = n => (n == null || n === '') ? 'â€“' : Number(n).toLocaleString();
  const short = h => h ? h.slice(0,10) + 'â€¦' + h.slice(-6) : '';
  const age = t => {
    if (!t) return '';
    const s = (Date.now() - new Date(t).getTime())/1000;
    if (s < 60) return Math.round(s)+'s ago';
    if (s < 3600) return Math.round(s/60)+'m ago';
    if (s < 86400) return Math.round(s/3600)+'h ago';
    return Math.round(s/86400)+'d ago';
  };

  // Pick chain from URL hash (#cchain), localStorage, or default.
  const slugFromHash = location.hash.replace(/^#/, '').trim();
  const stored = localStorage.getItem('explorer.slug');
  const def = (CHAINS.find(c=>c.default) || CHAINS[0] || {}).slug;
  let current = CHAINS.find(c=>c.slug === slugFromHash)
              || CHAINS.find(c=>c.slug === stored)
              || CHAINS.find(c=>c.slug === def)
              || CHAINS[0];

  // Render branding.
  if (BRAND.name) $('#brand').textContent = BRAND.name;
  if (BRAND.iconUrl) $('#logo').src = BRAND.iconUrl;
  if (BRAND.accentColor) document.documentElement.style.setProperty('--ac', BRAND.accentColor);

  // Chain picker.
  const sel = $('#chain');
  CHAINS.forEach(c => {
    const o = document.createElement('option');
    o.value = c.slug;
    o.textContent = c.name + ' (' + (c.coin || '') + ')';
    if (current && c.slug === current.slug) o.selected = true;
    sel.appendChild(o);
  });
  sel.onchange = () => {
    current = CHAINS.find(c => c.slug === sel.value) || current;
    localStorage.setItem('explorer.slug', current.slug);
    location.hash = current.slug;
    render();
  };

  async function jget(path){
    const r = await fetch(path, {headers:{accept:'application/json'}});
    if (!r.ok) throw new Error(path + ' â†’ ' + r.status);
    return r.json();
  }

  async function loadStats(slug){
    return jget(PATH_BASE + '/' + slug + '/stats');
  }
  async function loadBlocks(slug){
    return jget(PATH_BASE + '/' + slug + '/blocks?items_count=10');
  }
  async function loadTxs(slug){
    return jget(PATH_BASE + '/' + slug + '/transactions?items_count=10');
  }

  function renderKpi(stats, chain){
    const items = [
      ['Chain ID', chain.chainId || 'â€“'],
      ['Blocks', fmt(stats.total_blocks)],
      ['Transactions', fmt(stats.total_transactions)],
      ['Addresses', fmt(stats.total_addresses)],
      ['Avg block', stats.average_block_time ? stats.average_block_time + 'ms' : 'â€“'],
      ['Gas used today', fmt(stats.gas_used_today)],
    ];
    $('#kpi').innerHTML = items.map(([k,v]) =>
      '<div class="card"><h3>'+k+'</h3><div class="v">'+v+'</div></div>'
    ).join('');
  }

  function renderBlocks(blocks){
    const rows = (blocks.items || []).map(b => {
      const ts = b.timestamp ? new Date(b.timestamp).toISOString() : '';
      return '<tr>'
        + '<td class="r">'+fmt(b.height)+'</td>'
        + '<td class="mono">'+short(b.hash)+'</td>'
        + '<td class="r">'+fmt(b.tx_count)+'</td>'
        + '<td class="age">'+age(b.timestamp)+'</td>'
        + '</tr>';
    }).join('');
    $('#blocks').innerHTML = rows
      ? '<table><thead><tr><th class="r">#</th><th>Hash</th><th class="r">Txs</th><th>Age</th></tr></thead><tbody>'+rows+'</tbody></table>'
      : '<div class="err">No blocks indexed yet.</div>';
  }

  function renderTxs(txs){
    const rows = (txs.items || []).map(t => {
      return '<tr>'
        + '<td class="mono">'+short(t.hash)+'</td>'
        + '<td class="mono">'+short(t.from && t.from.hash)+'</td>'
        + '<td class="mono">'+short(t.to && t.to.hash)+'</td>'
        + '<td class="age">'+age(t.timestamp)+'</td>'
        + '</tr>';
    }).join('');
    $('#txs').innerHTML = rows
      ? '<table><thead><tr><th>Hash</th><th>From</th><th>To</th><th>Age</th></tr></thead><tbody>'+rows+'</tbody></table>'
      : '<div class="err">No transactions indexed yet.</div>';
  }

  async function render(){
    if (!current){
      document.body.innerHTML = '<div class="err" style="padding:32px">No chains configured. Check window.ENV.</div>';
      return;
    }
    $('#chip').textContent = (current.coin || '') + ' â€¢ ' + (current.chainId || '');
    document.title = current.name + ' â€” ' + (BRAND.name || 'Explorer');
    try {
      const [s, b, t] = await Promise.allSettled([
        loadStats(current.slug),
        loadBlocks(current.slug),
        loadTxs(current.slug),
      ]);
      renderKpi(s.value || {}, current);
      renderBlocks(b.value || {});
      renderTxs(t.value || {});
    } catch (e) {
      $('#kpi').innerHTML = '<div class="err">'+e.message+'</div>';
    }
  }

  render();
  setInterval(render, 15000);
})();
</script>
</body>
</html>
W×4ç®8xŽqü40000 .github ó;:ø5¾ŒÌ„¡Û,
�ö4‰8µ100644 .gitignore ¬ž'ê	‰×Ã€Ê!6ÐÂœã�¶>100644 CLAUDE.md h2øq–!8­]~Ó&o QËÀ100644 Dockerfile i†ö÷Â0]ÃÏpZp¾ÀøZ•100644 LICENSE þ<¬:¸Mµ¶¥dŽPRÉø›100644 LICENSING.md __"]À+Ò°?�ãÿ¯®�×YÙ120000 LLM.md hëœôSÐúÝóªÊìsWé{¨é100644 README.md oƒ&;bk…èÉÜ›ƒÛV›vÿ4100644 chains.example.yaml Ò[$rQ”³j¦Ù“ÄU!ÆÐ100644 compose.yml àÓõ>”)S¢7>ðr¹Âš çÑ100644 config.go Ø½#N!,¾y«¾˜º¢¡ªä^Ÿ‘R100644 frontend.go X(´ÊýÈëãñqÅ-µÆŠ
]100644 go.mod AÛÖ•ý3öhÌ�Ð(¥‚±qÂm100644 go.sum |Sï…)B’(2—(ŽÆÚ‚Ü¹I100644 main.go ßGª/× Ïsý´÷ÛÜ�®Ìµ100644 mdns.go _Öˆm2mÀWw1<&vÜ¢XN°100644 realtime.go zY¥‘Ÿ¯ª»W/rb4gÓŒÝë100644 realtime_sse.go Z*‚ø’Ú¶%éáe�&…Ê100644 realtime_test.go ˆãá2ƒ¸-£«ÒðCŒìGãMË"100644 registry.go ÷o:Åº±Z©jø¬šß+F)p�L100644 registry_test.go úäYs²¸5ÀZ ö½C±40000 scripts ÞB:¤ôþºN~=w$�=„ˆ^+E²40000 static 0ûÄûƒÁ�1~ótHŠãT×4 ©100644 supervisor.go �O[>LbgXá)�	i}b ²�kì†x¼ CÿŽº�¨Áu¤²Á*tRzY›ßn5’‘“ù%Mµ¦•ÁÑïw8-’H³ †ï100644 frontend.go bgP6
®áoJ)Âi™PÿZb100644 frontend_test.go Ìß¤ü[Í§r`!¸¡GøÇe}100644 go.mod AÛÖ•ý3öhÌ³ßrÃ2ÙØ¥µ=ðÌÓý=I˜“e)©,M}í€KxM ²ÿºº°³@ý>Ø)HnñÚ•è¶Ä`­é,¨100644 frontend_test.go >Â<ì„)>Ý¡�y×Í¥8�Õu³óÇ+8"ï\x àÿºº�Y½`ŸÚîþÙ(z52æv#ºècæ±mMæjóá-x! Þÿºº°E5ug¡)BLF±–9—,q¤…¦Þ³¥Ä;»ÿxû'Ø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.
//
// SPA override: if cfg.StaticDir (or $EXPLORER_STATIC_DIR) points at an
// existing directory, file lookups prefer the on-disk copy over the
// embedded FS. This lets a ConfigMap-mounted SPA replace the embedded
// 60-byte placeholder without a binary rebuild â€” useful when the
// upstream `explore` Next.js app hasn't shipped a static export yet but
// we want a richer in-cluster default than `<div id="root"></div>`.
type Frontend struct {
	cfg      Config
	registry *ChainRegistry
	root     fs.FS    // embedded SPA (always present)
	overlay  fs.FS    // disk overlay (nil if cfg.StaticDir not set)
	index    []byte   // resolved index.html (overlay > embedded > builtin)
}

// 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
	}
	f := &Frontend{cfg: cfg, registry: r, root: root}

	dir := cfg.StaticDir
	if dir == "" {
		dir = os.Getenv("EXPLORER_STATIC_DIR")
	}
	if dir != "" {
		if st, err := os.Stat(dir); err == nil && st.IsDir() {
			f.overlay = os.DirFS(dir)
		}
	}

	if data := f.readStatic("index.html"); data != nil {
		f.index = data
	} else {
		f.index = []byte("<!doctype html><title>Explorer</title>")
	}
	return f, nil
}

// readStatic returns the bytes for `name` from the overlay if present,
// otherwise from the embedded FS. Returns nil if neither has the file.
func (f *Frontend) readStatic(name string) []byte {
	if f.overlay != nil {
		if data, err := fs.ReadFile(f.overlay, name); err == nil {
			return data
		}
	}
	if data, err := fs.ReadFile(f.root, name); err == nil {
		return data
	}
	return 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).
//
// HEAD-method note: the bundled SPA opens EventSource (Server-Sent Events)
// channels for live block / token / tx streams and pre-flights each URL
// with a HEAD request:
//
//	fetch(url, {method: 'HEAD'}).then(r => r.ok ? openEventSource() : disable());
//
// If we serve a 200 (because of the SPA fallback to index.html), the SPA
// proceeds to open the EventSource â€” which then immediately aborts because
// the response body is text/html, not text/event-stream. Browser logs
// "EventSource's response has a MIME type ('text/html') that is not
// 'text/event-stream'" for every channel on every page load â€” noisy and
// disables the channel anyway.
//
// Fix: return 404 for HEAD requests on the SPA-fallback path. Real assets
// (anything in `f.root`) still serve normally on HEAD; only the index.html
// fallback gets the 404 so the SSE pre-flight cleanly fails and the SPA
// disables that channel on its own. Visible browser load is unchanged
// because the SPA only HEAD-probes its SSE endpoints, never the SPA URL
// itself.
func (f *Frontend) handleSPA(w http.ResponseWriter, r *http.Request) {
	p := strings.TrimPrefix(r.URL.Path, "/")
	if p == "" {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		f.writeIndex(w)
		return
	}
	// Overlay wins if it has the file (any non-directory entry); falls
	// back to embedded; falls back to index.html (SPA-routing).
	if f.overlay != nil {
		if file, err := f.overlay.Open(p); err == nil {
			st, statErr := file.Stat()
			file.Close()
			if statErr == nil && !st.IsDir() {
				http.ServeFileFS(w, r, f.overlay, p)
				return
			}
		}
	}
	file, err := f.root.Open(p)
	if err != nil {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		f.writeIndex(w)
		return
	}
	defer file.Close()
	stat, err := file.Stat()
	if err != nil || stat.IsDir() {
		if r.Method == http.MethodHead {
			http.NotFound(w, r)
			return
		}
		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 on-disk overlay (if configured)
// then the embedded SPA's named asset.
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 := f.readStatic(fallback); data != 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.
//
// Matches any of these hostname shapes, in order of specificity:
//   - "{slug}"                             â€” bare slug (curl localhost)
//   - "{slug}.tld..."                      â€” {slug}.lux.network, {slug}.hanzo.ai
//   - "explore-{slug}.tld..."              â€” explore-hanzo.lux.network
//   - "api-explore-{slug}.tld..."          â€” api-explore-hanzo.lux.network
//   - "explore.{slug}.tld..."              â€” explore.hanzo.ai, explore.zoo.ngo
//   - "api-explore.{slug}.tld..."          â€” api-explore.hanzo.ai
//   - "explore-{slug}-{env}.tld..."        â€” explore-hanzo-test.lux.network
//   - "api-explore-{slug}-{env}.tld..."    â€” api-explore-hanzo-dev.lux.network
//
// Per-chain Brand wins over BrandDefault; first match wins on ambiguous
// hosts.
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
		}
		slug := c.Slug
		if host == slug ||
			strings.HasPrefix(host, slug+".") ||
			strings.HasPrefix(host, "explore-"+slug+".") ||
			strings.HasPrefix(host, "explore-"+slug+"-") ||
			strings.HasPrefix(host, "api-explore-"+slug+".") ||
			strings.HasPrefix(host, "api-explore-"+slug+"-") ||
			strings.HasPrefix(host, "explore."+slug+".") ||
			strings.HasPrefix(host, "api-explore."+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)
}
æaóõ»áxäépackage main

import (
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// TestFrontendStaticOverlay verifies that NewFrontend prefers the on-disk
// static dir over the embedded FS when cfg.StaticDir points at a real
// directory. The overlay use-case: ConfigMap-mounting a richer SPA shell
// into an existing image without rebuilding.
func TestFrontendStaticOverlay(t *testing.T) {
	dir := t.TempDir()
	want := "<!doctype html><title>OVERLAY</title>"
	if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(want), 0644); err != nil {
		t.Fatal(err)
	}

	cfg := Config{StaticDir: dir, BrandDefault: Brand{Name: "Test"}}
	f, err := NewFrontend(cfg, NewChainRegistry())
	if err != nil {
		t.Fatal(err)
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	req := httptest.NewRequest("GET", "/", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "OVERLAY") {
		t.Fatalf("expected overlay index.html, got %q", got)
	}
}

// TestFrontendNoOverlay verifies that with no StaticDir, the embedded
// index.html is served (current behaviour â€” pre-overlay).
func TestFrontendNoOverlay(t *testing.T) {
	cfg := Config{BrandDefault: Brand{Name: "Test"}}
	f, err := NewFrontend(cfg, NewChainRegistry())
	if err != nil {
		t.Fatal(err)
	}
	if f.overlay != nil {
		t.Fatal("expected nil overlay when StaticDir empty")
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	req := httptest.NewRequest("GET", "/", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "doctype html") {
		t.Fatalf("expected fallback index.html, got %q", got)
	}
}

// TestFrontendOverlayMissingFileFallsBack covers the case where the
// overlay dir exists but a particular file is missing â€” the request
// falls through to the embedded FS (and ultimately to index.html for
// SPA-routing).
func TestFrontendOverlayMissingFileFallsBack(t *testing.T) {
	dir := t.TempDir()
	// Only index.html in overlay; no other assets.
	if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html>OVERLAY"), 0644); err != nil {
		t.Fatal(err)
	}

	cfg := Config{StaticDir: dir, BrandDefault: Brand{Name: "Test"}}
	f, err := NewFrontend(cfg, NewChainRegistry())
	if err != nil {
		t.Fatal(err)
	}

	mux := http.NewServeMux()
	f.Mount(mux)

	// Request a path the overlay doesn't have. With SPA-routing, the
	// handler returns index.html â€” which IS in the overlay.
	req := httptest.NewRequest("GET", "/some/spa/route", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	got := rec.Body.String()
	if !strings.Contains(got, "OVERLAY") {
		t.Fatalf("expected overlay index.html (SPA fallback), got %q", got)
	}
}

// TestFrontendEnvStaticDir verifies $EXPLORER_STATIC_DIR is the env-var
// equivalent of cfg.StaticDir. Empty cfg + env-var-set overlay should
// still pick up the overlay.
func TestFrontendEnvStaticDir(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("ENV-OVERLAY"), 0644); err != nil {
		t.Fatal(err)
	}
	t.Setenv("EXPLORER_STATIC_DIR", dir)

	cfg := Config{BrandDefault: Brand{Name: "Test"}}
	f, err := NewFrontend(cfg, NewChainRegistry())
	if err != nil {
		t.Fatal(err)
	}
	if f.overlay == nil {
		t.Fatal("expected overlay populated from env var")
	}
	if !strings.Contains(string(f.index), "ENV-OVERLAY") {
		t.Fatalf("expected env-overlay index, got %q", string(f.index))
	}
}

// TestBrandStructHostMatching covers every hostname shape the unified
// explorer's IngressRoutes hand it: the per-chain brand must win over
// BrandDefault for both UI hosts (explore-{slug}, explore.{slug}.tld)
// and API hosts (api-explore-{slug}). The matcher must NOT bleed across
// chains â€” a hanzo host must not pick zoo's brand or vice versa.
func TestBrandStructHostMatching(t *testing.T) {
	hanzoBrand := Brand{Name: "Hanzo", Coin: "AI", AccentColor: "#0b1220"}
	zooBrand := Brand{Name: "Zoo", Coin: "ZOO", AccentColor: "#7c3aed"}
	cfg := Config{
		BrandDefault: Brand{Name: "Lux Explorer"},
		Chains: []ChainConfig{
			{Slug: "cchain", Brand: &Brand{Name: "Lux C-Chain", Coin: "LUX"}},
			{Slug: "hanzo", Brand: &hanzoBrand},
			{Slug: "zoo", Brand: &zooBrand},
		},
	}
	f, err := NewFrontend(cfg, NewChainRegistry())
	if err != nil {
		t.Fatal(err)
	}

	cases := []struct {
		host string
		want string
	}{
		// Bare slug + slug-prefix shape.
		{"hanzo", "Hanzo"},
		{"hanzo.lux.network", "Hanzo"},
		// explore-{slug} prefix shape (lux.network UI hosts).
		{"explore-hanzo.lux.network", "Hanzo"},
		{"explore-hanzo-test.lux.network", "Hanzo"},
		{"explore-hanzo-dev.lux.network", "Hanzo"},
		{"explore-zoo.lux.network", "Zoo"},
		// api-explore-{slug} prefix shape (lux.network API hosts).
		{"api-explore-hanzo.lux.network", "Hanzo"},
		{"api-explore-hanzo-test.lux.network", "Hanzo"},
		{"api-explore-zoo.lux.network", "Zoo"},
		// explore.{slug}.tld shape (brand-owned domains).
		{"explore.hanzo.ai", "Hanzo"},
		{"explore.hanzo.network", "Hanzo"},
		{"explore.zoo.network", "Zoo"},
		{"explore.zoo.ngo", "Zoo"},
		{"api-explore.hanzo.ai", "Hanzo"},
		{"api-explore.zoo.ngo", "Zoo"},
		// Mismatched hosts fall back to BrandDefault.
		{"explore.lux.network", "Lux Explorer"},
		{"unknown.example.com", "Lux Explorer"},
		// Cross-contamination guard: explore-spcfoo must NOT match spc.
		{"explore-spcfoo.lux.network", "Lux Explorer"},
	}

	for _, tc := range cases {
		t.Run(tc.host, func(t *testing.T) {
			got := f.brandStruct(tc.host).Name
			if got != tc.want {
				t.Fatalf("host=%q: got brand=%q, want %q", tc.host, got, tc.want)
			}
		})
	}
}
HÁ<Ål‡†
x óÿ¡#¡#�*3±+v‹Úg‹ÌMx øÿ§±°±+Úêûcx: ÅÿûO£G°Ü“¤“ù¤if “À!c.Slug+".") || host == c.Slug³#"Ø�‹Ìg¬#x øÿ›,Ÿ°Ÿ.ÞlŠžx óÿ²$Ô#°³bÐÏyâyxÒ -ÿ£G¤;°_“	P
	index    []byte“è*idx, err := fs.ReadFile(root, "index.html"“d“<@“õ+, index: idx},³	 ³W?embedded SPA's named asset if no
// disk override is configured³¢Œ“¼fallback); err =³PS	ÍD¢®xn ‘ÿ100644 icon.svg ^pý”`9«ƒ8(í=ëÙü0¸100644 index.html §Ož;ùÁ@’6‰w4²ÑLÏ100644 logo.svg ¿ÛÃž—ìÿ6Ñ•£„µ_|nÉ%€Ê¸,(³xcœþ<!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>
P{pÛé€ŒExI ¶ÿŽÇ�O‘tk“Ô6…³°ê7i»û£^Ë¤,ÍŒ™A‡100644 go.sum 1)7|í¡˜?.2s£�‘®ÅŸ³ƒji=îYxœ;ÎÖÇ>Á_ÎÙÇ1ÔÅU/7…!Ãè‡pá4E¦µ±u—åÕòž>0Ñ£HÑÈ |||ÁŠ„_Ïù|á×ÝÏ«N½)Y½âåÆ�}L dý!ï6‡Ž/xo�ü¡#“!�*3‘+h0.1.0“Ñ‘«	!0.0.0-00010101000000-000000000000“Ñ‘ê
0‘õˆQProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a8“!
“}š!decred/dcrd/dcrec/secp256k1/v4 v4“`“Hx2.5“A³Ü½replicate v0.6“Á“T10801064154-80525e88d958“A“¡5“qib/pq v1.12“Q“]<0.7“q ge v1“`“À“ä-“<]ontainer v0.0.4“q“˜“Õ database v1.17.44“q
ids v1.2.9“q“	“R$“_	 “À“r:“R!“Í02“Q!“	/v4 v4.9“Amagefile/mage v1.1“a“E	S4“�
“€	“»	“€
“ã	*“›
2“Á“È
�ierrec“Ã(“®“‡t7.5“A“®procfs v0.19““I51127171934-4e34e03a991a“A“˜Qsuperfly/ltx v0.5“±“2“±“n3’^2’$otel/metric v1.42’$“¸2“ÈF2 v2.4.3“€crypto v0.49“ñ“Z312153236-7ab1446f8b90“€#4“ñ	net v0.52“ñ“é-2“ñ
text v0.35“ñ“[
3“fJ“ÕA0“h“3[48.0 // indirect
)

replace (“Ñgraph => ../graph“Ñindexer => ../indexer
)
"Y6ì…x, Óÿ“!‘°{“æ›³¿Ž“x²rypto v1.17.45“�³…	€=ì;x, Óÿ‘ô�“1.2.0“ ‘¬1.4.1±Õ‹9“`“³ƒ	<mÓê:x* Õÿô¹�¸5-0.20260524134132-0c0ba56e32d7±¹bª1â¦†ó,xb�å÷›°‡°nProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 h1:TBzelXBdnzDy+HCrBMcomEnhrmigkWOI1/mIPCi2u4M=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQk³``“m“4ö00 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc“Z10³ŒP22.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug“¾!92.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0“`#“Ð"³‚"­“}126.0 h1:YxjIynlo3iKtpf6GeQngeB5RbexIKPPKsZIoq5EHe3E“1“}196.0/go.mod h1:PBvHNQ7cXBJejTWDWxXWvtUwjT5O66pTC7npxLWu8H0“q2+³B2eG10801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE“î3*10801064154-80525e88d958“04\05 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE“¼4&5“ã4D³ß6Ž01 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow“�91“¸9û20.7 h1:ksHieAp50umwqxqgyHk9WiOmXM54kia3IEDb5H7FsM8“!=Baccel v1.0.7/go.mod h1:iZD3oxffiMEIT/KvzD8bgwC/cBn4AYlMW3QJpbRa4RE“!=“M;24.0 h1:tU5q65RQSQdaVq64Z/DVUhiPQMoMPQT6pr41LsvG0AQ“!=“M;4“¡;K“ß<“�<™“*>³æ=V03 h1:bcqUO8twMOhHWCgN/RbjfWulVPbvUQcxDkssaRrCx4g“!=“/?73/go.mod h1:ENkJ121cmDEkwQPDiKK4QhnTnW9u37PGpepbrdVcAmc“!=“,@“è?¨00 h1:VtH6kvZrCEjCnkHPkhExDU+GJ0ZulYX4rnA+lXJdJHw“Á@ 70/go.mod h1:ee525i8Recbpb0jVTDZYZBr1MmvJ27OITJHZ/nlNMBw“!=“~A37.44 h1:hfiTls7sqbweW+o4iaZqB8P997paC+vpgWmhN6v5MJ0“!=“~A:7.44/go.mod h1:6Ey5y3I0WNLHbxIlIdFqUuKfBg+b0fAgTA8FgRgQ8zg“!=;geth v1.16.69 h1:CHO6xTZ+A+3itk94ts4uyVRJajNVP3RxWTjJp5qGOl“ñIBgeth v1.16.69/go.mod h1:8eEO1hW5sa6OH2VeCMaCPnRz28JBxFvCPBCWPLsU2c“ñI“|B00 h1:jwOXnlDGfx2TjqE5GhP5T3Rv9cGh7jHSfV6DceggmAc“!=“|B70/go.mod h1:bf35zaU3Nm5tiJj1d5SoZP8DYYJl4aCVp32rOY5vj2w“!=“C	09 h1:+yjdhXW99drnd2Zlp1u/p8k3G23W3/1btJQ4ogHawUI“!=“C	79/go.mod h1:khJOEdOPxd22yn0jcVrnbX1ADa0GHn5Y74gvCzN5BYc“!=“ÀC04 h1:r/c47r6WEK+EfkTeYCj3tWKTH5dUWQauuLUmK/jDvBw“!=“ÀC4“ZDI“ÀCC4105612-65220f1104cd h1:s34y2+uPyyqsXuJKWQu5bL2Y1sWevkBJr2xF7eRkrFc“!=“ÀC4105612-65220f1104c“YDJ“ÀCC4130605-aade4dd9fd4c h1:OCaB/KxgU8hrKtIBMwSoA8mt2Pf8B+23UhKQf0EbRDo“!=“ÀC4130605-aade4dd9fd4c“ZDI“ÀCC4134132-0c0ba56e32d7 h1:vgt+Q5m+lJzLLIuErWkjg2lbbKE3wtoZD9X41MoRokg“!=“ÀC4134132-0c0ba56e32d7“ZDR01 h1:rIfFRodb9jrD/w7KayaUk0Oc+37PaQQdKEEMJCjR8gw“!=“ïD	61/go.mod h1:64IE3xRMJcpkQwnPUfJw3pDj7wU0kRS7BZ9wM7R72j“ñI“�E12.4 h1:iVz7s0gToCNUU/2cLT8gUa8MLzc0YRp4jBmeXBeKdX“ñI“�E92.4/go.mod h1:i+Am9YvFsqDE75ZW8MPE62XCXGukXNiN/7mD9SKxxZY“!F"“òE¤/0 h1:VB3mQcETc9j5SY1S6lAgFtuGr/rjWuDgPYnxS+OKWM“ F“�F	60/go.mod h1:/3dheKVjUk2yiS/ocH1IDzeLXOIe+kpVsErIGDOZdi“ F“.G01 h1:6tVarXMnNR3Xzud8FYUHjtXabTll3HI/OEqG0tgLAcI“!F“.G71/go.mod h1:PkD4D4JoGuyKtfUkqPNYkrg9xKrJeNVZFdW/5XvAe/A“!F“ H
“ÝG–“I²03 h1:yDEuuJ6bm9H8DzOzzveXI1AZlu/AeSpHNRrY1akbyc0“!=@utils v1.1.3/go.mod h1:lskchZiYYRBFsSyYg3NWLPw211uRyxIs3/wAzrfSI“ðI9zap v0.2.1 h1:5uU3Z47Nu/FNcLqYeKZ4B0f+0ZDF79/2M/9FRN/1/Hw“!=@zap v0.2.1/go.mod h1:CobyEg6MNO6qZ5wn3kVEw6QCxFVZvvGMnEKsC5sJZtI“!=>zapdb/v4 v4.9.3 h1:KV1DrbZqtZsZvrWyPw5D8WHR0DxzqW2jHjqx6obxgoM“!=zapdb/v4 v4.9.3/go.mod h1:BrpLaDeYy+smON4ASDPCFKWrQ58CXhy3WNEPGna7vuU=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mar“›K×04 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE“ÿL“¸L4“ÉLY/0 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE“¡L“hM60/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3“¡L“N	³ËM“%R22.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o“R92.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc³oR£ierrec“Ñ0cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4³«V@“ºY³fYF17.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI“ZCommon v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw“�\"319.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws“�\":19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw“]]+G51127171934-4e34e03a991a h1:r4YWl0uVObCbBFvj1VsIlyHzgZwZOHvY1KdRaQjzzUc“]]+51127171934-4e34e03a991a³ ]é“~a³ bPsuperfly/ltx v0.5.1 h1:vGUeBhKvBKZ2s2TsTrOSahz+m0PswfqOWDAD+ICiiYY=
github.com/superfly/ltx v0.5.1/go.mod h1:Nf50QAIXU/ET4ua3AuQ2fh31MbgNQZA7r/DYx6Os77s“9d#22.1 h1:J4X2hrGzJvt+wqltuvcSjHQ7ujQxA9gb6PeMs4qlUWs“9d#92.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ“Že 23.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw“Že 93.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg“Že?nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ“ŽeFnottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo³!hi22.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho“iBotel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc“i“*l12.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/“i“*l92.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI“i“8m22.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY“i“8m92.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc³€mL03 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0“ün63/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s“q“op“	pÔ349.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4“q:49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA“ÇqE312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA“ÇqL312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ“Ÿr“Hrn24.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI“Ÿr94.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY“t³Ws¤22.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0“t952.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36S³|uþ96.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg“Žw342.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo“Žw42“Åx 25.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8“ýx95.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA³èy!23.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s“ðz83.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk“Œ{ø26.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk“k|96.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E“‘³}“ÅƒÄ“ô„—27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis“½…97.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0“†22.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw“†92.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0³­†Q02 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo“.ˆ2“Hˆè0.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo“þ‰³Ï‰4J1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4“M‹í348.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4“mŒ:48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig³¿Œ8È7ƒÙ¥&x31 ½ôÌ’ŒÒ$Õc5ßc={’².[®9a%ñÀêµ¡��™‰	XIfz^~Q*ÃÄ·QÝÉaŸŸÝÍŒ2?È¼QøªÑ®PU.ùÉÙ©Ei™9©?âÔÂÖê~ÛßÊ|^8yÃóï÷ ª‚\]|]õrS–ë›œl/hièSøuÁ_™(ÑPEÉ‰™yÅz©‰¹9©z•‰¹9ámœýÍn[é¯�j|áYnËŒ¿[aÊósò‹�ê€ÊúÊ
þ�[8ÿùt·y³¥ŽgÏY_YÌU–ž¯—›ŸÂ lð�aëKîGÒ¦Ÿ×ÕÜç¶v:ÄƒPQ\šËÐtðrfóæ«>9wvŸ6µOÓˆ;ÍU‘t˜^z>Ã;¥è¿wUßrðìIórýÜ«úaJR€N*aðãªd“2ØTâ´”cMÚáÕßì�JÌMM
K’3tSs“RSôŠ3ÌÔÎ®–ÖˆlŠg/µ/÷ú£µþkØ¨aE©‰9%™¹© .¬ù³°Cni‹¶ª¿fÁ¯/«þ )‹/I-.©íxüÐ¨y‡îâÕ—>8×÷¼qì{Z	®6=³¸¤¨¤,+ò×�ÇjïZâxéeUdËÊ`4ep#Ÿ8¸ø“öªÚ¨´½,™û=›%Kfê›€“PqrQfAI1Ã='«%_þíò«³-Wé·méˆÓvÝ ,Ýô‡ëƒx+ Ôÿåå"40000 .github jÊÍe}™Z²‘Lööuú°£‘Ö±"CdÈaë:x+ Ôÿåå"40000 .github ™ù“Ì#+«{"È»9Š<;'Ý"±"CJS`ë9x+ Ôÿåå"40000 .github ^œù$Ë':r»y.ª™¯bI‚‘±"CM¢Çà9x  ßÿåå°__Öˆm2mÀWw1<&vÜ¢XN°“sòÙHIë.x+ Ôÿåå"40000 .github *ßŽ%w@KAë‰­¬<‚%êf±"C9Ìë9x+ Ôÿåå"40000 .github m¦ÏÊÖÁë­“P¨Ï�‰¤½ü±"CnF¹ï9x àÿåå�4‡ý‹õ(EßÝ2Õ£þ_”uè)°±Hðëì#-x<ÃýåÑ40000 .github ¾�òs¬åóÎ£jQ’Äö�ÊJhÝ100644 .gitignore EyTHò)Ïþ<WÍ®\wÝè100644 Dockerfile -YË!Afip„·H•Þt100644 README.md oƒ&;bk…èÉÜ›ƒÛV›vÿ4100644 chains.example.yaml Ñdeû.¤"?Ø�JÀâö100644 compose.yml àÓõ>”)S¢7>ðr¹Âš çÑ100644 config.go Ø½#N!,¾y«¾˜º¢¡ªä^Ÿ‘R100644 frontend.go ˆ3‰d�=­‹�<@>°5ßc&100644 go.mod m(Å›¯S¹žÂ^nãb‚àâ100644 go.sum 7uJ	û«œ‘âž!*‚Ž§gr100644 main.go BÁoX8Vøïâ0þ_9BõL““P&“ c@÷o:Åº±Z©jø¬šß+F)p�L100644 registry_test.go úäYs²¸5ÀZ ö½C±“C"J40000 static 0ûÄûƒÁ�1~ótHŠãT×4 ©100644 supervisor.go b|3ŒyaGŽ-,ûJÇ9'P5¢Ü£ëƒJx+ ÔÿÑÑ"40000 .github MxC{Vä¯±Q±cƒƒÊkhé§±"¯K`×á:x! ÞÿÑÑ°eÓƒÝš»wÝ¬dZ+Þq9ÏñÈ>³yXˆEå/xE ºÿÑÑ"40000 .github &Ò›_#ò{(ãaê*ðêÐºŠƒ±"C7uJ	û«œ‘âž!*‚Ž§gr³yXœ!7ïSx àÿÑÑ�®Ò[$rQ”³j¦Ù“ÄU!ÆÐ±ÂÖô[å-xe šÿÑóD40000 .claude äó,õ9ã‰ÿ…ž;·- åu40000 .github .õB��Þø·ˆô41œƒùd“*‘"8á_E§�Àf»Âs¦e`Rô¶óø²±ncüõ+Ëàsx  ßÿóÑ‘"4¬ž'ê	‰×Ã€Ê!6ÐÂœã�¶>±j‰óZGã.xC ¼ÿÑÑ"40000 .github ”Ÿ~?×¦~¼®ºSñ<�¼µë›Q‘"8lŽ¼àÅÉêp¹æSÑ@jw±nc¢>ëQx+ ÔÿÑÑ"40000 .github ÓÇ,žd/`ìjÒ*^ÇëøNm±"¯K¨ï9x àÿÑÑ�ZL`>$‘T,]$ˆéÆ.�YøIuq±ncÁ¨bë-x+ ÔÿÑÑ"40000 .github «!r<M…S)0Úã=�Š—ÐÙÍ±"¯=v]ë9x+ ÔÿÑÑ"40000 .github äÛÎÏÌ±Ø˜¯äªAÀjAÞX±"¯{*¦á9x! ÞÿÑÑ°CtÍÖÞÍ,îAxÝ»úžžìëÆ³Wzkã/xC ¼ÿÑÑ°C6ø¬ÔCÒ�˜œVO¬k�¶ØóHBÕ100644 go.sum º@?èõ0hÁYò¿Øçs�� ›³yXbïQxO °ÿÑœ�uDLICENSE þ<¬:¸Mµ¶¥dŽPRÉø›100644 LICENSING.md __"]À+Ò°?�ãÿ¯®�×YÙ±ncˆÚ%ë]x+ Ôÿœœ"40000 .github Ø—ƒä¼Ò¶AùE›oþÜ�XO÷Äž±"úsOã9xC ¼ÿœœ°Ž6‚Xó& ÙR²ê–H»)ü°î¶%100644 go.sum 	‘tt
›à»›ÐohåÑCEÊ˜ç³ÄX½¥ãQxC ¼ÿœœ°Ž6–´<V+ýòdþ_­æ SÓqÊB]100644 go.sum ‹nf#"7ó?ùÈ]!0GGˆOB:ï³ÄX©ÏëQx+ Ôÿœœ"40000 .github ÞbGáws‡MM½‚5KÖr]±"ú+ 'á9x! Þÿœœ°lX(´ÊýÈëãñqÅ-µÆŠ
]³€œìÂ¹ä/xd ›ÿœÇ°Ó’•
ëExöšW8Äê[SGRÁê“ç6?‹�ŠwY��Z<Õ&U×£(®ú§100644 realtime_sse.go “aþ£Â¡Nf%¸l\ÖÂòÈ^“1ëŒf+�ërx{ „ÿÇÇ°ŽYõŒ5•3Öd "J›„1x28100644 go.sum ²6}üÛÃSf„|({rŠÓ]é½¤100644 main.go ?¦ˆÍ\ò\®$·¬ÿƒPJ¦²�³çLÏÖ37›pUëì€•L¤#@ˆ[1ì€	x ãÿÇÇ°38ÿ ?-#øAZ^»jD¬·~ÓÝp—ƒËâ+xR ­ÿÇÇ°ÓßGª/× Ïsý´÷ÛÜ�®Ìµ“ç6zY¥‘Ÿ¯ª»W/rb4gÓŒÝë“1
íe ìSrt²ãŠIØËj™S“\ë€&¾à`x  ßÿÇÇ°HZ*‚ø’Ú¶%éáe�&…Ê“\ëÊÞLî.xN ±ÿÇÇ�Z
,µÚœí·åüXÒ
±¹LM(±n ð_¾¯Q\æºaÿw¦R»
nuC•p³¢‘�O[>LbgXá)�	i}b qu Àë\x+ ÔÿÇÇ"40000 .github ó;:ø5¾ŒÌ„¡Û,
�ö4‰8µ±"%Zù¡ã9xC ¼ÿÇÇ°Ž6²÷©ÈÛë¬#jÞÙ ëçû100644 go.sum zNšö•v3Õ½4ÞwÍ�·ý|³Äƒ¥óãQxC ¼ÿÇÇ°Ž6ïƒ
¸ÒþÊdöü°É‰/w"Ä$100644 go.sum �q
Äê;§tç`ÌS@ƒòO,1×³Äƒ ™LãQxC ¼ÿÇÇ°Ž6…³°ê7i»û£^Ë¤,ÍŒ™A‡100644 go.sum 1)7|í¡˜?.2s£�‘®ÅŸ³ÄƒÎð´ëŒ”'xë ÿâ$�°³ “vw
Liquid EVM“ýL“€#“A³gb“î2“1	“_
 && [ -f /app/out/index.html ] || \
    echo '<!doctype html><title>Explorer</title><div id="root"></div>' > /app/out/index.html³u
¾&COPY --from=frontend /app/out ./static³OÑLªàÒx  ßÿ¹¹�Æ0605-aade4dd9fd4c“q±êO»•DâÒx íÿ°‡Í…°¡A“ÐB
³ŽB"AOXµáÒ\x! Þÿ¹¹�Å05612-65220f1104cd“q±êO°H
HâÒTx íÿ°‡êƒ°¾@“ÐB
³ŽB"AR$ìãÓgx3 Ìÿô½�³(d5e65d4b50c127ce8982ef94f448dd17822448f7±¹b´…âncx ñÿêƒ‡‚°Û?³¾@,A3Sc¦x& Ùÿ100644 docker.yml ÕCï¡bájŠ{‘!Ù_®Gj`ëGq~êŒÁx*ÕþŠÕ�…Multi-arch on luxfi org's native amd64 ARC runner pool; arm64 via QEMU
# (matches lux/graph + lux/indexer docker.yml). Mac devsP run native arm64
# under OrbStack â€” zero Rosetta is allowed. Semver tags only“ühanzo-build-linux-amd64“$Iqemu“í#platforms: linux/arm6³PÃ
,linux/arm“ùªiŠæ�:x éÿÕÇ°s	lux-build³ŠKbsä†x ëÿ�§°“N“Âš³c:Fäsl×yx óÿô›�¸4±¹b$5#l‚škx óÿ¾o•o°C³lR"%yâà„Àx°OþÊTÌQ°_SSE response headers â€” set BEFORE the HEAD short-circuit so
		// the pre-flight sees them and the SPA's `u.ok` check passes
	4	// (200 + text/event-stream â†’ open EventSource).
“p8“�%[“�Ë// HEAD pre-flight from EventSource open. Standard HTTP requires
		// HEAD to return the same headers as GET would, with no bodAy.
		// We've already set them above; just acknowledge and return“J*“«,“&.³–Ó“r6“�%ˆ“N&“«,³&5”˜“ql…³#x óÿŒ=�:°ì³w"r�å„Ò&xU ªÿƒ5µ.°‘“qframed“�A#framed = append(framed, '\n', '\n')“O"framed)
	}
}
“@³£àÌöBàƒ@x@ ¿ÿÌQþ:°÷“Â“t+“¸Fif c.channel != eventType {“ŽL’body³#y/'.á„*x! Þÿ•o‘h°€)“s“ž3£Ý,“".rest³?.V	ÝQÑgÝmx øÿô›°;¦è‰<xH ·ÿêƒ‡‚°A?/1 h1:6NXqd1aDXvhIN13U0O3UvehDjG/9fQ0tb+w8ZpTjWs“�>“Ü?1³u@uAc´á…¶x îÿŒ=Ö7°€³«A³wA1ê€=x åÿ‘h¦^°Ò³ü>³™“n³ý—s	ÿá…¶Xx îÿŒ=½0°€³«>³>wüäíyx$ Ûÿ¤;¸1°“Q“/
n“.“/“.“–P³.vÌË
h¦x& Ùÿ100644 docker.yml {92þj‘ÉØ‰8Éé¬b8«—âà†á	‹dx‘ nÿêƒæ€°<37.45 h1:uGK0y4+aLipE/M0YIQ5hcsWv0ZG0E4cPv03a94K/eLE“!?“ù;97.45/go.mod h1:GnAkhQ7HNs3X0Tzx5nOONS3kl0yRmWHbDcRO5ffILs“á:³ú;ø“•>Ÿ³¾@,Ak0Æn€ x ñÿæ€½ÿ°<³­<¹C4ø®¦x& Ùÿ100644 docker.yml Ú€çlœkç‰YÆ¸·×û|°`ïe)ÊàŒ"x ïÿÕÓ°slux³x]6÷Ìç�x èÿæ€œþ°i7³8–“œ=³Z=Cz5ûlãx óÿ‘‘�*1±+f¯ž¦x& Ùÿ100644 docker.yml £³×—87ä:ße³ >£o²C,¡à�4x` ŸÿÕƒ�¦LARC runner pool. arm64 via QEMU until a
# native arm64 runner joins the pool“B1lux³x]C žêoxz …ÿƒÁ
�…
amd64-only‘�.Ywill be added back
# once a native arm64 scale-set is provisioned (CLAUDE.md: no QEMU).
#‘óŽ³Ú¦“Œ÷"ò*Ê¦x& Ùÿ100644 docker.yml ­3â+	ë«œÛÊ³’Xi¼…ŠÃŠ]5í”
x½ Bÿ�¤°“N“Âš£chas inconsistent caching“xM^Regenerate go.sum from the
# proxy state we actually see at build time and skip sum.golang.org“A	 GOSUMDB=off go mod download

RUN“V	W³¶	çÛFB€¨xH ·ÿ100644 ci.yml êþÿòÅiæöVäPôy˜� sÌ100644 docker.yml ­3â+	ë«œÛÊ³’Xi¼…ŠÃŠWa L·Pxøúname: CI
on:
  push:
    branches: [main]
  pull_request:
    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: lux-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 }
    - name: Verify build deps
      run: |
        command -v gcc || { echo "gcc not installed on runner"; exit 1; }
        gcc --version | head -1
    - uses: actions/setup-go@v5
      with: { go-version-file: explorer/go.mod }
    - name: Refresh go.sum (proxy cache inconsistency)
      working-directory: explorer
      run: rm -f go.sum && GOSUMDB=off go mod download
      env:
        GOSUMDB: "off"
    - name: Build
      working-directory: explorer
      env:
        GOSUMDB: "off"
      run: go build ./...
    - name: Test
      working-directory: explorer
      env:
        CGO_ENABLED: "1"
        GOSUMDB: "off"
      run: go test -race -count=1 ./...
C‘Œd¨xH ·ÿ100644 ci.yml Bñ©‘Ž7Óêƒ[2t«w‰ô×è^"100644 docker.yml ­3â+	ë«œÛÊ³’Xi¼…ŠÃŠ!ä	‰ix” kÿ‡
Á	°uInstall build deps
      run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends gcc libsqlite3-dev
   ³:“]q“å"™è1¬k�-x ôÿ¤˜°£$>  x0 Ïÿ100644 scheduled_tasks.lock �Sã»îg	Õ«³FÙÝit)™š®ºxZ ¥ÿ{"sessionId":"cf4e8ffa-52d2-4e07-b22c-dbf44cba2479","pid":5284,"acquiredAt":1778041169393}Êï¯gŒÓx øÿ±¨°(
›7¨xH ·ÿ100644 ci.yml Ò5TÁØ·´µ¼¼[¹jx6100644 docker.yml ­3â+	ë«œÛÊ³’Xi¼…ŠÃŠ¯Pìj‚Kx
 õÿÁ	�°N’ÁÆºo�mx ðÿ¤�°Ú“ã5£$1ífì‹×5xìþ»/¾�š³º`“´zmainnet“-A<
    indexer:
      poll_interval: 30s
      start_block: 0
“‘¶“ÿ“^7    “¯a    logo_url: /logo.svg

    tokens:
      featured: []
      allow_list: []
      block_list: []“emAmainnet/ext/bc/2Y625Kvdd4wZ5LKx9kLSf1jGALyARmXVN1YsTb4F3Hzv4bPDbp³ÜhAmainnet/ext/bc/2GiQb73CeJESjc4omFv2YtQHZrRgJf25NXPzAr5J6UNHRcDV2F³Ph@mainnet/ext/bc/rtjwvtE1tEvrokmpeYdTq7b2zqZgmybKwR5MLjKMGAR1W78dQ“Â¹Amainnet/ext/bc/2pUskxqaL5Bpx7uRUGG1fDjPckjxQ4UKX4sLKeaS1NdSVBJd3F“†K,¶¡¨xH ·ÿ100644 ci.yml ïÅê$¨ÏJà˜«¬qvT¢-xÇ100644 docker.yml ­3â+	ë«œÛÊ³’Xi¼…ŠÃŠ°”n‡Qx ñÿÁ	‡°o“÷W’Á*=´é˜x9 Æÿœþœþ°0*5Rl34569IHJ5hXOvrkjbadfKNt/2rGZd83vhcSIRSP³10ëNI“¯xO °ÿ100644 actionlint.yaml £…Àj @\/B|öÈV5ƒÇÎñé40000 workflows nÊ9w�Õ~ÿÐ4YQ_ïí‡3ºa²�!é\x æÿOO�;_,�ïêýtÏ ]£§£¦Aá¦Ž‰©¹xy †ÿself-hosted-runner:
  labels:
    - hanzo-build-linux-amd64
    - hanzo-build-linux-arm64
    - hanzo-deploy-linux-amd64
c&ú§xw ˆÿ100644 ci.yml ü0è5�-FW,ÏòRo±RG�)ó100644 docker.yml bU/ÖÑ>4qÍÜZþˆ�Xq/åì100644 workflow-sanity.yml ~¨Žzêƒ‰2–/V)(ßõ„Á>o¯.Íì€x ãÿww�4-0V7óY=¦,±x»Äëå´]ƒ¬ê‘H/²ö¶î+xN ±ÿwwH100644 ci.yml mÌ�ã£Ò°’Õ1£IeiVƒZÎÂ›100644 docker.yml ã÷Éè‡Ç…Ü_amþ1}ó79%‘H/ô!nè\x( ×ÿww"100644 ci.yml Ñ+_È-Â‚äýhu….¡ã@�‘"USã„.x3 Ìÿ‡ù°Ehanzo³H±	|
       “øIrun: |
       “i�£Ü¾%x^¡ýname: Docker
on:
  workflow_dispatch:
  push:
    tags: ['v*']

permissions:
  contents: read
  packages: write

# Multi-arch (amd64 + arm64) on native hanzo ARC runners.
# No QEMU emulation. No GitHub-hosted fallback.
# Semver-only tags (v*); see hanzoai/.github docker-build.yml.
jobs:
  docker:
    uses: hanzoai/.github/.github/workflows/docker-build.yml@main
    with:
      image: ghcr.io/luxfi/explorer
      platforms: linux/amd64,linux/arm64
      runner-amd64: hanzo-build-linux-amd64
      runner-arm64: hanzo-build-linux-arm64
      runner-deploy: hanzo-deploy-linux-amd64
    secrets: inherit
+qÇ½	x� bÿname: Workflow Sanity
on:
  pull_request:
  push:
    branches: [main]

jobs:
  sanity:
    uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
2§5å·)x—hýname: Release
on:
  push:
    tags: ['v*']
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          platforms: linux/amd64,linux/arm64
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.ref_name }}
            ghcr.io/${{ github.repository }}:latest
ƒ¯Çaï„$x/Ðþ—Ì[name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  test‘:!‘—7‘¬setup-go@v5“go-version-file: go.mod
      - run: go mod download
      - run: go test ./... && go build ./...
  docker:
    needs: test
   $ if: github.ref == 'refs/heads/main'±;main“.“�ú\Mà�>x` ŸÿÌÇ�"
    - main‘)
    - main‘N#hanzo-build-linux-amd64
    steps:
“ç$‘«"with:
‘×$‘ý“'HPà´ÀxÅVQsÚ8~÷¯Ø£�Ò»«ì@ÚNË„Ì0	SŠ9Czé´FØëb,×–®Mfîéžïá~aÉ­d;i2¹·ò h%}’v¿owA˜mWÜbÛ8	KàÛ_ÿB_l–<b~xä³-Z…uBã ‹…°JD$Yä�ŒG °¼ÄäÂÊÑÊm ßþþ¼€ò|*)<]†Â;OŸ�Üª/qÎ"üõ*¡žL¾+?�ÐXi¶Ìì‚E2…§é%�""ÄŸògp¬îúÛø.´òú9Ú„m¥ùGZ{ÌwOÙ9F?xÚ† ƒ„1¼yî)í›z�²Ä"Â»�G#\™
H²RI#Ÿ†8+‘h¿Æè=µ.õhÈ£µi=÷Ž�Å;Û��œI·e¶_jÛÄØ;k»­m£ÉÀ>³Ý…kO�n eœv,kÍe�-M¼ÃÍ@˜8qk×°»Á¨äGº½éÉ@Úé;˜ãbGÄ>›Ž×~ ¦ðæ¨ÚU@!~`&éšA«Gý]€ô¤1t�·	Ÿu­ûæšÐ0FâBo¶Û¡_X3un††{:Ÿõ}„�ñ0Ž”Ë~wÜ7ƒ‘�c½�àé(â³XÝîY&4ò‚îã¯Õ9Ãk¨§Î5˜ÁC•ÅÔ;ÑeÈàÉˆ£xƒŒ@Š ¯Ae}aQBYñ�öäLì³ùbzz4õ½éhqÔ›Ù‹io~Òµ.Z;ñæKYœé4ë6ÙÐ-¡OR<•‘”aÝW­×í†¾MÊðÍ©%2g²ÍŠ©ÍÊˆq‰lZˆ”dP°+¾VÊ¹º™dLƒéw,u¼êæÍ¹Ï ±ö¢‚Toþ dµ3X:_˜�Ü„ðIA~4”Þ™hü4púó÷SÔüáAñÍ¨x ¹Ù¡]ä®+X�ž]
ÿÏÃ«øÑûšpx×©ÆMÚµKÚÀDHÎÒóÖ"¤Ñ¹WiµÎ<í–ÜÒ[A¼J¶Ã[Ú»1¼Ÿ•ž›,‘}�~¹dúo)ð2wà´zT®ž"Wt.ŠúPºvaEéjXQz-`#|ðÅe
ê}gú>çñ¦[–‰�×!6K,oãï˜k!ë$÷êœl¦W˜ÐD‡m–HÒ|ÞúøKÝˆzéäWÍ{pËóM¼dûÐB·XQ†2«´�q´'½£±=@9«Q8î+õcLÊöp4¶_>_ÌœS·o7 ±ršç<%¡¿
é:ÅõH“K g r«yÁ’”‹óB•ëRªt˜ÚE<,ýþq�]•ËïS­ŠCº TÊ‡ÿƒ?»R¡ØSîçŽBüÐï)¢¡ÿë§×JÑ~Ü,’|Ãj™ /7�}³ÝÒ¡»£Œx”x,‘|Å=*1�šù2½!”"kÜd‡•¥‰……€†uÒÜ¿¯ŒÜÚXÚïß™¿ÿÖ¶<ÈÈ9M.¬ReFîßÏRlÉ H/_¼Øo—mcbœÎl·½sÆ§om°Ts¨«× 7ï-°ävµ	Næóé¢7¸ÝÎ«½×{XgfƒúÏ÷ööãÄî�ç'ý»ÿÃ±'M.hØÝßS[…N÷…`¦H$‰YÂ…ßmá¼Roÿí .×Lùì¬02Æ6J{<©Ô‡ªjÊ@¥¶ÅúßR¢°ÍB¬—ª¹“Ø.lpÄIÞê–î†§…°kýsé*Õœ:˜!î3¸Ä¾­ƒ	Ô;G·)DÓ4w½7)P½œ�$öÔ¨}Dò@Ój½=™»ï§Îh2‡�òŽ�OÆ~DØù»Ïxû$Û# Explorer

Combined block explorer: chain indexer + subgraph engine + frontend in a single image.

```
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)
```

Each component runs standalone for independent scaling, or together in one container.

## Architecture

```
                          +------------------+
                          |   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)

```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 explorer-data:/data \
  ghcr.io/luxfi/explorer
```

Open http://localhost:8090 to see the explorer frontend.

## Run Modes

```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
```

## Multi-Chain Configuration

Use `chains.yaml` to index multiple chains in one instance.
See `chains.example.yaml` for a complete template with all Lux networks.

```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
```

## Per-Network Deployment

### Mainnet

```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
```

Mainnet chains and their RPC endpoints:

| 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

```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
```

### 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)
©�3»°xôô# chains.example.yaml -- All Lux chains across all networks
#
# Copy to chains.yaml and adjust as needed:
#   cp chains.example.yaml chains.yaml

# Mainnet (network-id=1)
mainnet:
  cchain:
    name: "Lux C-Chain"
    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)"
    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)"
    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"

  hanzo:
    name: "Hanzo (Devnet)"
    chain_id: 36964
    rpc: "https://api.lux.network/devnet/ext/bc/tecXMucYDxwN65mebPE6cvQ9GynG6Y5WEvwGFsY3xVDWHxiqT/rpc"
    coin_symbol: "HANZO"

  spc:
    name: "SPC (Devnet)"
    chain_id: 36912
    rpc: "https://api.lux.network/devnet/ext/bc/213ZsFLRCBSmrii4huQqa6S3SxYPP8ec6dPEn6ad4CtLk3RdXp/rpc"
    coin_symbol: "SPC"

  pars:
    name: "Pars (Devnet)"
    chain_id: 494951
    rpc: "https://api.lux.network/devnet/ext/bc/MEGAgimfcodi7QaqCruFpTkNM6fqu7ZduLmWJJjGQwUg8uDPJ/rpc"
    coin_symbol: "PARS"
×‰jÂê€—xœ›(¿€e‚‡ni^fZfjÊD…œ‰/ø&«3ªNžÃè1ù(³áäóüLfF“×óJsææ§(”èZLNàUa22R²œÅ•yÉ Q¯Ä12ñdJòós |=}}…Ì¼”Ì¢Ôä.M. 5$`î5ÿxœ]Î;¯›f Æq�iÔ¤ª²$Qn=;�¹Øs”¨²æb›‹1`{9áfÀ¼€Á˜Û’-S”åÛ¹c—Ó©CU© j·~ƒ.ý
Ub%‘NûþÏïùáß³wO ýèOør^>ë\=zæžÁŸ¨›ð¯ï¾‡oø¯!Ç/q’8ðGÈGv‚£u‘‘Ò	po’�6Ši `!×ô¬v8_Pá+þñ€$P?íÄ©{Šã’ŽP´1bçÔ|™
Ó,ò�Å`2çEšYJ’)KæÊ–TˆËOáù5üÝú¾s¿€çnÜÅì´ÓGh Ùl«h®ñ]¢\+£ŠÝ¯ÓŠ±ç}Bwéz¤—°1ø›{ïâÔ]»,íxt�Iñ Ú‰^°Äœõ†¥T3êÕz¦Lò…/"”Õíã«o½Ï‚údO»ÒÎ©0DC—{n¬¹NmÊk^Ö£®kH³’�I9l ²½qÊ®Ñ.bJ˜Rêr>ÔÇU‰–‰æÈ½*áU¹ïèm‚.š‚…/ƒûðv ðOðú Ä[m”*´ª¬gXÝLE%Ã|Ç]®›IšÕKÓÞªG^èÿºã¶@ªé÷Ç;aô"Áv\_5Ê½Hæ­‘å}kÕ´›«Ûþ~sõ0œu	â£¼*Šl:N*}ÖŠ³,*4Õ¦» �2•†²’5ouÙ2…ñ½‹SwÍF=ðö¡%ÎU}ßCR¥69ÎÝØ"�åLXïdy9Ù(º3„¿Fàóˆ‡¿€ã«Ï¼ŸiÜl“•ÈP � ¶I(bÉ†Yp}n¤´aoJ-Ù_|é§ÀJüNšûh�i
ç%Öùß#k\9‡]VfÔ:+È¥Ô¬(u®^b«(W`vJF¹vÓcóqàÏñÞ-ô6ÁØð›q/Å=clÅPž†åˆÆ¤$ÝµE`¸ pëMçð«¢‚wÊ·ïAþ+è}€¤BxœmUÏoãDVZ°ii—Õ6ÐÕ¶¯^uå”Äi»°€Û
ºmÚTôMVå{ÜXuÆÑxœ¦¬ª²Gn áàÀ�#qàà†¸p]\¹sâÍØÉ&Ë²<žï½yï{ß{~ê>…/ç®h¯Åœr LðèÄ!ŽY©àÇ)a›–Û© Íˆ‚×"!NOÃDaÏCÑ‚öÖAü0ñâ.Åo„ùpB›õØ;£¡$Rú¥aä»°sxrxüáz Ëc®´ßÄ¹ íH¡òkY¹ÜöY²�(¡¸èÄ\À»Kï-åN«¬ëffG‡Ç¾þüük¨5G mlŸ$�2ãbfRýøhïêð¸züÉæáÁöîNfÓ!˜™ˆá?�)¸Fï«´ó(Óì\cbpšà)ÍOÛ¬mìfvC†±(d}v#äÖËÓ+·%DÇ­TÇ)}ÇCK+O~Ão£ÑÆÑnNÁNU1 Ç¦fîŸ¡¼æÙô©ª72Üü0Žø~ËQ�ûÞä§S/ÝÕ�´ãA‡Q[Õ½j£ÚG5ŠÓ6Ò0@i5åbÈ#=©KçætE£“´™x<lREz3Bñ$Á	Kˆ'Â˜%ÐAh_ò«Ï¢ä"ñHY¦a)‡–Y4Í e´aá‰iè‚»ëDäÔÙeÂ¶Ô«„Åêê¥Ò
.µJ`TbMÃ‹Yžn‡XTWÎÝx}�lkPûL)hlYÊtH$Z8ÊC&	-WòåU-“TÄå¾H 3%Q^è†DkHÙë°�O¨]”wÍï'LÃX+'áéfßä&Ô W­9õhØ¥¾23<Â<Ùøz‰Ó@¦7G›ÖæYSCÞÔEi¼2¿¢‘{1ñ!àq2 @L#`qˆ‘ùuÌžÈgãÓ3ÊbÔ†=*®Ê–ñþÊ¥yÍ/ò¨ýåc}4³Y[(B¥˜x§€&uA°~£%¿›Xõ{ýÑ­´i§ñ C?88¹lOôr
tqÛiÏ4ð¡NU}äÐó:å]ºŸöú\Õ”Ó–üæÕ_^³â3«„Œy}-W~19;>Ürˆ+È¥›ãrrjVþ5ù÷í!‘?‚ò÷©�[Úû`/D$¹õÏòÇÛ…EÕŸ”…ìÜì…¼“èÑ·Ð-";‹JŸrnzîJ…›ð®Jå¾ÎE'ÂQ÷Æ†ïswdBÐN½Ãq°¶…þû®Š*¿NìˆŽ˜ IjÃõ‘Ÿò§8––—`Dx…½N±Ü~7@d×ƒ7;á¡ £¸‡ÿÛÅh®y[^¹†û@÷I/‹îÑ… ‰Æ.ÃÚ,?Ä]¥<Ô„
Ù<Pª£œ+Æ�8ùÓ´X0±£P#ÎVÌ¨ê˜¤•
?>g›¢W‚þBîÞ{[^Þ¸§÷ý…^œz²è
oÊogÒpÄÈÙ;Ó/#çEÀ‡üuÆvº„S+G×§ì.ê^å>S-Ì©H9&ÿ™©Éßfn©RãŸÖkAW¶î<†ü·â>‡â�1jïL¥}iþ§A_âº	xš eÿ#!/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
Ù55‹µx êÿ# rebuild 1776010884
B¹_¤x$ Ûÿ40000 workflows nÊ9w�Õ~ÿÐ4YQ_ïí‡3ºa÷üŽ¤x$ Ûÿ40000 workflows É¿ þAé„ân¸„“ct6‘èúkçü;x èÿÇ½�rubuntu-latest‘‰¾n‘	Íì€ŠXx, ÓÿÞÕ�˜lux±�:lux“Ü'lux“(	lux-build“<"vÝo¤x$ Ûÿ40000 workflows  9[ñÚz¿ÝÇ—­>âLB’§Çðtrë€‹Ex; ÄÿÞ¦�2branches: [main, dev, test]‘-D“„secrets: inherit
nm‘¤x$ Ûÿ40000 workflows 2Aµ‡îŠ¥(ZLM`æ*³¼Ô²¥x340031QH*ÍÌIÑ«ÌÍap|—Ä*­cÎï—�o£}útï‚ö> Ìî/xN ±ÿ%n�%I100644 ci.yml äìtî&úí_‘ÏÂXU�œ‚100644 release.yml 0Õ*Y˜Ùº$ 4ƒ…øAzb'—^GC±‡xíUMoÛ0½çWC�CíöqÈ íæ¥Á¶¶H»¡ÃVŠÍ$ZlÉ�ä4AÚÿ>RJœ:mÑw˜/‰IŠ|�z¤•(°Ç•Ì3He®šVK«NàF›Ù8×7ÃLÚR¸tÊÆ²²þ`d„J§h;ð«R]“À‰	öç¯÷Ù`°ÔV:m–�$·,ýI©2\ ‰ª2³˜QN·¯0m×­ª9ã$½þÅåàg&ÓÔÄR“­ÿí¨—Ô†v^-Æ²½Î@¬þè‘å£#fË ¬3Tv²o …pF.6o R¥y•áÖ �©”BC,-æãhª­žKU-@Ùû·¡\€Ÿ2n¬MÑÓö!¾|ð?—ÑÏfäŸ‘°Ùˆ®öV«5›8à…»;Q¢)¤µR+ß†�jåP9º8ƒ"[#+E:¾¤#z+1-ëST–Ý"uœ¬MZHgºr‡ó …["2�ÎÐ´s=‘*
á‡ó7ë:¤3éÖ’
ý08‘t1Ë@‚î;Þ\ö†Cˆ£äFyý2Ù	e©F1¥×¦W
kIËYÈg15èlÜë_ž|?^ž}IN·ñ;�t‚„)jÔïjÔ’ò±»6ìÒ�Ehƒc^�ÛJÌ!LËm�ÂÛh2ºÇ8§;é†){a±˜£9 ¹tÔ…î|µ¢W¾Õõ5‡…AëÚéæ??
Ïô#w	y™,\â{pü> gª �P6ðbâ^11÷)&i”5ž�Í6äb„9)é‰ÁÝ<€ÃÛ¦Ù¿Éà¢vÚ½§	êæ�…ÒLAûâôSr•†ƒäs—WØ=Z ½ÁÑùÉã®äêüëÙ Ù:é`!”£uaU(ÄŒ�y”>k=—Ï-�GÆóÅ“øÎþ¡9›é#mT‡PT¹“‘0é´J-6’FSÃô­àÍ@Ÿ§&b=B ãÏÞö	¨K m:®þj�¼‚ß�XZÏÍ„Ê^è¾½Ý�r ò¼Ø·õxlÒ=ŒÏ´ÂÖ_¶o¤í„xxM ²ÿñÍ°<lux-build-amd64“©“ulux-build-arm64³©Èlux-build-amd64“í³¤Í+Û�¤x$ Ûÿ40000 workflows ¬áwr…öÿ6ã'ÂÐ`/2„q <0ä€Úexœ{Êò”EÉÄ ôÒ3K2J“"z_v]\vâUÚüßu››ôâ7*Ù2Š0øqÕ²Iì*qZÊ±&íðêoö“‹? ‰¥A¤x$ Ûÿ40000 workflows ÙÈ[ÎLdçC2BÅ_¶½üœX÷‡N¥x% Úÿ100644 build.yml táÍ‘¤Ó° ‰Ñï‹˜.W[¼álÓc‚à€5xœ{Êò”eC<£û©[#[Kµ®Íjõ�HHŸþmÉS½ÉÅŒŸ õx˜ì†²nxœU�±JAEK³³	(6
aM‚K"‚i-l— 0»;›Ù¼æM„T¦¤qÿÀZ°²ÿÃÆ�°v6+QÛË»çÞûî�?Ÿ_×:çÜ Ësˆs™Œ’!H ù@�áš§ Ì�ƒÊ™É¤ÛÓJ`ç›Y½Áí~1#×ÚzµKûèí4PþC	´ï^ÓŠÚ›mùÙÝºçu»ió%·�ÇTI�>g 2§”)‡TrÂ¦£Y2*õ%6,1`eÖÜL´dXÔ‘ºW˜OWSJ£OÎÄŠcRÓŽ]ø{6©nŸtê¹ÌŸ(·;žþö�FB)�ƒb±f˜†€lÌÛöÉŸÛxca©¾eªÑÑŸÇ­€ÚàÒr>Ê'ƒ°lq=UŽ©UrÓ¿xÙm|U¾v½‡·àƒxœ{Êò”eC<£ˆîÓ½­&{<zWìó­ŸÄÞöÆýQ¥ääbÆO ñ†ï†µUxœ›dòCeÃNv›ÀÒÔ¢J…¤œüäìäŒÄÌ¼b…”Ê¼ÄÜÌäÄœœJ…GSòRËR‹2‹R’óSRÀªr2‹KŠ'[r˜NžÇ«¦¤¯¯4¥ '±$-¿(WÁ1À“‹“bžŽBjQ‘ÂäR^[ÆÚÉx-`ÂÅÙ™™yé“™øB&OçÌËGqGfÞäCœê“3ùLÖà7š¼‹¿lr·†5Ð` un™EÅ%
)™ÅÉù@×¥¦@�´]¡$#³X!©(1/E!)59?7è¥Ô´ÄÒœ’É�Ü7k7ß¶` &ZÙà…7xœ  ßÿåå°_4Å�|W}³ÍUVôíès÷ó¢¹Þ“sòþofá|†·rxœ•UMoÓ@• 	j—‚„„¦V‰šÄ!F9 $¨HmH…"ª6ö:¶âØî®�¦‘9sà€ùÜ�8Á�¿À�À•?Áì:I7©Â¡Šú:ïÍÌËÎë§§?7¿~¿qçuHÙ1´Oïè±]k7×¯´ôøãÒ³uVb´mó€²F@\ƒ0£"kr-ÂiZÁ8³¼»¾@]>n,ÿŽs+Oâ+½HöÚ;ö)hePh¯«d3Û0ÛmóRÅs©Ðë¾ª@!X‘‡S,ÊœS‘i�¶`€:œÂ%¾CÓcC•Õ”Œ/SâÜí‡¸¶¢Ñ.éR \ü”À›zE‹ÿÞÒÎ§—pÃ™Ž•J×p¹" A>›dÙ¬ªBÇõŽÜÄèŸƒAh×sS8µž8R8xIðkD·F‹ Ý6#“2êI%‡?ï?ƒë@+z*j]m–²=Â¦º—EÿýÄ ƒý‰¢M¦ÝFG'ì+štø´�à:zbJ#”í°•"Ò"‰Šýµä»+@•š$tÒAaBÅŸ¡R/V†àPdX4EìÏ 6%1š`Š hâG9ñ¼Ôˆ“	‡*ï<jowŸ_(´?©yP­5§%Æ38öah©1pz’m‰ýß0‰N4=ÐPhæHé	L‹^ ¾ÜªÍÙÅ"îIÚQÄ¢“”©[¢n¡M¤¬OO©
hZ³ŽÈÉöÍäÔf‡ïLÂèb0€ŒŽêÈ¢îD Â¡LD“ØN\¼~u¶h\_‚{^G¤×Ä‰íË£>ˆ?dà±ë]ì¹ÁÀ;¶ïãbÇi½£Œ‚ôWæQÄ¥+z)ÉM2TÃývˆ"�Dh`�OÿÂ�ì 3B1oÇ¸Ex#4M»ŸC=j H‹ópv	åòøÎ5äGØZ(—A$7l‚¢Ò~ ¶tµ¢2_Wi!Þf^“I=G«:W+J‹‰÷z¹ØÞ”ØÄZþùZsÉõ1’ÌþÌ¦`Ãç}YáØ¸AòŽ2&^+½0Œœž.�µ2¸¶§ñ—kw×ÄÁ§¢ÁFßÆÈ,Ë'*ƒoñjtõ0¼c�àŒ xœ  ßÿåå°_B+äžŠ&©Ûp<çüä“�§ÿ“sòöÓá …ixœ}Q=JÄ@F¬2¢¢��±Ü,ÓRÈXKp-"2;3‰�ü1I$K9D."xàk¯áŒd7+f-¿÷½ïçñÞfï—/«ƒþóèâœ�øÀŸX”¨,i&dz,s%ƒ¨ÇŒQ¿‡ÎºaâÂR±TÀð#«EÈ*J=`Jfýþ�Gš¥A6È²šE\…r-ã?‘Ø†–Hg´:§sëQM°Bš9‘uI–'”¨œkön•k–Ï‰‹¬RÜÀD¤…Æ×2`U\®}]}@\Õ¸µ§ªä»ªxªt[]8ñÆ�ßM¦ë]�þ·sâã«M `ádZ‹¬ Sðh7�ÐÖ¡„ñ7Íë×ÉÇþ7C2›KàŽkxœ  ßÿåå°_ìÿ³õR¸Ë;õ‘’ÈÌ,ÕV]_“sò›oì�Kxœ]�ÍJÃ@…‰‰m3þ@u!XÁÛHµ…šºpQ‚.Ü(­J—
3Óšš&a2ÑJ)‚ø ê|—în|ŸÂµÏ •
î—s¿sÎÛÒÇÂmŒÊØbþØ‹ûÔ<Nãç®ƒëc÷&u#¬MèQ?2›ì¢lüùŒ*¬zg\êµ*Áu¯Ô²Z!ŸÕõ‚Û†b7p}Fa@t]çLÄÜ—jHðS…å”¶€Rd•¨d„Œqs‹Rþ+÷.*›š‘­}˜bo´2*Úl½m»žÄŠ "asaAé\çÒ8Ê�²6ã�î0["Ëx8~¤¥¹ø2¾‡µL=‰3sÊ´P&kü”1ð){µlÛžwb;g~™ùÔæœSÛõ#|ÈîæºÉknGI,(3#Ç÷°”‰ffß'
D~s×ïT`€�É•¼á|Cdå¦Ýcø8Ù(
³aU´¼¸#¯áWqª‘7úÿ®ÉÝÔ†ö¥©Ž<á’xœ{ÊÒÏ4As¢ÿÜÉeŒê“�™” ;ëÜéx) Öÿ�µ�).gitignore ‘íZ‹cVóæÝiZ7Á±Õ2º¸‘"í^Ö„§	x— hÿ40000 .github é*MôÙ
š(‘ªQ7È×«a100644 Dockerfile ø^&V¬-ö¿…Ïc’ ç÷Þ100644 README.md ÎÔKVÑ¤æKŒÜÑ9¦
ã*å100755 patch-embed.sh 6&Í«(Y‚_u?wJü*¯õV¬xâ9¤x$ Ûÿ40000 workflows b˜2oQ`Ÿ�fwX‰Q¬d(j×‹VäzØ¥x% Úÿ100644 build.yml /x%üÈãïÉ=ÌA,�©:óñí·ôâ›rxœû(°‚sÂÄ‰´µ¹²ò“Š­¸Rò“³S‹@,…¢Ò¼bÝü<+…Ò¤Ò¼’RÝœÄ’Ôâ’ÉoëÅŠSKJt“J3sR*t“K2óóÊŒ'²‰MÎaú/äêî©P[«ŸSZ‘–©ŸZQ�“_”Z4ù6[ýä*6ÞÉÿ™wZ%'&g¤ê¦åçZ)”T¤Ú¦g$‚m† ˆtI>BR'7?%Õ67±bòZ–ÜÉ¦¬‚ ~^Dcä)€’Oxœ]RMkA&äÔõP¡XŠ¾˜ IÈÒ öšÖµÔnj>ÄBšÙÝ·Ùm73ÛÙYI’ÐCÁ[ur�Á«g½	žÜ_ xôâì&Æ¥Ã<Ãð~<ïÇŸ�ÏË“||~ñö¬•Ò×§	ÉÛ¹JÊ{¹I¾QùeéV>žLå¯¥�wžD¡ á"xÔÁr(Ù.ñ(8Dh¶w€Qÿ´,^½)¿iGÕ…ù€“À¤�"”ÂÈš}l'wÇL½ä‹+¯ÇMn»ž@[D5­ßïk8
|Æ“Luð†d€Uh€åQÂ=ËZ<}O'ê,Ô^ÕkÿÜjH°õ_e×è=K¤f\gbV –¾O|X@~‚µqÆx9ÉS�Ë0†::°‡#qï(„CÎ¨@ê¨ g™ ½Ž©Ê„ ¹>k`È™n3zèª°ß4a¸ÊÌx�àÑ&ì>—?r×Æ�‰ê±KB|ÆŽ£ T†Ó¹aß³‰ÊŸ¿ƒ^ã²ÚuÚ5›ŸB©.µL}×hél”Óîk…ì'ÑMb¡ŸŽÄR¹4‡ÙÇªÙ<’íëwåòŠ!Wo¬É…MP«ÛfeÍ‡R…úýõŠPþ.4Ò}*_¦ü¦ØNù{þê|ëA­xm ’ÿ40000 .github é*MôÙ
š(‘ªQ7È×«a100644 Dockerfile „éÙÌÊ^[Žšƒv¸�N;žúª100644 README.md ;¡1ÄàN{8B·S^g¤ gS³z(ïå%€¡gxœUQÑjA%&ié*‚’¶n’j2)´¡]¡MÛÄlØe³3i¦�ÌlggÃö½úâKË¼
~ƒøþ@ÿ¡úìˆèlcRú23gî™sî=sžûòèbå%PŽIL$ô=ÿ„pk@â€	I` WÉå°tä«háp(	™àð¨$I Bû²™Ï³(P4UXm‰™¶àì¬¤¦–*¦ðóãLÈSCCî�XUŽÃ[ïé“|ý×(¯ª!mðÅ¨O9Á¥›Þv#Ê°€…q4C%ÊêòÇ“ž„Š!¸7åAFÊlU·«v²@™€Ûm¾ßïìu�vç°1T*°â{»¶Y5(Ô÷QÉÀ7²)©?¥Î¾µï}Héßi\+Â�ÇX2°t�d)¤‚�G™ÉÊ—ÄSF”›ô„ÊSÔL¥~—ÙÒŸ3�1Å�‚Bž×‘Áú:³;7	P—²kÓã›lA§Ó9ý5»üàµã¾Øk»€Béë?ÙñS³#„gi¡‰•µã¶àÕ¾Ûk;�&cýw®fµ§×`”G±Þ˜¯çßZI<e·QWîhéÅ…Óùÿª¯
ºkëïV¤ÝÏýûkÆ3ä$‚PxD»ýŸò
�:‘#
#
# Uses the pre-built indexer image as base, adds the frontend static export.
# No Go compilation needed â€” just layers the frontend on top.
#‘j!*Go backend (built by luxfi/indexer CI)
#  “Q7   â†� Next.js frontend (built by luxfi/explore CI)
#  “Q“"This: combined

ARG INDEXER_IMAGE=‘o:main
ARG NODE_VERSION=22“Elstatic export³°ƒ“j7\
   “ G“ü6@Combine indexer + frontend ----
FROM ${INDEXER_IMAGE}

USER root“*"v/frontend
USER explorer

# The explorer binary serves /srv/frontend at /* and API at /v1/explorer/*
ENV FRONTEND_DIR=/srv/frontend
_ä½Ê¶hx†yù# Explorer

Combined block explorer image: Go backend ([`luxfi/indexer`](https://github.com/luxfi/indexer)) + Next.js frontend ([`luxfi/explore`](https://github.com/luxfi/explore)) in a single container.

```
ghcr.io/luxfi/indexer   â†� Go backend (API + chain indexing)
ghcr.io/luxfi/explore   â†� Next.js frontend
ghcr.io/luxfi/explorer  â†� This: combined single image
```

## Run

```bash
docker run -p 8090:8090 \
  -e RPC_ENDPOINT=http://node:9650/ext/bc/C/rpc \
  -e CHAIN_NAME="My Chain" \
  -e COIN_SYMBOL=ETH \
  -v explorer-data:/data \
  ghcr.io/luxfi/explorer
```

Single port, single image. Backend API at `/v1/explorer/*`, frontend at `/`.

## Build

```bash
docker build -t ghcr.io/luxfi/explorer .
```

Pulls `luxfi/indexer` and `luxfi/explore` from GitHub at build time. Override branches:

```bash
docker build \
  --build-arg INDEXER_REF=v1.2.0 \
  --build-arg EXPLORE_REF=v2.7.2 \
  -t ghcr.io/luxfi/explorer .
```

## White-Label

```bash
docker run -p 8090:8090 \
  -e RPC_ENDPOINT=http://node:8545 \
  -e CHAIN_NAME="Awesome Chain" \
  -e COIN_SYMBOL=AWE \
  -e CHAIN_ID=12345 \
  ghcr.io/luxfi/explorer
```

## Multi-Chain

Mount a `chains.yaml`:

```bash
docker run -p 8090:8090 \
  -v ./chains.yaml:/etc/explorer/chains.yaml \
  -v explorer-data:/data \
  ghcr.io/luxfi/explorer --config=/etc/explorer/chains.yaml
```

## Related

- [`luxfi/indexer`](https://github.com/luxfi/indexer) â€” Go backend, 1528 tests, SQLite, E2E PQ encrypted backups
- [`luxfi/explore`](https://github.com/luxfi/explore) â€” Next.js frontend, Geist font, white-label
- [`luxfi/explorer-v1`](https://github.com/luxfi/explorer-v1) â€” Legacy Elixir stack (archived)
(C­xm ’ÿ40000 .github é*MôÙ
š(‘ªQ7È×«a100644 Dockerfile *n†Ÿ¸Št)g˜Ú*zïa]wL100644 README.md ;¡1ÄàN{8B·S^g¤ gS@&«›k
‘wqÔÔçæ_-‹½|±îž�