Files
universe/e2e/env.ts
T
zeekayandHanzo AI 15131f6af4
CI/CD / render (push) Waiting to run
chain letters are lowercase
The primary-network chains answer to either case; one spelling beats two.
/v1/chain/c/rpc, /v1/chain/p, /v1/chain/x across the estate.

Co-authored-by: Hanzo AI <[email protected]>
2026-09-08 15:23:20 -07:00

473 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// THE environment model. One axis, one definition, imported everywhere.
// ---------------------------------------------------------------------------
//
// LUX_ENV = local | devnet | testnet | mainnet (default: mainnet)
//
// Everything env-shaped — chain IDs, RPC endpoints, surface URLs, viewports,
// screenshot paths, credentials — is derived HERE and nowhere else. helpers.ts
// and playwright.config.ts are thin derivations of this module; they used to
// carry their own copies of the same tables and had already drifted apart (the
// old playwright.config.ts resolved the testnet explorer to
// `test-explore.lux.network`, which is NXDOMAIN, while helpers.ts used the real
// `explore-test.lux.network`. Measured 2026-07-26 with dig.)
//
// Chain IDs, network IDs, api host and explorer hosts are READ AT RUNTIME from
// ../NETWORKS.yaml — the declared registry — so the registry is load-bearing
// rather than decorative. Change a chain ID there and the suite follows.
//
// ONE axis, not two. The previous model complected ENV=production|local with
// LUX_NETWORK=mainnet|testnet|devnet, which made four of the six combinations
// meaningless. `LUX_NETWORK` is now rejected outright rather than silently
// half-honoured (see the guard below).
import { createHash } from 'crypto'
import { readFileSync } from 'fs'
import { homedir } from 'os'
import { join, resolve } from 'path'
import { load } from 'js-yaml'
export type EnvName = 'local' | 'devnet' | 'testnet' | 'mainnet'
export type NetworkKey = 'mainnet' | 'testnet' | 'devnet'
export type Org = 'lux' | 'zoo' | 'hanzo' | 'spc' | 'pars'
export const ORGS: Org[] = ['lux', 'zoo', 'hanzo', 'spc', 'pars']
export const NETWORKS: NetworkKey[] = ['mainnet', 'testnet', 'devnet']
// ── The registry (../NETWORKS.yaml) ────────────────────────────────────────
interface RegistryOrg {
explorer?: string
api?: string
network_ids?: Record<NetworkKey, number>
chains?: Record<string, { chain_ids?: Record<NetworkKey, number> }>
}
const REGISTRY_PATH = resolve(__dirname, '..', 'NETWORKS.yaml')
const REGISTRY = load(readFileSync(REGISTRY_PATH, 'utf8')) as Record<string, RegistryOrg>
// An org declares its EVM chain IDs on its one EVM chain entry (c-chain for
// Lux, <org>-evm for the app chains). Find it by shape, never by hard-coded key
// — new chains can be added to the registry without touching this file.
function chainIdsOf(org: Org): Record<NetworkKey, number> {
for (const chain of Object.values(REGISTRY[org]?.chains ?? {})) {
if (chain?.chain_ids) return chain.chain_ids
}
throw new Error(`NETWORKS.yaml: no chain_ids declared for org "${org}" (${REGISTRY_PATH})`)
}
// chainId per network per org, straight out of the registry.
export const CHAIN_IDS: Record<NetworkKey, Record<Org, number>> = (() => {
const perOrg = Object.fromEntries(ORGS.map((o) => [o, chainIdsOf(o)])) as Record<Org, Record<NetworkKey, number>>
const table = {} as Record<NetworkKey, Record<Org, number>>
for (const net of NETWORKS) {
table[net] = Object.fromEntries(ORGS.map((o) => [o, perOrg[o][net]])) as Record<Org, number>
}
return table
})()
// Lux PRIMARY-NETWORK id (1/2/3) — distinct from the C-Chain EVM chainId.
export const NETWORK_IDS: Record<NetworkKey, number> = REGISTRY.lux.network_ids!
// ── Hostname derivation ────────────────────────────────────────────────────
// Measured 2026-07-26 (dig + curl), not assumed:
// api.lux.network → 0x17871 (96369) api.lux-test.network → 0x17870 (96368)
// api.lux-dev.network → 0x1786f (96367)
// explore.lux.network / explore-test.lux.network / explore-dev.lux.network
// all resolve to 134.199.138.27; test-explore.lux.network is NXDOMAIN.
// So the suffix lands on the ORG label of an api host, and on the SERVICE label
// of an explorer host.
const SUFFIX: Record<NetworkKey, string> = { mainnet: '', testnet: '-test', devnet: '-dev' }
// api.<org>.network → api.<org><sfx>.network
export function apiHost(org: Org, network: NetworkKey): string {
const [svc, label, ...rest] = `api.${org}.network`.split('.')
return [svc, label + SUFFIX[network], ...rest].join('.')
}
// explore[-<org>].lux.network → explore[-<org>]<sfx>.lux.network
export function exploreHost(org: Org, network: NetworkKey): string {
const declared = REGISTRY[org]?.explorer
if (!declared) throw new Error(`NETWORKS.yaml: org "${org}" declares no explorer host`)
const [svc, ...rest] = declared.split('.')
return [svc + SUFFIX[network], ...rest].join('.')
}
// The one way to name an org's public API base for a given network.
export function apiBase(org: Org, network: NetworkKey): string {
return `https://${apiHost(org, network)}`
}
// ── Env resolution ─────────────────────────────────────────────────────────
if (process.env.LUX_NETWORK) {
throw new Error(
'LUX_NETWORK is retired — the suite has ONE axis now. Use LUX_ENV=local|devnet|testnet|mainnet.',
)
}
const RAW = (process.env.LUX_ENV || 'mainnet').toLowerCase()
const NAMES: EnvName[] = ['local', 'devnet', 'testnet', 'mainnet']
if (!NAMES.includes(RAW as EnvName)) {
throw new Error(`LUX_ENV="${RAW}" is not one of ${NAMES.join(' | ')}`)
}
export const ENV_NAME = RAW as EnvName
// Which column of the registry applies. `local` boots a mainnet-GENESIS network
// (`make start network=main` → 127.0.0.1:9630, chainId 96369, networkID 1), so
// it reads the mainnet column; only the endpoints move to localhost. A local
// testnet/devnet (9640/9650) is deliberately NOT a fifth env — it would give
// two axes back, and its chainId would contradict this column.
export const NETWORK_KEY: NetworkKey = ENV_NAME === 'local' ? 'mainnet' : ENV_NAME
// The registry's declared Lux api host must agree with what we derive, or the
// registry has drifted from reality — fail loudly instead of testing a fiction.
{
const declared = REGISTRY.lux.api
const derived = apiHost('lux', 'mainnet')
if (declared && declared !== derived) {
throw new Error(`NETWORKS.yaml lux.api="${declared}" but this module derives "${derived}"`)
}
}
// ── Surface URLs ───────────────────────────────────────────────────────────
// Only RPC + explorers are per-network. Every app surface (cloud, id, wallet,
// bridge, exchange, staking, mpc, safe) is a SINGLE mainnet-backed deployment
// shared by all remote envs — measured: test.lux.cloud and dev.lux.cloud both
// return 404, and lux.id is the one IdP for every env. Pretending otherwise
// would give the owner green tests against hosts that do not exist.
export interface Urls {
// Lux primary network
rpc: string
rpcInfo: string
rpcHealth: string
rpcPlatform: string
rpcXChain: string
rpcZoo: string
// Explorers + their APIs
exploreLux: string
exploreZoo: string
exploreHanzo: string
exploreSpc: string
explorePars: string
apiExploreLux: string
apiExploreZoo: string
apiExploreHanzo: string
apiExploreSpc: string
apiExplorePars: string
// Apps
exchange: string
// The SAME exchange deployment, addressed by its cluster hostname instead of
// its vanity domain. Not a second product — a second door to one product.
// MEASURED 2026-07-26: `https://lux.exchange` fails the TLS handshake from
// this network in all three stacks (curl → `tlsv1 alert protocol version`,
// python/openssl → same, chromium → net::ERR_SSL_PROTOCOL_ERROR) while
// `https://exchange.lux.network` serves the identical app 200 with a 4,589-
// char body on every one of the 22 routes probed. Hard-coding only the
// vanity host is why 02-exchange went red and 20-exchange-deep skipped all
// 36 of its cases — 49 routes of a shipped product tested by nothing.
// exchangeBase() in helpers.ts picks the reachable door and SAYS which.
exchangeOrigin: string
bridge: string
staking: string
cloud: string
wallet: string
mpc: string
id: string
safe: string
// Operator surfaces. Measured 2026-07-26: superadmin.lux.cloud → 200
// "Lux Admin" with /login /dashboard /users /orgs /settings all served;
// admin.lux.cloud → 401 "authentication required" (an ingress DOES exist —
// the earlier "404, no ingress" reading was of a different cluster).
admin: string
superadmin: string
}
// The one local stack: `make start network=main` for the node (:9630), and
// ./local/up.sh for the /v1 backend (hanzo cloud everything-binary, IAM
// included, :8899).
const LOCAL_RPC = 'http://127.0.0.1:9630'
const LOCAL_IAM = 'http://127.0.0.1:8899'
function remoteUrls(net: NetworkKey): Urls {
const lux = apiBase('lux', net)
return {
rpc: `${lux}/v1/chain/c/rpc`,
rpcInfo: `${lux}/v1/info`,
rpcHealth: `${lux}/v1/health`,
rpcPlatform: `${lux}/v1/chain/p`,
rpcXChain: `${lux}/v1/chain/x`,
rpcZoo: `${apiBase('zoo', net)}/v1/chain/c/rpc`,
exploreLux: `https://${exploreHost('lux', net)}`,
exploreZoo: `https://${exploreHost('zoo', net)}`,
exploreHanzo: `https://${exploreHost('hanzo', net)}`,
exploreSpc: `https://${exploreHost('spc', net)}`,
explorePars: `https://${exploreHost('pars', net)}`,
apiExploreLux: `https://api-${exploreHost('lux', net)}`,
apiExploreZoo: `https://api-${exploreHost('zoo', net)}`,
apiExploreHanzo: `https://api-${exploreHost('hanzo', net)}`,
apiExploreSpc: `https://api-${exploreHost('spc', net)}`,
apiExplorePars: `https://api-${exploreHost('pars', net)}`,
exchange: 'https://lux.exchange',
exchangeOrigin: 'https://exchange.lux.network',
bridge: 'https://bridge.lux.network',
staking: 'https://lux.build',
cloud: 'https://lux.cloud',
wallet: 'https://wallet.lux.network',
mpc: 'https://mpc.lux.network',
id: 'https://lux.id',
safe: 'https://safe.lux.network',
admin: 'https://admin.lux.cloud',
superadmin: 'https://superadmin.lux.cloud',
}
}
function localUrls(): Urls {
return {
rpc: `${LOCAL_RPC}/v1/chain/c/rpc`,
rpcInfo: `${LOCAL_RPC}/v1/info`,
rpcHealth: `${LOCAL_RPC}/v1/health`,
rpcPlatform: `${LOCAL_RPC}/v1/chain/p`,
rpcXChain: `${LOCAL_RPC}/v1/chain/x`,
rpcZoo: `${LOCAL_RPC}/v1/chain/zoo/rpc`,
exploreLux: 'http://localhost:4010',
exploreZoo: 'http://localhost:4011',
exploreHanzo: 'http://localhost:4012',
exploreSpc: 'http://localhost:4013',
explorePars: 'http://localhost:4014',
apiExploreLux: 'http://localhost:4010',
apiExploreZoo: 'http://localhost:4011',
apiExploreHanzo: 'http://localhost:4012',
apiExploreSpc: 'http://localhost:4013',
apiExplorePars: 'http://localhost:4014',
exchange: 'http://localhost:3200',
exchangeOrigin: 'http://localhost:3200',
bridge: 'http://localhost:3400',
staking: 'http://localhost:3500',
cloud: 'http://localhost:3600',
wallet: 'http://localhost:3300',
mpc: 'http://localhost:3700',
id: LOCAL_IAM,
safe: 'http://localhost:3800',
admin: 'http://localhost:3600',
superadmin: 'http://localhost:3600',
}
}
// ── OIDC contract ──────────────────────────────────────────────────────────
// Policy, one place: clientId is ALWAYS <org>-<app>, the browser redirect is
// ALWAYS https://<host>/auth/callback (never /api/...), and a browser client is
// PUBLIC — it carries a PKCE code_challenge and no secret. Measured on
// lux.cloud 2026-07-26: the handoff lands on
// lux.id/login/oauth/authorize?client_id=lux-cloud&code_challenge=…
// &code_challenge_method=S256&redirect_uri=https://lux.cloud/auth/callback
export const OIDC = {
clientId: 'lux-cloud',
callbackPath: '/auth/callback',
challengeMethod: 'S256',
// IAM routes ONE authorize path (iam internal/oidc/oidc.go PathAuthorize).
authorizePath: '/v1/iam/oauth/authorize',
tokenPath: '/v1/iam/oauth/token',
jwksPath: '/v1/iam/.well-known/jwks',
} as const
// The one way to build an IAM authorize URL. Hand-building `${iam}/oauth/
// authorize` or `/oauth2/authorize` is the trap: the IdP SPA answers ANY
// unrouted path with 200 text/html, so a hand-built URL fails SILENTLY and a
// test that merely asserts a 2xx/3xx passes on a dead endpoint.
//
// Measured on lux.id 2026-07-27 (same query on each):
// /v1/iam/oauth/authorize 400 text/plain "authorization error: invalid redirect_uri" <- REAL
// /oauth/authorize 200 text/html (SPA shell)
// /oauth2/authorize 200 text/html (SPA shell)
// /zzz-control 200 text/html (SPA shell) <- control: 200 proves nothing
export function authorizeUrl(iamBase: string, params: Record<string, string>): string {
return `${iamBase.replace(/\/$/, '')}${OIDC.authorizePath}?${new URLSearchParams(params)}`
}
// A PKCE pair for a PUBLIC client, which lux-cloud is. IAM refuses an authorize
// request without one — measured 2026-08-08, the request lands back on the
// caller as
// /auth/callback?error=invalid_request
// &error_description=PKCE+is+required+for+public+clients
// which is a REDIRECT, so `isRealAuthorizeResponse` rightly calls it real and
// the test then fails on the missing login form instead. The endpoint was never
// the problem; the request was incomplete.
//
// The verifier is fixed rather than random: a test that builds a fresh secret
// each run cannot be re-run against the same authorize code, and nothing here
// exchanges it — the challenge only has to be well-formed at authorize time.
export function pkce(): { verifier: string; challenge: string; method: string } {
const verifier = 'lux-e2e-pkce-verifier-0000000000000000000000000000'
const challenge = createHash('sha256').update(verifier).digest('base64url')
return { verifier, challenge, method: OIDC.challengeMethod }
}
// An authorize response is REAL only when it is not the SPA catch-all: either a
// redirect to the login page, or a text/plain OAuth error from the endpoint
// itself. A 200 text/html is the shell — always a failure.
export function isRealAuthorizeResponse(status: number, contentType: string): boolean {
if (status >= 300 && status < 400) return true // redirect to the login page
return !/text\/html/i.test(contentType) // anything else must not be the shell
}
// ── Concurrency the front door tolerates ───────────────────────────────────
// MEASURED, not guessed. Mainnet's api host is CDN-fronted (dig → 172.67.x) and
// served the whole gate at 4 workers with 342/342 green. Testnet resolves
// straight to the cluster LB 134.199.138.27 with no CDN in front: the same gate
// at 4 workers lost 66 cases to gateway throttling, and at 2 workers every one
// of them passed (the only remaining failures were a genuinely dead host).
// 00-health alone passes at 4 on testnet — it is AGGREGATE load that tips it, so
// the limit belongs to the environment, not to any one spec. Devnet shares that
// same LB and gets the same budget. Local has no network in the way.
export const WORKERS: Record<EnvName, number> = {
local: 4,
mainnet: 4,
testnet: 2,
devnet: 2,
}
// ── Liveness: what "the chain is alive" MEANS ──────────────────────────────
// The single biggest hole in this suite was that nothing asserted the chain was
// moving. Every liveness test reduced to `eth_blockNumber > 0`, which a chain
// frozen for two and a half days answers perfectly — mainnet has been stuck at
// 1,098,191 for 58.9 hours and the whole suite was green.
//
// Readiness is worthless here: all 15 pods read 5/5 Ready and every public API
// returned 200 on all three networks while one of them had not produced a block
// since Thursday. Gate on the TIP, never on readiness.
//
// A fixed "block every N seconds" threshold is also wrong — these chains are
// DEMAND-DRIVEN, so an idle one legitimately produces nothing until something
// asks. MEASURED inter-block gaps over the last 8 blocks, 2026-07-26:
//
// mainnet 27375, 100, 9, 2, 26, 5, 2, 2 → median 7s (then it stopped)
// testnet 290, 290, 327, 258, 320, 322, 288, 312 → median ~300s
// devnet 605, 300, 290, 305, 255, 345, 300, 290 → median ~300s
//
// Testnet and devnet sit at a 5-minute cadence because a heartbeat CronJob
// writes to them; mainnet has no heartbeat and produced at seconds-scale while
// there was demand. One constant cannot serve both — so the gate CALIBRATES
// ITSELF: the tip may be no older than STALE_GAP_MULTIPLE × the median recent
// gap, floored at STALE_FLOOR_SEC so a fast chain is not failed by one slow
// CDN round trip. Applied to the numbers above:
//
// mainnet max(4×7, 600) = 600s vs tip age 211,947s → RED (correct)
// testnet max(4×300, 600) = 1200s vs tip age 218s → green
// devnet max(4×300, 600) = 1200s vs tip age 198s → green
//
// Never raise these to get mainnet green. The freeze is the finding.
export const LIVENESS = {
/** How many recent blocks to sample when deriving the chain's own cadence. */
gapSamples: 8,
/** Tip may be this many median-gaps old before the chain counts as stalled. */
staleGapMultiple: 4,
/** Floor for the derived budget, so a 2s-block chain tolerates a slow hop. */
staleFloorSec: 600,
/** Window for the monotonicity/reorg sample. Shorter than any cadence above:
* it is deliberately NOT an advancement test (see 00-liveness.spec.ts). */
reorgWindowSec: 20,
} as const
// ── The resolved environment ───────────────────────────────────────────────
export interface Env {
name: EnvName
network: NetworkKey
networkId: number
chainIds: Record<Org, number>
urls: Urls
workers: number
isLocal: boolean
}
export const env: Env = {
name: ENV_NAME,
network: NETWORK_KEY,
networkId: NETWORK_IDS[NETWORK_KEY],
chainIds: CHAIN_IDS[NETWORK_KEY],
urls: ENV_NAME === 'local' ? localUrls() : remoteUrls(NETWORK_KEY),
workers: WORKERS[ENV_NAME],
isLocal: ENV_NAME === 'local',
}
// ── Indexed chains ─────────────────────────────────────────────────────────
// Which chains have a live indexer, asked and answered ONCE. Four specs used
// to carry their own copy of this list, so one wrong entry was four red tests.
//
// SPC is deliberately absent. There is no SPC chain to index anywhere, and the
// explorer says so itself: k8s/lux-mainnet/explorer.yaml sets `enabled: false`
// on slug `spc`, so the supervisor never mounts /v1/indexer/spc and
// api-explore-spc.lux.network answers `404 page not found` in text/plain where
// a stats document belongs. Restore the entry the day an SPC L1 exists and the
// indexer is enabled — the chain stays in REGISTRY either way, because it is a
// declared network; this list is about what is running, not what is planned.
export interface Explorer { org: Org; name: string; api: string; ui: string }
export const EXPLORERS: Explorer[] = [
{ org: 'lux', name: 'Lux C-Chain', api: env.urls.apiExploreLux, ui: env.urls.exploreLux },
{ org: 'zoo', name: 'Zoo', api: env.urls.apiExploreZoo, ui: env.urls.exploreZoo },
{ org: 'hanzo', name: 'Hanzo', api: env.urls.apiExploreHanzo, ui: env.urls.exploreHanzo },
{ org: 'pars', name: 'Pars', api: env.urls.apiExplorePars, ui: env.urls.explorePars },
]
// The one way to resolve any org's C-Chain RPC. Omit `network` for the active
// env (local-aware); pass one explicitly to reach that network's PUBLIC
// endpoint — which is what a cross-network spec wants even from a local run.
// Never hand-build /mainnet or /ext paths: every network serves flat /v1.
export function orgRpc(org: Org, network: NetworkKey = env.network): string {
if (env.isLocal && network === env.network) {
return org === 'lux' ? env.urls.rpc : `${LOCAL_RPC}/v1/chain/${org}/rpc`
}
return `${apiBase(org, network)}/v1/chain/c/rpc`
}
// ── Viewports ──────────────────────────────────────────────────────────────
// Implemented as Playwright PROJECTS, so "mobile and desktop" is the same three
// frames on every run and screenshots are comparable across runs and envs.
export interface Viewport { name: string; width: number; height: number }
export const VIEWPORTS: Viewport[] = [
{ name: 'mobile', width: 390, height: 844 }, // iPhone 15 Pro
{ name: 'tablet', width: 834, height: 1194 }, // iPad Air portrait
{ name: 'laptop', width: 1440, height: 900 }, // MacBook Air
]
// Name the frame a page is actually rendering at. Falls back to WxH so a stray
// viewport is visible in the filename rather than silently overwriting a frame.
export function viewportName(size: { width: number; height: number } | null): string {
if (!size) return 'headless'
return VIEWPORTS.find((v) => v.width === size.width && v.height === size.height)?.name
?? `${size.width}x${size.height}`
}
// ── Screenshots ────────────────────────────────────────────────────────────
// ~/Desktop/lux-e2e/<set>/<surface>-<viewport>.png — written on pass AND on
// fail. A failure you cannot look at is a failure you cannot fix.
//
// The SET is the env name by default, so the matrix runs never collide. A
// deliberate one-off capture names its own set instead (LUX_SHOT_SET=
// institutional-baseline) and reuses this exact path machinery — a second
// screenshot helper is how two runs end up with two naming schemes.
export const SHOT_SET = process.env.LUX_SHOT_SET || ENV_NAME
export const SHOT_DIR = join(homedir(), 'Desktop', 'lux-e2e', SHOT_SET)
export function shotPath(surface: string, viewport: string): string {
return join(SHOT_DIR, `${surface}-${viewport}.png`)
}
// ── Credentials ────────────────────────────────────────────────────────────
// NEVER hardcoded. The password comes from the environment only; a spec that
// needs it must FAIL (not skip) when it is missing — a skipped login proves
// nothing, and silence is how a broken login ships.
export const CREDS = {
user: process.env.LUX_E2E_USER || '[email protected]',
password: process.env.LUX_E2E_PASSWORD || '',
}
export const PASSWORD_HINT =
'LUX_E2E_PASSWORD is not set — export it before running the login spec (never commit it).'
// One-line banner so every run states what it actually tested.
export function envBanner(): string {
return `LUX_ENV=${env.name} network=${env.network} networkId=${env.networkId} `
+ `chainId=${env.chainIds.lux} rpc=${env.urls.rpc}`
}