safe-cgw.lux.network is served by two replicas on AWS, declared by the overlay hanzoai/universe infra/aws/values/hanzo/lux-safe-cgw.yaml over this file, which also pins v0.1.6 by digest. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016dDw5Lp9sZFctwvZLn4nVz
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)
deploy/ CD values, including the sole luxd definitions for all three networks
k8s/ Supporting Kubernetes manifests (no duplicate luxd workloads)
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
The validators run as docker containers on the LAN host evo, not in a
cluster. do-sfo3-lux-k8s is a DigitalOcean context and that account is gone;
deploy/lux-*/luxd.yaml still addresses
luxd-headless.lux-mainnet.svc.cluster.local:9630, a Service no cluster here
serves. Read those files as the image pin and chain config they carry, not as a
description of where anything runs.
| Network | Network ID | C-Chain ID | HTTP ports on evo | luxd |
|---|---|---|---|---|
| Mainnet | 1 | 96369 | 9730 9740 9750 9760 9770 | 5/5 |
| Testnet | 2 | 96368 | 9830 9840 9850 9860 9870 | 5/5 |
| Devnet | 3 | 96367 | 9930 9940 9950 9960 9970 | 5/5 |
Node i answers on 9730+10i, stakes on 9731+10i and keeps its ledger in
/var/lib/luxd/mainnet/data-i. scripts/validators.sh is the recipe: it holds
every flag the containers carry and rolls them one at a time, which is the only
safe order at sample 5 / quorum 4.
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: publish through the CD-managed deploy/ values. See the note below.
kubectl apply -k k8s/lux-k8sis not the luxd deploy path. The kustomization does not build (it pulls raw files from sibling directories, which kustomize refuses), and if it did build it would regress unrelated live services. Luxd is reconciled from the three CD-managed files underdeploy/. The retired LuxNetwork and handwritten StatefulSet copies were removed so an old pin cannot be applied over the live fleet.
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/chain/{C,P,X}/rpc chainId 96369
testnet https://api.lux-test.network/v1/chain/{C,P,X}/rpc chainId 96368
devnet https://api.lux-dev.network/v1/chain/{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 Industries Inc. All rights reserved.