The gate enforced only health. explorer, apps and branded each printed a
warning and fell through to an unconditional 'gate passed', so run 62877
went green with three red jobs. One rule now covers every guarded job:
green or the gate is red.
The apps matrix named itself App E2E (${{ matrix.app }}), which
stringifies the leg mapping to 'Object' — five identically named jobs,
none identifying which leg failed. Use matrix.app.name.
Explorer E2E was one of the hidden failures: it asserted SPC serves
stats, but no SPC chain exists and the explorer config disables the slug,
so api-explore-spc answers a plain-text 404. Four specs each carried
their own copy of the chain list; EXPLORERS in env.ts is now the one
list, and SPC is absent from it with the reason recorded.
httpGetJson fed error-page bodies to JSON.parse, reporting a complaint
about byte 4 of '404 page not found' instead of the endpoint and status.
It now reports both.
Lux Universe
Infrastructure monorepo for Lux Network. Compose files, Kubernetes manifests, gateway configs, and deployment scripts.
Lux Ecosystem
| Repo | Purpose | Stack |
|---|---|---|
| lux/node | Core blockchain node (luxd) | Go |
| lux/wallet | @luxfi/wallet (extension, mobile, web) | TypeScript |
| lux/exchange | DEX at lux.exchange (apps/web, apps/mobile) | Vite + Tamagui |
| lux/web | lux.network marketing site | Next.js 14 |
| lux/ui | @luxfi/ui component library | React |
| lux/papers | 111 research papers | LaTeX |
| lux/proofs | Formal verification (Lean 4, TLA+, Halmos, Tamarin) | Multi |
Structure
compose*.yml Docker Compose for local dev (mainnet/testnet/devnet)
k8s/ Kubernetes manifests (lux-k8s cluster)
configs/ Node and gateway configuration
chain-configs/ Per-chain EVM configuration
scripts/ Deployment and management scripts
docker/ Dockerfiles
iam/ IAM init data for lux.id
e2e/ Playwright E2E tests (~488 cases)
Networks
All networks are namespaces on the single do-sfo3-lux-k8s cluster. There
is no lux-test-k8s or lux-dev-k8s cluster — no such kube context exists.
(k8s/lux-test-k8s/ and k8s/lux-dev-k8s/ are leftovers from when there were;
they still pin ghcr.io/luxfi/node:v1.28.16 against a v1.36.23 fleet.)
| Network | Network ID | C-Chain ID | Namespace (do-sfo3-lux-k8s) | luxd |
|---|---|---|---|---|
| Mainnet | 1 | 96369 | lux-mainnet |
5/5 |
| Testnet | 2 | 96368 | lux-testnet |
5/5 |
| Devnet | 3 | 96367 | lux-devnet |
5/5 |
| Storm | — | — | lux-storm |
5/5 |
Each network runs a 5-replica luxd StatefulSet in its namespace.
app chain EVM Chain IDs
| app chain EVM | Coin | Mainnet | Testnet | Devnet |
|---|---|---|---|---|
| Zoo | ZOO | 200200 | 200201 | 200202 |
| Hanzo | AI | 36963 | 36962 | 36964 |
| SPC | SPC | 36911 | 36910 | 36912 |
| Pars | PARS | 494949 | 494950 | 494951 |
Quick Start
git clone --recursive [email protected]:luxfi/universe.git
cd universe
# Local development
docker compose -f compose.mainnet.yml up -d
# Production: there is no working aggregate apply. See the note below.
kubectl apply -k k8s/lux-k8sis not the deploy path and never worked. The kustomization does not build (it pulls raw files from sibling directories, which kustomize refuses), and if it did build it would regress live — e.g.hanzo/cloudv1.801.210 → v1.785.19. Live workloads are applied per-file by hand; they carry kubectl's last-applied-configuration and no ownerReferences. Reconcile the pins ink8s/lux-k8s/against the cluster before trying to make it applyable.
Production (lux-k8s)
LB: 134.199.138.27. All *.lux.network services.
| Namespace | Services |
|---|---|
| lux-system | hanzo-ingress (Traefik), lux-operator |
| lux-mainnet | 5 luxd validators |
| lux-testnet | 5 luxd validators |
| lux-devnet | 5 luxd validators |
| lux-gateway | Gateway API gateway |
| lux-explorer | Lux Explorer (single Go binary: indexer+GraphQL+SPA, all 5 chains), exchange, indexers, faucets |
| lux-bridge | Bridge UI + server |
| lux-mpc | MPC dashboard + API (5 nodes, threshold 3) |
| lux-kms | KMS API |
Key Images (GHCR)
| Image | Purpose |
|---|---|
| ghcr.io/luxfi/node | Blockchain node |
| ghcr.io/luxfi/explorer | Lux Explorer (single Go binary: indexer+GraphQL+SPA) |
| ghcr.io/luxfi/operator | K8s operator |
| ghcr.io/luxfi/gateway | Gateway |
RPC Endpoints
Each network has its own host and serves a flat /v1 path. There is no
/mainnet or /ext path segment — never hand-build one.
mainnet https://api.lux.network/v1/bc/{C,P,X}/rpc chainId 96369
testnet https://api.lux-test.network/v1/bc/{C,P,X}/rpc chainId 96368
devnet https://api.lux-dev.network/v1/bc/{C,P,X}/rpc chainId 96367
App chains serve the same shape from their own host: api.zoo.network,
api.hanzo.network, api.pars.network (-test / -dev suffix per network).
E2E (Playwright)
One axis, one viewport set, one place where environments are defined.
make e2e-install # npm ci + chromium
make e2e-live # IS THE CHAIN ALIVE — ~25s, run this first
make e2e-gate # the go-live gate — minutes, all three frames
make e2e # everything, mainnet (default), all three viewports
make e2e-testnet # same specs, testnet endpoints
make e2e-devnet
make e2e-local # needs: make start network=main + ./local/up.sh
make e2e-surfaces # every declared route of every product, anonymously
make e2e-console # all 43 lux.cloud dashboard routes, SIGNED IN
make e2e-institutional # the 27-screen institutional score
make e2e vp=mobile # one viewport
make e2e spec=e2e/01-explorer.spec.ts
make e2e-health # per-surface targets honour LUX_ENV too
make e2e-report # HTML report
make e2e-shots # open the screenshots for this env
make e2e is ~1977 cases (659 × 3 frames) and takes hours: the very large
legacy broker/exchange specs are serial within their own file, so no amount of
workers shortens them. make e2e-gate is the subset you run before a go-live —
chain health, explorer, validator lifecycle, DeFi, bridge/MPC, DEX contracts,
network independence, app chains, plus the login proof when LUX_E2E_PASSWORD is
exported.
Concurrency is per-environment and measured (WORKERS in e2e/env.ts), because
the environments do not have the same front door. Mainnet's api host is
CDN-fronted and ran the gate at 4 workers with 342/342 green; one worker and four
returned an identical result set (154 passed / 2 failed) in 5.9m vs 2.3m. Testnet
and devnet resolve straight to the cluster LB with no CDN, and at 4 workers the
same gate lost 66 cases to gateway throttling that all passed at 2 — it is
aggregate load that tips it, so the budget belongs to the environment rather than
to any one spec.
LUX_ENV=local|devnet|testnet|mainnet (default mainnet) is the only
environment knob. e2e/env.ts resolves every base URL, RPC endpoint, network ID
and chain ID from NETWORKS.yaml — the config, helpers.ts and
the specs all derive from that one module, so changing a chain ID in the registry
changes the suite. The old two-axis ENV=production|local + LUX_NETWORK=… model
is retired and LUX_NETWORK now fails loudly instead of being half-honoured.
local targets the one local stack (127.0.0.1:9630 node, :8899 IAM) and reads
the mainnet column, because make start network=main boots a mainnet-genesis
chain. Only RPC and explorers are per-network: every app surface (cloud, id,
wallet, bridge, exchange, staking, mpc, safe) is a single deployment shared by all
remote envs — test.lux.cloud and dev.lux.cloud are 404.
Is the chain alive — e2e/00-liveness.spec.ts
The one question the suite did not ask. 00-health has nine tests that all
reduce to eth_blockNumber > 0 — "node reports healthy", "C-Chain is
bootstrapped", "all primary chains bootstrapped" — and a chain frozen since
Thursday answers every one of them. Measured 2026-07-26, with the whole suite
green:
| network | chainId | tip | tip age | pods |
|---|---|---|---|---|
| mainnet | 96369 | 1,098,191 | 58.9 hours | 5/5 Ready |
| testnet | 96368 | 1,354 | 3.6 min | 5/5 Ready |
| devnet | 96367 | 528 | 3.3 min | 5/5 Ready |
Gate on the tip, never on readiness. Readiness is a claim the node makes about itself; the tip is a fact.
A fixed "a block every N seconds" threshold is also wrong, because these chains are demand-driven — an idle one legitimately produces nothing until something asks. So the gate calibrates itself: the tip may be no older than 4× the chain's own median inter-block gap over its last 8 blocks, floored at 600s. Measured cadences are 7s on mainnet (while it was producing) and ~300s on testnet/devnet (a heartbeat CronJob), which one constant could never span. The median matters: a single 27,375-second gap — which is what a freeze looks like from inside the block list — would let a stall widen its own budget if this used a mean.
Four orthogonal tests, no overlap: fresh (tip age vs derived budget),
monotonic (the tip never goes backwards and a served block keeps its hash —
reorg/fork detection, which is what four competing blockIDs at height 512 looked
like on devnet), consistent (net_version agrees with NETWORKS.yaml), and
honest — when the P-Chain reports a full validator set, the C-Chain tip must
be fresh. That last one asserts the readiness lie head-on: bootstrapped-and-frozen
is the state that read 5/5 Ready across the fleet, and it now fails.
The whole fleet is read in one pass too, so any single run says which of the three networks is moving.
On mainnet today this is RED and it should stay red until the chain moves. Do not widen the budget to get a green run.
Every page of every product — e2e/routes.ts
Coverage used to be a function of which spec somebody happened to write. Safe's
31 routes, MPC's 15 and the entire admin surface had zero tests between
them, while api.lux.financial — whose API is dead — carried 293.
So the routes are data: one list per surface in e2e/routes.ts, iterated by
60-surfaces (anonymous) and 61-console (signed in). Adding a page to a
product is one line there and it is covered at all three viewports on every
environment. No spec hard-codes a path.
HTTP status is not evidence. Seven of the nine origins answer 200 for a
path that does not exist (lux.cloud, exchange, bridge, lux.id, wallet, mpc,
superadmin — measured with a /zzz-bogus control against each); only
safe.lux.network and explore.lux.network 404 honestly, and those two get a
live control test each run so the distinction stays true rather than remembered.
Every route is therefore opened in a real browser and has to have painted:
real text into a non-empty root, and not the app's own error boundary. Two
separate assertions, because a crash screen sails through a length check —
exchange /pool serves HTTP 200 with 553 characters, all of which say
"Something went wrong".
That is what a route sweep is for, and it found on its first run:
- exchange
/positions,/pool,/pool/v2,/bridge,/teleport— crash screen at HTTP 200 - exchange
/vote— white screen, 0 characters, 0 root children - explorer
/stats,/advanced-filter— crash screen at HTTP 200
Protected routes are asserted as protected: the MPC console's 13 signed-in
screens, /bridge/fees among them, must bounce an anonymous browser to a
sign-in screen. Serving them would be the finding, so the redirect is the pass.
61-console opens all 43 /dashboard routes with a real session, and holds the
nine screens the owner named — treasury, safes, mpc, bridge, wallets,
validators, dex, chains, deploy — to a higher bar: each must name itself and
show which chain it is talking about (a network name, a chainId, an address or a
height). Five of the nine currently show none of those.
61-console fails rather than skips when LUX_E2E_PASSWORD is unset. A
skipped authenticated sweep is the same colour as a passing one, and the whole
point of the file is that nobody had checked.
Viewports are projects
mobile 390x844, tablet 834x1194, laptop 1440x900 — so "mobile and desktop"
means the same three frames on every run, and every screenshot is comparable
across runs and environments.
Screenshots
~/Desktop/lux-e2e/<env>/<surface>-<viewport>.png, written on pass and fail —
a failure you cannot look at is a failure you cannot fix. Deliberate frames come
from snap(page, surface); a failing test's dying state is copied out of
Playwright's run directory by e2e/shot-reporter.ts as
fail-<spec>-<test>-<viewport>.png.
Proving sign-in
export LUX_E2E_PASSWORD='…' # never committed; LUX_E2E_USER defaults to [email protected]
make e2e-login
e2e/50-login.spec.ts drives the real flow — lux.cloud → "Continue with Lux ID"
→ lux.id → /dashboard — and refuses the cheap answers, because every cheap
answer lies here:
- HTTP 200 on
/dashboardproves nothing: it is an SPA, so an anonymous visit is also 200 and also stays on the/dashboardURL. - "the password field vanished" proves nothing: the OIDC callback parks on an
interstitial that has no form either, and
/auth/callbackwith no code renders "Sign-in failed · Missing authorization code in callback URL" at HTTP 200.
So the proof is the access token: the run ends with a JWT in sessionStorage
whose iss is the IdP and whose email is the account that was typed, and
GET lux.id/v1/iam/oauth/userinfo accepts it (that endpoint returns 401 for
no token and 401 for a junk token, so a 200 is only reachable with a token the
IdP really minted). That is the exact leg that breaks in an invalid_client
regression — the IdP still issues a code and the browser still lands on
/dashboard, but the code→token exchange 401s and no token is ever stored. The
spec fails on Sign-in failed, invalid_client, PKCE is required, a missing
authorization code, a parked interstitial, or still being on the IdP.
It also asserts the handoff is a public PKCE client (client_id=lux-cloud,
code_challenge_method=S256, redirect https://lux.cloud/auth/callback, no
secret), and it carries its own negative control: a test that proves an anonymous
/dashboard holds no token and offers no sign-out, so the positive assertions
cannot go vacuous.
License
Proprietary - Lux Partners Limited. All rights reserved.