Merge remote-tracking branch 'origin/main' into fix/dex-real-subgraph

# Conflicts:
#	lib/api/dchain/useDexData.ts
#	ui/dex/DexPage.tsx
This commit is contained in:
2026-06-30 15:27:12 -07:00
43 changed files with 2248 additions and 1489 deletions
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="explore">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="17 26 66 66"><path d="M50 88 L17.09 31 L82.91 31 Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">explore</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Lux Network Blockchain Explorer.</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/luxfi</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">lux.network</text>
</svg>

After

Width:  |  Height:  |  Size: 1004 B

+187
View File
@@ -166,6 +166,193 @@ All symlinks reference this single source of truth.
## Recent Changes
### Chain-visibility + 10-VM/DEX handling (v1.0.14)
Two orthogonal correctness fixes shipped in one build cycle.
- **Chain-visibility rule (PR #7):** `isPrimaryNetworkExplorer()` now requires
`!isWhiteLabelMode() && vm === 'EVM'`, so unregistered/brand hosts stop
rendering the Lux primary panels + cross-L1 list. Registered
`explore.zoo.network`/`.ngo` + `explore.pars.network` as own-scoped L2s
(brand parity with Hanzo), added **Osage** L1, and fixed Pars chainId →
**7070**. Rule: **lux explorer = ALL chains; hanzo/zoo/pars = ONLY their own.**
Proven by `configs/app/chainRegistry.spec.ts` (12 cases).
- **10-VM / DEX handling:** the Lux primary network is a family of VM-specialized
chains (C=EVM, X=UTXO, D=DEX, P=platform, + A/B/Q/T/Z/G/K/O/R/I/M), mirrored
from `~/work/lux/node/node/vms.go`. New single source of truth
`configs/app/primaryChains.ts` (`PRIMARY_VMS`, `getPrimaryVm`, `view`,
`hasBespokeView`, `chainsAwaitingBespokeView`) **decomplects** the two lists
that used to duplicate this (ChainsPage / ChainDetailPage now consume it).
Each VM routes at `/chains/<slug>`; `view` selects the render:
- **D-Chain → the DEX order-book UI** (`ui/dex/DexPage`, wired to the indexer
`./dex` adapter via `lib/api/dchain`) — `ChainDetailPage` renders `<DexPage/>`
for `view: 'dex'`, not a generic block list.
- **C-Chain** = the EVM explorer core; **P-Chain** = platform/validators data;
**X-Chain** + the custom VMs (A/B/Q/T/Z/G/K/O/R/I/M) render the generic
chain-detail fallback (info + indexer DAG stats + validators) — they do NOT
crash/blank. `chainsAwaitingBespokeView()` FLAGS the backlog: **X-Chain
(UTXO) and the 11 custom VMs still need a bespoke explorer view**; only
C/P/D have one today.
- **VMs are Lux-primary → lux-only:** the primary-VM surfaces (Chains / DEX /
Bridge / AI Compute nav + the `/chains`, `/chains/[slug]`, `/dex` pages) are
gated on `isPrimaryNetworkExplorer()`. Nav hides them on brand/white-label
hosts; `ui/shared/PrimaryNetworkGuard` backstops direct-URL access so the 10
VMs can never leak onto Hanzo/Zoo/Pars. Proven by
`configs/app/primaryChains.spec.ts` (26 cases).
- **Build/deploy:** single multi-destination kaniko Job (NO GHA), context
`git://github.com/luxfi/explore.git#main`, Dockerfile (base Next.js build),
fan-out `--destination=ghcr.io/{luxfi,zooai,hanzoai,parsdao}/explore:v1.0.14`
(parsdao ADDED — it had no v1.0.1x image). White-label is 100% runtime via the
`explore-env-{brand}` configmaps (APP_HOST), not baked per-brand. Deploy = bump
all four `explore-fe-*` images to v1.0.14 in
`luxfi/universe k8s/lux-mainnet/explore-fe.yaml`, operator reconcile.
### Realtime transport: Phoenix WebSocket → SSE (v1.0.13)
- **Bug:** the FE connected realtime via Phoenix Channels
(`wss://api-explore.lux.network/socket/v2/websocket`), but the Go backend
(`luxfi/explorer`) serves **no** Phoenix endpoint — its realtime is **SSE**
at `/v1/base/realtime` (multiplexed, envelope `{event, chain, data}`, initial
`event: CONNECT`, `: ping` keep-alive). Result: repeating `ws 404` →
"Live updates temporarily delayed". This is a frontend-only transport
mismatch; the chain/indexer are untouched.
- **Fix:** `lib/socket/sse.ts` reimplements the slice of the Phoenix
`Socket`/`Channel` API the 27 realtime consumers use, backed by ONE
`EventSource`. The seam is unchanged — `SocketProvider` / `useSocketChannel`
/ `useSocketMessage` and every consumer keep working; only the transport
underneath swapped. No more `phoenix` import in app code.
- **Routing:** envelope `{event, chain, data, topic?}`. With `topic` →
delivered straight to that channel/event (scoped `addresses:*`/`tokens:*`,
and the component-test harness). Without → the `TRANSLATIONS` table maps the
backend hub channel to a Blockscout `(topic, event)`; today the indexer
broadcasts only `blocks` → `blocks:new_block` / `new_block`. `join()` resolves
`ok` on stream open; EventSource auto-reconnects. URL normaliser collapses any
`wss://…/socket*` (or test `ws://host:port`) to `<https>/v1/base/realtime`.
- **Tests:** `lib/socket/sse.spec.ts` (12 vitest cases, all green) covers URL
normalisation, blocks→new_block translation, direct-topic routing, join `ok`,
on/off, onError, disconnect. CT harness `playwright/fixtures/socketServer.ts`
is now a tiny SSE server (same `createSocket`/`joinChannel`/`sendMessage`
signatures) so the 12 consumer specs are unchanged.
- **Still owner-gated (separate, NOT this fix):** data freshness on
explore.lux.network is blocked on the C-Chain rollback + indexer reorg
handling — no fresh blocks flow until that lands. When it does, align the
EVMBlock→Blockscout `Block` field shapes in the `blocks` translation (or have
the backend emit Blockscout-shaped blocks); that is the only remaining piece
and it can only be verified end-to-end once data flows again.
### Investor-grade data: real validators + honest-empty stats/DEX (v1.0.11)
Removed every fabricated placeholder; data is now real-from-chain or honestly
empty, never faked. Root causes + fixes:
- **Validators/Stake = 0 was a proxy bug, not missing data.** P-chain
`platform.getCurrentValidators` returns real validators (5, total weight
2.5e18 on hanzo). `pages/api/pchain.ts` derived its base URL by stripping
ONLY `/ext/bc/C/rpc` via regex; brand RPCs are `…/ext/bc/<chain>/rpc`
(hanzo: `/ext/bc/hanzo/rpc`), so the regex no-op'd and the proxy POSTed to
`…/ext/bc/hanzo/rpc/ext/bc/P` → HTML 404 → "Unexpected non-whitespace
character after JSON at position 4" → 502. Fix: `new URL(rpcUrl).origin`
(works for every brand) + defensive text→JSON parse with a clear error.
`ui/stats/lux/NetworkStats.tsx` (Network Overview: Total Chains / Validators
/ Connected / Total Stake / Avg Uptime) already had honest `—` fallbacks and
now renders REAL values. `lib/api/luxnet/instance.ts` was already safe (uses
`new URL().hostname`), so B/D-Chain SDK calls were unaffected.
- **"Placeholder Counter 9.074M ×8"** came from `stubs/stats.ts` `STATS_COUNTER`
(`value:'9074405', title:'Placeholder Counter'`) used as React-Query
`placeholderData` in `ui/stats/NumberWidgetsList.tsx`. When the Blockscout
**stats microservice** is absent (hanzo has NO `NEXT_PUBLIC_STATS_API_HOST`;
every `api-explore.hanzo.network/api/v1|v2/*` 404s) the stub leaked as "real".
Fix: neutralize the stub (`title:'', value:'0'`) AND gate the counters/charts
in `ui/pages/Stats.tsx` on `config.features.stats.isEnabled` — show the live
chain-sourced Network Overview always, with an honest "being indexed" note
when no stats service. Aggregate counters (total txns/addresses, gas-used-
today, history charts) genuinely need a stats indexer that isn't deployed.
- **DEX showed static demo numbers.** `lib/api/dchain/useDexData.ts` had
hardcoded `DEMO_SYMBOLS/ORDERS/TRADES/POOLS/OVERVIEW` (LUX/USDT 24.85, etc.)
served when the D-Chain (DexVM) is unavailable. Deleted ALL demo data → real
D-Chain data or honest-empty; `ui/dex/DexPage.tsx` renders an honest empty/
error message per tab.
- **Bridge signers = 0 is already honest** (`lib/api/bchain/useBridgeData.ts`
returns real B-Chain signer set or `EMPTY_STATS`; B-Chain not yet reporting).
- **Homepage is already investor-grade**: real RPC blocks (#8–#13), tx hashes,
gas (2.5 Gwei), Total blocks/txns/wallets — `ui/home/Stats.tsx` degrades to
RPC when the general indexer errors; `HOMEPAGE_STATS` stub is placeholder-only.
- **Blockchain dropdown clipped its last item** ("Verified Contracts"):
`@luxfi/ui` `Tooltip` `variant="popover"` Content defaults to
`overflow-hidden px-3 py-2`, clipping a tall menu `<ul>`. Fixed at the
consumer (`ui/snippets/navigation/horizontal/NavLinkGroup.tsx`) via
`contentProps={{ className:'overflow-visible px-1.5 py-2' }}` (twMerge wins)
+ `placement:'bottom-start'`. No node_modules patch.
- Still needs a real indexer (honest-empty until then): aggregate Stats
counters + history charts (Blockscout stats microservice), DEX live
markets/orders/trades (DexVM indexer), Bridge signer set (B-Chain).
- Built v1.0.11 via the kaniko Job pattern (NO GHA) → `ghcr.io/{hanzoai,zooai,
luxfi}/explore:v1.0.11`; deploy = image patch of `explore-fe-*` Deployments
on do-sfo3-lux-k8s ns `lux-mainnet` (plain Deployments, not operator-managed).
### Validators page: visible in light mode + brand currency (v1.0.12)
v1.0.11 fetched REAL P-chain validators (5, total weight 2.5e18 on hanzo) but
a Playwright pass found the `/validators` view rendered **white-on-white in
light mode** — the data was present in the DOM yet invisible to users (only the
tab bar showed because it alone used `text-[var(--color-text-primary)]`). The
three custom Lux components (`ui/validators/lux/{ValidatorsDashboard,
ValidatorsList,DelegatorsList}.tsx`) set NO text color, so they inherited a
white color that doesn't flip with the theme. Fixes:
- Set `text-[var(--color-text-primary)]` on each component's root container —
one place per component; the token flips per `styles/tokens.css` (light
`rgba(16,17,18,.80)` / dark `rgba(255,255,255,.80)`), so all descendants now
render the mode-correct color (dark on light, light on dark).
- Stake unit was hardcoded `LUX` on every brand; now `config.chain.currency.
symbol || 'LUX'` (= `AI` on Hanzo) — matches `NetworkStats.tsx`'s pattern,
DRY across all 6 stake labels in the three components.
- NOT changed: `formatStake` uses `LUX_DECIMALS = 6`, the codebase-wide
convention (NetworkStats + ChainDetailPage use the same); P-chain
`getMinStake` returns `minValidatorStake:2000000000` (= 2000 at 6dp),
consistent with 6. (Three copies of this helper is a latent DRY violation to
consolidate later, but the value is correct — left as-is to avoid drift.)
- `next.config.js` has `typescript.ignoreBuildErrors:true`; repo carries ~224
pre-existing tsc errors (all in `*.pw.tsx` / unrelated) — build is unaffected.
Dockerfile uses **pnpm** (not yarn). Edited files lint+typecheck clean.
- Built v1.0.12 via the kaniko Job (NO GHA) → `ghcr.io/{hanzoai,zooai,luxfi}/
explore:v1.0.12`; deployed by image patch of `explore-fe-{hanzo,lux,zoo}` on
do-sfo3-lux-k8s ns `lux-mainnet`. (Built on do-sfo3-hanzo-k8s ns `hanzo`.)
### Multi-brand hostname-driven header logo (explore v1.0.10)
- ONE image white-labels by request Host: `configs/app/chainRegistry.ts`
`getCurrentChain()` resolves the chain (and its brand) from the hostname,
and the header renders `chain.branding.logoContent` (an inline-SVG mark
using `currentColor`). NO hardcoded logo, no image-URL→triangle fallback.
- Per-brand marks live in `chainRegistry.ts`: Lux = downward triangle
(viewBox `0 0 100 100`), Hanzo = real blocky-H (`@hanzo/logo`, viewBox
`0 0 67 67`, 5 paths — `M22.21 67…`), Zoo = interlocking circles
(`0 0 1024 1024`), Pars = 8-pointed star, SPC = unicorn.
- The header logo renders in THREE spots, all reading `branding`:
`ui/snippets/topBar/TopBar.tsx` (desktop), and
`ui/snippets/networkLogo/{NetworkLogo,NetworkIcon}.tsx` (mobile header
`ui/snippets/header/HeaderMobile.tsx` + chain-switcher). The stale
pre-v1.0.10 mobile `NetworkIcon` hardcoded a `viewBox="0 0 50 50"`
`<polygon>` triangle mislabeled `aria-label="Hanzo icon"` — fixed in
4369780 (mobile) + 79dbe63 (desktop, single header per breakpoint).
- Multi-tenant L1/L2/L3: each `ChainEntry` has a `vm` field
(`EVM` = Lux primary L1, `L2` = brand sovereign chains) gating
`isPrimaryNetworkExplorer()` so brand explorers show ONLY their own chain.
mainnet/testnet/devnet/localnet entries per brand; hostnames cover both
canonical (`explore-<brand>.lux.network`) and brand-domain
(`explore.<brand>.network`, `explore.hanzo.ai`) hosts.
### Build + deploy (NO GitHub builders)
- Built on-cluster via kaniko (do-sfo3-hanzo-k8s ns `hanzo`, node pool
`runner-pool-32g`, secret `kaniko-ghcr-multi`, toleration
`dedicated=ci-runner`): context `git://github.com/luxfi/explore.git#main`,
`--dockerfile Dockerfile`, fan-out `--destination` to per-brand GHCR orgs
`ghcr.io/{luxfi,zooai,hanzoai}/explore:v1.0.10` (one digest,
`sha256:4f980291…`). pars (`ghcr.io/parsdao/explore`) not in the fan-out
yet → stays v1.0.4.
- Runs on do-sfo3-lux-k8s ns `lux-mainnet` as `explore-fe-<brand>`
Deployments (selector `{app:explore-fe,brand,network}`), env from
`explore-env-<brand>` ConfigMap, pull secret `ghcr-luxfi`, svc 80→3000.
NOT operator-managed (plain Deployments) → bump via image patch / apply.
Declared state: `luxfi/universe k8s/lux-mainnet/explore-fe.yaml`
(+ `k8s/lux-devnet/explore-fe.yaml`).
- App hydrates client-side (SSR HTML is an 8 KB shell) — verify the logo
with a real browser (Playwright), not `curl | grep`.
### 2024-12-24
- Merged upstream blockscout/frontend (up to commit 5a49ad8b1)
- Updated branding from Blockscout to Lux Network
+2
View File
@@ -1,3 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="explore" width="880"></p>
# DEPRECATED — see `~/work/lux/explorer/`
> The standalone Next.js frontend in this repo is no longer deployed.
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it, beforeEach } from 'vitest';
import {
getChainsForNetwork,
getCurrentChain,
getCurrentNetwork,
isPrimaryNetworkExplorer,
isWhiteLabelMode,
} from './chainRegistry';
// Drives the registry off a simulated request host, exactly like the browser
// (getHostname() reads window.location.hostname). window.__envs is emptied so
// no NEXT_PUBLIC_* override leaks in — we assert the bundled defaults.
function atHost(hostname: string) {
// Minimal browser global so isBrowser() is true and getHostname() reads the
// simulated request host — no jsdom needed.
(globalThis as unknown as { window: unknown }).window = { location: { hostname }, __envs: {} };
}
// Mirrors the pure logic of ui/snippets/topBar/ChainSwitcher.tsx: the lux
// primary (C-Chain) host shows every chain; a brand explorer shows only chains
// sharing its brand. Returns the chain names the switcher would render.
function switcherChainNames(): Array<string> {
const current = getCurrentChain();
const all = getChainsForNetwork(getCurrentNetwork().network);
const isMain = current.name === 'C-Chain';
return (isMain ? all : all.filter((c) => c.branding.brandName === current.branding.brandName)).map((c) => c.name);
}
describe('chain-visibility rule', () => {
beforeEach(() => atHost('localhost'));
describe('Lux primary explorer (explore.lux.network) = UNIVERSAL (shows everything)', () => {
beforeEach(() => atHost('explore.lux.network'));
it('is the primary-network explorer', () => {
expect(isWhiteLabelMode()).toBe(false);
expect(isPrimaryNetworkExplorer()).toBe(true);
expect(getCurrentChain().name).toBe('C-Chain');
});
it('lists every sovereign L1 incl. Osage', () => {
const names = switcherChainNames();
expect(names).toEqual(expect.arrayContaining([ 'C-Chain', 'Zoo', 'Hanzo', 'SPC', 'Pars', 'Osage' ]));
});
});
describe('Brand explorers = OWN-ONLY (no lux primary, no other orgs)', () => {
const cases = [
{ host: 'explore.hanzo.network', name: 'Hanzo', brandName: 'Hanzo AI' },
{ host: 'explore.zoo.network', name: 'Zoo', brandName: 'Zoo Chain' },
{ host: 'explore.pars.network', name: 'Pars', brandName: 'Pars Network' },
{ host: 'explore.osage.network', name: 'Osage', brandName: 'Osage' },
];
it.each(cases)('$host is a registered L2, NOT the primary explorer (no leak)', ({ host, brandName }) => {
atHost(host);
expect(isWhiteLabelMode()).toBe(false);
expect(isPrimaryNetworkExplorer()).toBe(false);
expect(getCurrentChain().vm).toBe('L2');
expect(getCurrentChain().branding.brandName).toBe(brandName);
});
it.each(cases)('$host switcher shows ONLY its own chain (no lux-primary, no other orgs)', ({ host, name }) => {
atHost(host);
expect(switcherChainNames()).toEqual([ name ]);
});
it('pars uses its deployed chainId 7070 (not the stale 494949)', () => {
atHost('explore.pars.network');
expect(getCurrentChain().chainId).toBe(7070);
});
});
describe('keystone: unregistered white-label hosts are NEVER the primary explorer', () => {
it('does not leak the lux primary network to an unknown host', () => {
atHost('explore.unknown.example');
expect(isWhiteLabelMode()).toBe(true);
expect(isPrimaryNetworkExplorer()).toBe(false);
});
});
});
+78 -8
View File
@@ -248,6 +248,27 @@ const PARS_BRANDING: ChainBranding = {
'</g>',
};
/** Osage — sovereign L1 (coin OSG). Concentric-ring mark; refine via
* NEXT_PUBLIC_BRAND_* at deploy time when the real logo lands. */
const OSAGE_BRANDING: ChainBranding = {
brandName: 'Osage',
productName: 'Explorer',
orgName: 'Osage',
websiteUrl: 'https://osage.network',
description: 'Osage blockchain explorer.',
githubUrl: 'https://github.com/luxfi',
twitterUrl: 'https://x.com/luxdefi',
discordUrl: 'https://discord.gg/luxnetwork',
logoViewBox: '0 0 100 100',
logoContent:
'<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" stroke-width="8"/>' +
'<circle cx="50" cy="50" r="14" fill="currentColor"/>',
faviconContent:
'<rect width="512" height="512" rx="64" fill="#dc2626"/>' +
'<circle cx="256" cy="256" r="150" fill="none" stroke="#fff" stroke-width="36"/>' +
'<circle cx="256" cy="256" r="56" fill="#fff"/>',
};
export const CHAINS: ReadonlyArray<ChainEntry> = [
{
name: 'C-Chain',
@@ -268,7 +289,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
vm: 'L2',
network: 'mainnet',
chainId: 200200,
hostnames: [ 'explore-zoo.lux.network' ],
hostnames: [ 'explore-zoo.lux.network', 'explore.zoo.network', 'explorer.zoo.network', 'explore.zoo.ngo' ],
explorerUrl: 'https://explore-zoo.lux.network',
apiUrl: 'https://api-explore-zoo.lux.network',
branding: ZOO_BRANDING,
@@ -279,7 +300,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
vm: 'L2',
network: 'mainnet',
chainId: 36963,
hostnames: [ 'explore-hanzo.lux.network' ],
hostnames: [ 'explore-hanzo.lux.network', 'explore.hanzo.network', 'explore.hanzo.ai' ],
explorerUrl: 'https://explore-hanzo.lux.network',
apiUrl: 'https://api-explore-hanzo.lux.network',
branding: HANZO_BRANDING,
@@ -300,12 +321,23 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
label: 'Pars Network',
vm: 'L2',
network: 'mainnet',
chainId: 494949,
hostnames: [ 'explore-pars.lux.network' ],
chainId: 7070,
hostnames: [ 'explore-pars.lux.network', 'explore.pars.network', 'explorer.pars.network' ],
explorerUrl: 'https://explore-pars.lux.network',
apiUrl: 'https://api-explore-pars.lux.network',
branding: PARS_BRANDING,
},
{
name: 'Osage',
label: 'Osage Chain',
vm: 'L2',
network: 'mainnet',
chainId: 1872,
hostnames: [ 'explore-osage.lux.network', 'explore.osage.network', 'explore.osage.group' ],
explorerUrl: 'https://explore-osage.lux.network',
apiUrl: 'https://api-explore-osage.lux.network',
branding: OSAGE_BRANDING,
},
// Testnet chains
{
name: 'C-Chain',
@@ -334,8 +366,8 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
label: 'Hanzo AI (Testnet)',
vm: 'L2',
network: 'testnet',
chainId: 36964,
hostnames: [ 'explore-hanzo-test.lux.network' ],
chainId: 36962,
hostnames: [ 'explore-hanzo-test.lux.network', 'explore-test.hanzo.network' ],
explorerUrl: 'https://explore-hanzo-test.lux.network',
apiUrl: 'https://api-explore-hanzo-test.lux.network',
branding: HANZO_BRANDING,
@@ -362,6 +394,17 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
apiUrl: 'https://api-explore-pars-test.lux.network',
branding: PARS_BRANDING,
},
{
name: 'Osage',
label: 'Osage Chain (Testnet)',
vm: 'L2',
network: 'testnet',
chainId: 1871,
hostnames: [ 'explore-osage-test.lux.network' ],
explorerUrl: 'https://explore-osage-test.lux.network',
apiUrl: 'https://api-explore-osage-test.lux.network',
branding: OSAGE_BRANDING,
},
// Devnet chains
{
name: 'C-Chain',
@@ -391,7 +434,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
vm: 'L2',
network: 'devnet',
chainId: 36964,
hostnames: [ 'explore-hanzo-dev.lux.network' ],
hostnames: [ 'explore-hanzo-dev.lux.network', 'explore-dev.hanzo.network' ],
explorerUrl: 'https://explore-hanzo-dev.lux.network',
apiUrl: 'https://api-explore-hanzo-dev.lux.network',
branding: HANZO_BRANDING,
@@ -412,12 +455,23 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
label: 'Pars Network (Devnet)',
vm: 'L2',
network: 'devnet',
chainId: 494951,
chainId: 7072,
hostnames: [ 'explore-pars-dev.lux.network' ],
explorerUrl: 'https://explore-pars-dev.lux.network',
apiUrl: 'https://api-explore-pars-dev.lux.network',
branding: PARS_BRANDING,
},
{
name: 'Osage',
label: 'Osage Chain (Devnet)',
vm: 'L2',
network: 'devnet',
chainId: 1873,
hostnames: [ 'explore-osage-dev.lux.network' ],
explorerUrl: 'https://explore-osage-dev.lux.network',
apiUrl: 'https://api-explore-osage-dev.lux.network',
branding: OSAGE_BRANDING,
},
// Localnet — chainId 1337. Listing localhost / 127.0.0.1 / 0.0.0.0 here
// keeps isWhiteLabelMode() false in local dev so the chain + network
@@ -557,6 +611,22 @@ export function getCurrentChain(): ChainEntry {
return { ...base, branding: applyBrandEnvOverrides(base.branding) };
}
/**
* True only on the Lux PRIMARY-network explorer (the Lux C-Chain host +
* the unregistered white-label fallback, both `vm: 'EVM'`). Brand explorers
* (Hanzo / Zoo / SPC / Pars are `vm: 'L2'`) return false: they show ONLY
* their own chain, never the Lux primary-network chains / cross-L1 lists.
*/
export function isPrimaryNetworkExplorer(): boolean {
// The Lux primary-network explorer is the REGISTERED Lux C-Chain host only.
// White-label / unregistered hosts are sovereign brand explorers (Hanzo /
// Zoo / Pars / SPC / Osage): they show ONLY their own chain — never the Lux
// primary network's chains or the cross-L1 list. Gating purely on vm==='EVM'
// wrongly promoted unregistered hosts (buildWhiteLabelChain defaults to
// vm:'EVM') to primary, leaking the parent network's chains.
return !isWhiteLabelMode() && getCurrentChain().vm === 'EVM';
}
export function getCurrentNetwork(): NetworkEntry {
const chain = getCurrentChain();
if (isWhiteLabelMode()) {
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { isPrimaryNetworkExplorer } from './chainRegistry';
import {
PRIMARY_VMS,
getPrimaryVm,
hasBespokeView,
chainsAwaitingBespokeView,
} from './primaryChains';
// Drives the registry off a simulated request host, exactly like the browser
// (getHostname() reads window.location.hostname). Mirrors chainRegistry.spec.ts.
function atHost(hostname: string) {
(globalThis as unknown as { window: unknown }).window = { location: { hostname }, __envs: {} };
}
describe('primary-network VM registry', () => {
// The 10 VM chains the Lux primary network must expose + route (the build
// directive). The registry is a superset (P/M/O/R/I as well), so we assert
// the mandated 10 are all present with the right VM type.
const REQUIRED = [
{ slug: 'c-chain', vm: 'EVM' },
{ slug: 'x-chain', vm: 'XVM' },
{ slug: 'd-chain', vm: 'DexVM' },
{ slug: 'a-chain', vm: 'AIVM' },
{ slug: 'z-chain', vm: 'ZKVM' },
{ slug: 'q-chain', vm: 'QuantumVM' },
{ slug: 'b-chain', vm: 'BridgeVM' },
{ slug: 'k-chain', vm: 'KeyVM' },
{ slug: 'g-chain', vm: 'GraphVM' },
{ slug: 't-chain', vm: 'ThresholdVM' },
];
it.each(REQUIRED)('registers $slug as $vm', ({ slug, vm }) => {
const entry = getPrimaryVm(slug);
expect(entry).toBeDefined();
expect(entry?.vm).toBe(vm);
});
it('exposes every VM at a unique slug routed under /chains/<slug>', () => {
const slugs = PRIMARY_VMS.map((c) => c.slug);
expect(new Set(slugs).size).toBe(slugs.length);
expect(PRIMARY_VMS.length).toBeGreaterThanOrEqual(REQUIRED.length);
});
it('resolves slugs case-insensitively', () => {
expect(getPrimaryVm('D-Chain')?.slug).toBe('d-chain');
expect(getPrimaryVm('C-CHAIN')?.slug).toBe('c-chain');
});
describe('each VM type maps to the correct render view', () => {
it('D-Chain renders the DEX order-book UI (not an EVM block list)', () => {
expect(getPrimaryVm('d-chain')?.view).toBe('dex');
});
it('C-Chain renders the EVM model', () => {
expect(getPrimaryVm('c-chain')?.view).toBe('evm');
});
it('X-Chain renders the UTXO model', () => {
expect(getPrimaryVm('x-chain')?.view).toBe('utxo');
});
it('P-Chain renders the platform/validators model', () => {
expect(getPrimaryVm('p-chain')?.view).toBe('platform');
});
it('custom VMs (A/Z/Q/B/K/G/T) fall back to the generic view', () => {
for (const slug of [ 'a-chain', 'z-chain', 'q-chain', 'b-chain', 'k-chain', 'g-chain', 't-chain' ]) {
expect(getPrimaryVm(slug)?.view).toBe('generic');
}
});
});
describe('bespoke-view flag — what still needs a native explorer', () => {
it('EVM / platform / DEX have a bespoke view today', () => {
expect(hasBespokeView('evm')).toBe(true);
expect(hasBespokeView('platform')).toBe(true);
expect(hasBespokeView('dex')).toBe(true);
});
it('UTXO + generic still fall back (flagged backlog)', () => {
expect(hasBespokeView('utxo')).toBe(false);
expect(hasBespokeView('generic')).toBe(false);
});
it('flags X-Chain and the custom VMs, never C/P/D', () => {
const flagged = chainsAwaitingBespokeView().map((c) => c.slug);
expect(flagged).toEqual(expect.arrayContaining([ 'x-chain', 'a-chain', 'z-chain', 'q-chain', 'b-chain', 'k-chain', 'g-chain', 't-chain' ]));
expect(flagged).not.toContain('c-chain');
expect(flagged).not.toContain('p-chain');
expect(flagged).not.toContain('d-chain');
});
});
});
// The primary-network VM surfaces (Chains / Chain detail / DEX nav + pages) are
// gated on isPrimaryNetworkExplorer(). This keystone proves the 10 VMs surface
// ONLY on the Lux explorer and never leak onto a brand explorer — the same
// predicate the nav and PrimaryNetworkGuard use.
describe('primary VMs surface only on the Lux primary explorer', () => {
beforeEach(() => atHost('localhost'));
it('shows the VM surfaces on the Lux explorer', () => {
atHost('explore.lux.network');
expect(isPrimaryNetworkExplorer()).toBe(true);
});
it.each([
'explore.hanzo.network',
'explore.zoo.network',
'explore.pars.network',
'explore.osage.network',
'explore.unknown.example',
])('hides the VM surfaces on %s (no leak)', (host) => {
atHost(host);
expect(isPrimaryNetworkExplorer()).toBe(false);
});
});
+217
View File
@@ -0,0 +1,217 @@
// Single source of truth for the Lux PRIMARY-network VM chains.
//
// The Lux primary network is not one chain but a family of VM-specialized
// blockchains that share one validator set — mirrored from the node registry
// at ~/work/lux/node/node/vms.go (P/X/C core + the extended allvms set:
// A,B,D,G,I,K,M,O,Q,R,T,Z). Each chain runs its OWN vmID and therefore its own
// data model: C is EVM blocks, X is a UTXO/DAG, D is a DEX order book, etc.
//
// This axis is ORTHOGONAL to the sovereign-L1 brand axis in chainRegistry.ts
// (Lux / Zoo / Hanzo / Pars / SPC / Osage). These VM chains belong to the Lux
// primary network ONLY, so they surface ONLY on the Lux primary explorer — see
// isPrimaryNetworkExplorer(). Brand explorers never list them.
//
// `view` selects which UI renders a chain's native model. `hasBespokeView`
// derives whether that view is VM-native today or a graceful generic fallback
// (so callers can flag the chains still awaiting a bespoke explorer).
export type PrimaryVmView = 'evm' | 'platform' | 'utxo' | 'dex' | 'generic';
export interface PrimaryVm {
/** Route slug — the chain renders at /chains/<slug>. */
readonly slug: string;
/** Short display name, e.g. "C-Chain". */
readonly name: string;
/** Full display name, e.g. "Contract Chain". */
readonly fullName: string;
/** VM label as the node registers it, e.g. "EVM", "DexVM". */
readonly vm: string;
/** P-Chain vmID where known (core chains), else '' (extended VMs). */
readonly vmId: string;
/** EVM chainId where the chain has one (C-Chain), else null. */
readonly chainId: number | null;
/** One-line description for the chain detail header. */
readonly description: string;
/** Which UI renders this chain's native data model. */
readonly view: PrimaryVmView;
}
// Views backed by a VM-native render today. Everything else falls back to the
// generic chain-detail view (info + indexer stats + validators) and is FLAGGED
// by chainsAwaitingBespokeView() as still needing a bespoke explorer.
const BESPOKE_VIEWS: ReadonlySet<PrimaryVmView> = new Set<PrimaryVmView>([ 'evm', 'platform', 'dex' ]);
export const PRIMARY_VMS: ReadonlyArray<PrimaryVm> = [
{
slug: 'c-chain',
name: 'C-Chain',
fullName: 'Contract Chain',
vm: 'EVM',
vmId: 'mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6',
chainId: 96369,
description: 'The C-Chain is an EVM-compatible blockchain on the network used for smart contracts and DeFi applications.',
view: 'evm',
},
{
slug: 'p-chain',
name: 'P-Chain',
fullName: 'Platform Chain',
vm: 'PVM',
vmId: 'rWhpuQPF1kb72esV2momhMuTYGkEb1oL29pt2EBXWsBY6MALT',
chainId: null,
description: 'The P-Chain manages validators, staking, and subnet creation across the network.',
view: 'platform',
},
{
slug: 'x-chain',
name: 'X-Chain',
fullName: 'UTXO Chain',
vm: 'XVM',
vmId: 'jvYyfQTxGMJLuGWa55kdP2p2zSUYsQ5Raupu4TW34ZAUBAbtq',
chainId: null,
description: 'The X-Chain is a DAG-based chain for creating and exchanging digital assets on the network.',
view: 'utxo',
},
{
slug: 'd-chain',
name: 'D-Chain',
fullName: 'DEX Chain',
vm: 'DexVM',
vmId: '',
chainId: null,
description: 'The D-Chain is a decentralized exchange chain on the network for on-chain order books and token swaps.',
view: 'dex',
},
{
slug: 'a-chain',
name: 'A-Chain',
fullName: 'AI Chain',
vm: 'AIVM',
vmId: '',
chainId: null,
description: 'The A-Chain powers AI workloads on the network, providing decentralized inference and model serving.',
view: 'generic',
},
{
slug: 'b-chain',
name: 'B-Chain',
fullName: 'Bridge Chain',
vm: 'BridgeVM',
vmId: '',
chainId: null,
description: 'The B-Chain is the bridge relay chain on the network, enabling cross-chain asset transfers via Teleporter.',
view: 'generic',
},
{
slug: 'q-chain',
name: 'Q-Chain',
fullName: 'Quantum Chain',
vm: 'QuantumVM',
vmId: '',
chainId: null,
description: 'The Q-Chain provides post-quantum cryptographic primitives and quantum-resistant operations on the network.',
view: 'generic',
},
{
slug: 't-chain',
name: 'T-Chain',
fullName: 'Threshold Chain',
vm: 'ThresholdVM',
vmId: '',
chainId: null,
description: 'The T-Chain enables threshold signature schemes and distributed key generation on the network.',
view: 'generic',
},
{
slug: 'z-chain',
name: 'Z-Chain',
fullName: 'ZK Chain',
vm: 'ZKVM',
vmId: '',
chainId: null,
description: 'The Z-Chain handles zero-knowledge proof generation and verification on the network.',
view: 'generic',
},
{
slug: 'g-chain',
name: 'G-Chain',
fullName: 'Graph Chain',
vm: 'GraphVM',
vmId: '',
chainId: null,
description: 'The G-Chain provides decentralized graph indexing and query services on the network.',
view: 'generic',
},
{
slug: 'k-chain',
name: 'K-Chain',
fullName: 'Key Chain',
vm: 'KeyVM',
vmId: '',
chainId: null,
description: 'The K-Chain provides decentralized key management and custody services on the network.',
view: 'generic',
},
{
slug: 'o-chain',
name: 'O-Chain',
fullName: 'Oracle Chain',
vm: 'OracleVM',
vmId: '',
chainId: null,
description: 'The O-Chain provides decentralized oracle services, bringing off-chain data on-chain for the network.',
view: 'generic',
},
{
slug: 'r-chain',
name: 'R-Chain',
fullName: 'Relay Chain',
vm: 'RelayVM',
vmId: '',
chainId: null,
description: 'The R-Chain handles cross-chain message relay and interoperability routing on the network.',
view: 'generic',
},
{
slug: 'i-chain',
name: 'I-Chain',
fullName: 'Identity Chain',
vm: 'IdentityVM',
vmId: '',
chainId: null,
description: 'The I-Chain manages decentralized identity, DIDs, and verifiable credentials on the network.',
view: 'generic',
},
{
slug: 'm-chain',
name: 'M-Chain',
fullName: 'MPC Chain',
vm: 'MPCVM',
vmId: '',
chainId: null,
description: 'The M-Chain coordinates CGGMP21 threshold ECDSA and FROST threshold EdDSA signing sessions across validators.',
view: 'generic',
},
];
export function getPrimaryVm(slug: string): PrimaryVm | undefined {
return PRIMARY_VMS.find((vm) => vm.slug === slug.toLowerCase());
}
/** True when `view` has a VM-native render today (not the generic fallback). */
export function hasBespokeView(view: PrimaryVmView): boolean {
return BESPOKE_VIEWS.has(view);
}
/** Chains that still render via the generic fallback — the bespoke-view backlog. */
export function chainsAwaitingBespokeView(): ReadonlyArray<PrimaryVm> {
return PRIMARY_VMS.filter((vm) => !hasBespokeView(vm.view));
}
+5
View File
@@ -39,9 +39,14 @@
"zooai",
"parsdao",
"Kaniko",
"unstub",
"abfnrtv",
"abitype",
"AIVM",
"MPCVM",
"CGGMP",
"UTXO",
"decomplects",
"allvms",
"abkw",
"ACMRT",
+15 -8
View File
@@ -4,6 +4,7 @@ import React from 'react';
import type { NavItemInternal, NavItem, NavGroupItem } from 'types/client/navigation';
import config from 'configs/app';
import { isPrimaryNetworkExplorer } from 'configs/app/chainRegistry';
import { getEnvValue } from 'configs/app/utils';
import { layerLabels } from 'lib/rollups/utils';
import { rightLineArrow } from 'toolkit/utils/htmlEntities';
@@ -99,30 +100,36 @@ export default function useNavItems(): ReturnType {
icon: 'navigation/validator',
isActive: pathname === '/validators' || pathname === '/validators/[id]',
};
const chains: NavItem = {
// Chains / DEX / Bridge / AI Compute are Lux PRIMARY-network VM surfaces
// (the C/X/D/A/B… VM family). They belong to the Lux primary network only,
// so they are hidden on brand explorers (Hanzo / Zoo / Pars / SPC / Osage)
// and on white-label hosts — enforcing the chain-visibility rule. Pages
// self-guard too (PrimaryNetworkGuard) so a direct URL can't leak them.
const isPrimaryNetwork = isPrimaryNetworkExplorer();
const chains: NavItem | null = isPrimaryNetwork ? {
text: 'Chains',
nextRoute: { pathname: '/chains' as const },
icon: 'navigation/blockchain',
isActive: pathname === '/chains',
};
const dex: NavItem = {
} : null;
const dex: NavItem | null = isPrimaryNetwork ? {
text: 'DEX',
nextRoute: { pathname: '/dex' as const },
icon: 'navigation/dex_tracker',
isActive: pathname === '/dex',
};
const aiCompute: NavItem = {
} : null;
const aiCompute: NavItem | null = isPrimaryNetwork ? {
text: 'AI Compute',
nextRoute: { pathname: '/ai' as const },
icon: 'navigation/blockchain',
isActive: pathname === '/ai',
};
const bridge: NavItem = {
} : null;
const bridge: NavItem | null = isPrimaryNetwork ? {
text: 'Bridge',
nextRoute: { pathname: '/bridge' as const },
icon: 'navigation/cross_chain_txs',
isActive: pathname === '/bridge',
};
} : null;
const rollupDeposits = {
text: `Deposits (${ layerLabels.parent }${ rightLineArrow }${ layerLabels.current })`,
nextRoute: { pathname: '/deposits' as const },
+4 -4
View File
@@ -1,8 +1,8 @@
// https://hexdocs.pm/phoenix/js/
import type { Channel, SocketConnectOption } from 'phoenix';
import { Socket } from 'phoenix';
import React, { useEffect, useState } from 'react';
import type { Channel, SocketOptions } from './sse';
import { Socket } from './sse';
type ChannelRegistry = Record<string, { channel: Channel; subscribers: number }>;
const socketContexts = new Map<string, React.Context<{
@@ -23,7 +23,7 @@ function getSocketContext(name: string) {
interface SocketProviderProps {
children: React.ReactNode;
url?: string;
options?: Partial<SocketConnectOption>;
options?: SocketOptions;
name?: string;
}
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Socket } from './sse';
// Controllable EventSource stand-in. Drives the adapter the way the Lux
// explorer backend would: open the stream, push `{event,chain,data}`
// envelopes, raise errors.
class FakeEventSource {
static last: FakeEventSource | undefined;
readonly CONNECTING = 0;
readonly OPEN = 1;
readonly CLOSED = 2;
url: string;
withCredentials: boolean;
readyState = 0;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
private listeners = new Map<string, Set<() => void>>();
constructor(url: string, init?: { withCredentials?: boolean }) {
this.url = url;
this.withCredentials = init?.withCredentials ?? false;
FakeEventSource.last = this;
}
addEventListener(type: string, cb: () => void) {
const set = this.listeners.get(type) ?? new Set();
set.add(cb);
this.listeners.set(type, set);
}
removeEventListener(type: string, cb: () => void) {
this.listeners.get(type)?.delete(cb);
}
close() {
this.readyState = this.CLOSED;
}
// Test drivers ----------------------------------------------------------
emitOpen() {
this.readyState = this.OPEN;
this.onopen?.();
this.listeners.get('CONNECT')?.forEach((cb) => cb());
}
emitMessage(envelope: object) {
this.onmessage?.({ data: JSON.stringify(envelope) });
}
emitError(closed = false) {
if (closed) {
this.readyState = this.CLOSED;
}
this.onerror?.();
}
}
function lastEs(): FakeEventSource {
if (!FakeEventSource.last) {
throw new Error('EventSource was not constructed');
}
return FakeEventSource.last;
}
beforeEach(() => {
FakeEventSource.last = undefined;
vi.stubGlobal('EventSource', FakeEventSource);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('SSE Socket — URL normalisation', () => {
it('rewrites a Phoenix socket URL to the backend SSE endpoint', () => {
new Socket('wss://api-explore.lux.network/socket/v2').connect();
expect(lastEs().url).toBe('https://api-explore.lux.network/v1/base/realtime');
});
it('rewrites a bare ws host (test harness shape)', () => {
new Socket('ws://localhost:3200').connect();
expect(lastEs().url).toBe('http://localhost:3200/v1/base/realtime');
});
it('passes withCredentials through to EventSource', () => {
new Socket('wss://api-explore.lux.network', { withCredentials: true }).connect();
expect(lastEs().withCredentials).toBe(true);
});
});
describe('SSE Socket — envelope routing', () => {
it('maps the backend "blocks" broadcast onto blocks:new_block / new_block', () => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
const channel = socket.channel('blocks:new_block');
const handler = vi.fn();
channel.on('new_block', handler);
lastEs().emitOpen();
lastEs().emitMessage({ event: 'blocks', chain: 'cchain', data: { number: 42, hash: '0xabc' } });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ block: { number: 42, hash: '0xabc' } });
});
it('routes a topic-scoped envelope straight to that channel + event', () => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
const channel = socket.channel('addresses:0xabc');
const handler = vi.fn();
channel.on('token_transfer', handler);
lastEs().emitOpen();
const payload = { token_transfers: [ { tx_hash: '0xdead' } ] };
lastEs().emitMessage({ event: 'token_transfer', topic: 'addresses:0xabc', data: payload });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(payload);
});
it('drops events with no matching channel or translation without throwing', () => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
lastEs().emitOpen();
expect(() => lastEs().emitMessage({ event: 'nonexistent', data: {} })).not.toThrow();
});
it('stops delivering to a handler after off()', () => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
const channel = socket.channel('blocks:new_block');
const handler = vi.fn();
const ref = channel.on('new_block', handler);
lastEs().emitOpen();
channel.off('new_block', ref);
lastEs().emitMessage({ event: 'blocks', data: { number: 1 } });
expect(handler).not.toHaveBeenCalled();
});
});
describe('SSE Socket — channel join', () => {
it('resolves join "ok" once the stream opens', () => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
const channel = socket.channel('blocks:new_block');
const onOk = vi.fn();
channel.join().receive('ok', onOk);
expect(onOk).not.toHaveBeenCalled();
lastEs().emitOpen();
expect(onOk).toHaveBeenCalledTimes(1);
});
it('resolves join "ok" immediately when the stream is already open', async() => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
lastEs().emitOpen();
const channel = socket.channel('blocks:new_block');
const onOk = vi.fn();
channel.join().receive('ok', onOk);
await Promise.resolve();
expect(onOk).toHaveBeenCalledTimes(1);
});
});
describe('SSE Socket — connection state', () => {
it('fires onError handlers when the stream errors', () => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
const onError = vi.fn();
socket.onError(onError);
lastEs().emitError();
expect(onError).toHaveBeenCalled();
});
it('stops firing a state handler after off()', () => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
const onError = vi.fn();
const ref = socket.onError(onError);
socket.off([ ref ]);
lastEs().emitError();
expect(onError).not.toHaveBeenCalled();
});
it('closes the EventSource on disconnect', () => {
const socket = new Socket('wss://api-explore.lux.network');
socket.connect();
const es = lastEs();
socket.disconnect();
expect(es.readyState).toBe(es.CLOSED);
});
});
+324
View File
@@ -0,0 +1,324 @@
// EventSource-backed, Phoenix-Channels-compatible realtime transport.
//
// The Lux explorer Go backend (luxfi/explorer) serves realtime over
// Server-Sent Events at `<socketEndpoint>/v1/base/realtime` — a single
// multiplexed stream that wraps every hub broadcast in a JSON envelope:
//
// event: CONNECT (once, on open)
// data: {}
//
// data: {"event":"<hubChannel>","chain":"<slug>","data":<payload>}
//
// : ping (keep-alive every 30s)
//
// There is NO Phoenix WebSocket endpoint (the legacy FE opened
// `wss://.../socket/v2/websocket` and got a 404, which surfaced as
// "Live updates temporarily delayed"). Rather than rewrite all 27
// realtime consumers, this module reimplements the slice of the Phoenix
// `Socket`/`Channel` API those consumers use, backed by one EventSource.
// The seam (`SocketProvider` / `useSocketChannel` / `useSocketMessage`)
// stays; only the transport underneath changes.
export type MessageRef = string;
type Payload = unknown;
// The transport hands consumers the raw wire payload; each consumer re-types
// it for its specific message (see useSocketMessage). This single `any`
// boundary mirrors the Phoenix `Channel.on` signature this replaces.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type WirePayload = any;
type EventHandler = (payload?: WirePayload) => void;
type StateHandler = () => void;
type StateKind = 'open' | 'close' | 'error';
type PushStatus = 'ok' | 'error' | 'timeout';
// Options accepted by SocketProvider. Phoenix took a large connect-option
// bag; EventSource only meaningfully honours credentialed requests.
export interface SocketOptions {
withCredentials?: boolean;
}
// The multiplexed SSE envelope the backend emits. `topic` is optional:
// - present -> the payload is delivered straight to that Phoenix topic,
// firing `event` handlers (used by chain/address/token-scoped pushes
// and by the component-test harness).
// - absent -> the backend hub channel in `event` is mapped to a
// Blockscout-style (topic, event) pair via TRANSLATIONS below.
interface Envelope {
event: string;
chain?: string;
data?: Payload;
topic?: string;
}
interface Translation {
topic: string;
event: string;
transform?: (data: Payload, chain?: string) => Payload;
}
// The single place that maps the backend's hub-channel broadcasts to the
// Blockscout-style Phoenix (topic, event) pairs the UI subscribes to.
// Today the indexer broadcasts only "blocks" (luxfi/indexer
// evm/indexer.go `BroadcastBlock`). Extend here as the backend grows new
// GLOBAL broadcast types; SCOPED events (addresses:*, tokens:*, …) arrive
// with an explicit `topic` and need no entry.
const TRANSLATIONS: Record<string, Array<Translation>> = {
blocks: [
{ topic: 'blocks:new_block', event: 'new_block', transform: (block) => ({ block }) },
],
};
// Normalize any realtime URL the app hands us into the backend's SSE
// endpoint. Callers pass a Phoenix-style URL (getSocketUrl appends
// `/socket/v2`, the multichain-stats API appends `/socket`, the test
// harness passes `ws://host:port`); all collapse to one shape here so the
// transport has exactly one way to reach the server.
function toSSEUrl(raw: string): string {
let u = raw.replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:');
u = u.replace(/\/+$/, '');
u = u.replace(/\/socket(?:\/v\d+)?(?:\/websocket)?$/i, '');
return `${ u }/v1/base/realtime`;
}
// Push mirrors the chainable `channel.join().receive('ok', …).receive('error', …)`
// shape. SSE has no per-topic handshake, so a join resolves 'ok' as soon
// as the stream is open; it never resolves 'error' (transport errors are
// surfaced at the socket level instead).
class Push {
private handlers: Partial<Record<PushStatus, Array<(resp?: Payload) => void>>> = {};
private status: PushStatus | undefined;
private response: Payload;
receive(status: PushStatus, callback: (resp?: Payload) => void): this {
if (this.status === status) {
callback(this.response);
return this;
}
const list = this.handlers[status] ?? [];
list.push(callback);
this.handlers[status] = list;
return this;
}
resolve(status: PushStatus, response?: Payload) {
if (this.status !== undefined) {
return;
}
this.status = status;
this.response = response;
this.handlers[status]?.forEach((cb) => cb(response));
}
}
export class Channel {
readonly topic: string;
private socket: Socket;
private handlers = new Map<string, Map<number, EventHandler>>();
private refSeq = 0;
private joinPush: Push | undefined;
private joined = false;
constructor(topic: string, socket: Socket) {
this.topic = topic;
this.socket = socket;
}
join(): Push {
if (!this.joinPush) {
this.joinPush = new Push();
this.joined = false;
}
if (this.socket.isOpen()) {
// Already streaming — resolve after the synchronous `.receive(...)`
// chain has registered its callbacks.
queueMicrotask(() => this.resolveJoin());
}
// Otherwise Socket fires resolveJoin() via notifyOpen() on open.
return this.joinPush;
}
leave(): Push {
this.handlers.clear();
this.joinPush = undefined;
this.joined = false;
this.socket.removeChannel(this.topic);
const push = new Push();
queueMicrotask(() => push.resolve('ok', {}));
return push;
}
on(event: string, callback: EventHandler): number {
const ref = ++this.refSeq;
let map = this.handlers.get(event);
if (!map) {
map = new Map();
this.handlers.set(event, map);
}
map.set(ref, callback);
return ref;
}
off(event: string, ref?: number) {
const map = this.handlers.get(event);
if (!map) {
return;
}
if (ref === undefined) {
this.handlers.delete(event);
return;
}
map.delete(ref);
if (map.size === 0) {
this.handlers.delete(event);
}
}
// Internal — deliver an inbound event to every handler registered for it.
receive(event: string, payload?: Payload) {
this.handlers.get(event)?.forEach((cb) => cb(payload));
}
// Internal — the stream (re)opened; complete a pending join exactly once.
notifyOpen() {
this.resolveJoin();
}
private resolveJoin() {
if (this.joined || !this.joinPush) {
return;
}
this.joined = true;
this.joinPush.resolve('ok', {});
}
}
export class Socket {
private url: string;
private withCredentials: boolean;
private es: EventSource | undefined;
private channels = new Map<string, Channel>();
private stateHandlers = new Map<MessageRef, { kind: StateKind; cb: StateHandler }>();
private refSeq = 0;
constructor(url: string, options?: SocketOptions) {
this.url = toSSEUrl(url);
this.withCredentials = options?.withCredentials ?? false;
}
connect() {
if (this.es || typeof EventSource === 'undefined') {
return;
}
const es = new EventSource(this.url, { withCredentials: this.withCredentials });
this.es = es;
const handleOpen = () => {
this.fire('open');
this.channels.forEach((ch) => ch.notifyOpen());
};
es.onopen = handleOpen;
// The backend sends an initial typed `CONNECT` frame; treat it as an
// open acknowledgement too (covers proxies that swallow onopen).
es.addEventListener('CONNECT', handleOpen);
es.onmessage = (event: MessageEvent) => this.dispatch(event.data);
es.onerror = () => {
// EventSource auto-reconnects while readyState === CONNECTING. Surface
// the error to consumers (matches Phoenix onError/onClose semantics);
// a healthy endpoint never reaches here under normal operation, so the
// "Live updates" alert stays cleared.
this.fire('error');
if (es.readyState === es.CLOSED) {
this.fire('close');
}
};
}
disconnect(callback?: () => void) {
if (this.es) {
this.es.close();
this.es = undefined;
}
this.channels.clear();
callback?.();
}
channel(topic: string): Channel {
let ch = this.channels.get(topic);
if (!ch) {
ch = new Channel(topic, this);
this.channels.set(topic, ch);
}
return ch;
}
onOpen(callback: StateHandler): MessageRef {
return this.addState('open', callback);
}
onClose(callback: StateHandler): MessageRef {
return this.addState('close', callback);
}
onError(callback: StateHandler): MessageRef {
return this.addState('error', callback);
}
off(refs: Array<MessageRef>) {
refs.forEach((ref) => this.stateHandlers.delete(ref));
}
// Internal — true once the underlying stream is open.
isOpen() {
return this.es !== undefined && this.es.readyState === this.es.OPEN;
}
// Internal — drop a channel once its last subscriber leaves.
removeChannel(topic: string) {
this.channels.delete(topic);
}
private addState(kind: StateKind, cb: StateHandler): MessageRef {
const ref = String(++this.refSeq);
this.stateHandlers.set(ref, { kind, cb });
return ref;
}
private fire(kind: StateKind) {
this.stateHandlers.forEach((handler) => {
if (handler.kind === kind) {
handler.cb();
}
});
}
private dispatch(raw: string) {
let envelope: Envelope;
try {
envelope = JSON.parse(raw) as Envelope;
} catch {
return;
}
if (!envelope || typeof envelope.event !== 'string') {
return;
}
if (envelope.topic) {
this.channels.get(envelope.topic)?.receive(envelope.event, envelope.data);
return;
}
const routes = TRANSLATIONS[envelope.event];
if (!routes) {
return;
}
for (const route of routes) {
const ch = this.channels.get(route.topic);
if (!ch) {
continue;
}
ch.receive(route.event, route.transform ? route.transform(envelope.data, envelope.chain) : envelope.data);
}
}
}
+2 -2
View File
@@ -1,5 +1,3 @@
import type { Channel } from 'phoenix';
import type * as multichain from '@luxfi/multichain-aggregator-types';
import type * as zetaChainCCTXType from '@luxfi/zetachain-cctx-types';
import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage } from 'types/api/address';
@@ -12,6 +10,8 @@ import type { TokenTransfer } from 'types/api/tokenTransfer';
import type { Transaction } from 'types/api/transaction';
import type { NewZkEvmBatchSocketResponse } from 'types/api/zkEvmL2';
import type { Channel } from './sse';
export type SocketMessageParams = SocketMessage.NewBlock |
SocketMessage.NewBlockMultichain |
SocketMessage.BlocksIndexStatus |
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Channel } from 'phoenix';
import { useEffect, useRef, useState } from 'react';
import { useSocket } from './context';
import type { Channel } from './sse';
interface Params {
topic: string | undefined;
+29 -1
View File
@@ -233,7 +233,35 @@
"brace-expansion": "1.1.12",
"js-yaml": "4.1.1",
"@noble/hashes": "1.8.0",
"@ipshipyard/node-datachannel": "link:./stubs/node-datachannel"
"@ipshipyard/node-datachannel": "link:./stubs/node-datachannel",
"@hanzogui/adapt": "3.0.2",
"@hanzogui/animate": "3.0.2",
"@hanzogui/animate-presence": "3.0.2",
"@hanzogui/compose-refs": "3.0.2",
"@hanzogui/constants": "3.0.2",
"@hanzogui/core": "3.0.2",
"@hanzogui/create-context": "3.0.2",
"@hanzogui/dismissable": "3.0.2",
"@hanzogui/floating": "3.0.2",
"@hanzogui/focus-scope": "3.0.2",
"@hanzogui/get-font-sized": "3.0.2",
"@hanzogui/get-token": "3.0.2",
"@hanzogui/helpers": "3.0.2",
"@hanzogui/is-equal-shallow": "3.0.1",
"@hanzogui/polyfill-dev": "3.0.2",
"@hanzogui/popover": "3.0.2",
"@hanzogui/popper": "3.0.2",
"@hanzogui/portal": "3.0.2",
"@hanzogui/remove-scroll": "3.0.2",
"@hanzogui/scroll-view": "3.0.2",
"@hanzogui/sheet": "3.0.2",
"@hanzogui/stacks": "3.0.2",
"@hanzogui/text": "3.0.2",
"@hanzogui/use-controllable-state": "3.0.2",
"@hanzogui/use-event": "3.0.2",
"@hanzogui/use-presence": "3.0.1",
"@hanzogui/web": "3.0.2",
"@hanzogui/z-index-stack": "3.0.2"
}
},
"packageManager": "[email protected]"
+28 -3
View File
@@ -5,12 +5,24 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { getEnvValue } from 'configs/app/utils';
const CCHAIN_RPC_PATTERN = /\/ext\/bc\/C\/rpc$/;
const TIMEOUT_MS = 10_000;
// Derive the node's API origin from the chain RPC URL. The RPC URL points at a
// specific EVM chain (e.g. `…/ext/bc/C/rpc` or `…/ext/bc/hanzo/rpc`); the P-chain
// lives at `<origin>/ext/bc/P`, so we must reduce to the scheme+host origin and
// never naively concatenate onto the full RPC path (which produced a malformed
// URL → HTML 404 → "Unexpected non-whitespace character after JSON" for every
// non-C-chain brand).
function getApiBase(): string {
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL') ?? '';
return rpcUrl.replace(CCHAIN_RPC_PATTERN, '');
if (!rpcUrl) {
return '';
}
try {
return new URL(rpcUrl).origin;
} catch {
return '';
}
}
const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void> => {
@@ -33,7 +45,20 @@ const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void>
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const data = await response.json();
// The node returns JSON for valid P-chain RPC; a wrong URL or gateway
// error yields HTML. Read as text and parse defensively so the client gets a
// clear error rather than an opaque "non-whitespace character" parse crash.
const raw = await response.text();
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
res.status(502).json({
error: 'P-chain request failed',
message: `Upstream returned non-JSON (HTTP ${ response.status }) from ${ base }/ext/bc/P`,
});
return;
}
res.status(200).json(data);
} catch (error) {
res.status(502).json({
+72 -55
View File
@@ -1,6 +1,5 @@
import type { TestFixture, Page } from '@playwright/test';
import type { WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import http from 'node:http';
import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage } from 'types/api/address';
import type { NewBlockSocketResponse } from 'types/api/block';
@@ -11,7 +10,25 @@ import type { Transaction } from 'types/api/transaction';
import { port as socketPort } from '../utils/socket';
export type CreateSocketFixture = () => Promise<WebSocket>;
// Component-test realtime harness. The app speaks Server-Sent Events to the
// Lux explorer backend's multiplexed `/v1/base/realtime` endpoint (see
// lib/socket/sse.ts), so this fixture is a minimal SSE server rather than a
// Phoenix WebSocket mock. `createSocket` / `joinChannel` / `sendMessage`
// keep their signatures so the consumer specs are unchanged; `sendMessage`
// emits the backend's `{ event, topic, data }` envelope, addressing a
// channel directly via the optional `topic` field the adapter routes off.
// SSEStream is the server side of one open EventSource stream. Tests
// push frames into it with sendMessage().
export class SSEStream {
constructor(private readonly res: http.ServerResponse) {}
send(frame: string) {
this.res.write(frame);
}
}
export type CreateSocketFixture = () => Promise<SSEStream>;
type Channel = [string, string, string];
@@ -21,64 +38,64 @@ export interface SocketServerFixture {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const createSocket: TestFixture<CreateSocketFixture, { page: Page }> = async({ page }, use) => {
const socketServer = new WebSocketServer({ port: socketPort });
const connectionPromise = new Promise<WebSocket>((resolve) => {
socketServer.on('connection', (socket: WebSocket) => {
resolve(socket);
});
const openResponses = new Set<http.ServerResponse>();
let resolveConnection: (conn: SSEStream) => void;
const connectionPromise = new Promise<SSEStream>((resolve) => {
resolveConnection = resolve;
});
const server = http.createServer((req, res) => {
if (req.method === 'HEAD') {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Access-Control-Allow-Origin': '*' });
res.end();
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write('event: CONNECT\ndata: {}\n\n');
openResponses.add(res);
res.on('close', () => openResponses.delete(res));
resolveConnection(new SSEStream(res));
});
await new Promise<void>((resolve) => server.listen(socketPort, resolve));
await use(() => connectionPromise);
socketServer.close();
openResponses.forEach((res) => res.end());
await new Promise<void>((resolve) => server.close(() => resolve()));
};
export const joinChannel = async(socket: WebSocket, channelName: string) => {
return new Promise<[string, string, string]>((resolve, reject) => {
socket.on('message', (msg) => {
try {
const payload = JSON.parse(msg.toString()) as Array<string>;
if (channelName === payload[2] && payload[3] === 'phx_join') {
socket.send(JSON.stringify([
payload[0],
payload[1],
payload[2],
'phx_reply',
{ response: {}, status: 'ok' },
]));
resolve([ payload[0], payload[1], payload[2] ]);
}
} catch (error) {
reject(error);
}
});
});
// SSE is a single read-only multiplexed stream — there is no per-topic join
// handshake. `socket` is kept for call-site compatibility; the returned token
// is what sendMessage() addresses.
export const joinChannel = (socket: SSEStream, channelName: string): Promise<Channel> => {
return Promise.resolve([ '', '', channelName ]);
};
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'coin_balance', payload: { coin_balance: AddressCoinBalanceHistoryItem }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_balance', payload: { block_number: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: `updated_token_balances_${ string }`, payload: AddressTokensBalancesSocketMessage): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'transaction', payload: { transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'transaction', payload: { transactions: Array<Transaction> }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transactions: Array<Transaction> }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block', payload: NewBlockSocketResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'total_supply', payload: { total_supply: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_bytecode', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'fetched_bytecode', payload: { fetched_bytecode: string }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'eth_bytecode_db_lookup_started', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_not_verified', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'fetched_token_instance_metadata', payload: TokenInstanceMetadataSocketMessage): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
socket.send(JSON.stringify([
...channel,
msg,
payload,
]));
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'coin_balance', payload: { coin_balance: AddressCoinBalanceHistoryItem }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'token_balance', payload: { block_number: number }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: `updated_token_balances_${ string }`, payload: AddressTokensBalancesSocketMessage): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'transaction', payload: { transaction: number }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'transaction', payload: { transactions: Array<Transaction> }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'pending_transaction', payload: { pending_transactions: Array<Transaction> }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'new_block', payload: NewBlockSocketResponse): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'total_supply', payload: { total_supply: number }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'changed_bytecode', payload: Record<string, never>): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'fetched_bytecode', payload: { fetched_bytecode: string }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'eth_bytecode_db_lookup_started', payload: Record<string, never>): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'smart_contract_was_not_verified', payload: Record<string, never>): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: 'fetched_token_instance_metadata', payload: TokenInstanceMetadataSocketMessage): void;
export function sendMessage(socket: SSEStream, channel: Channel, msg: string, payload: unknown): void {
socket.send(`data: ${ JSON.stringify({ event: msg, topic: channel[2], data: payload }) }\n\n`);
}
+623 -1133
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -95,11 +95,14 @@ export const STATS_CHARTS = {
sections: [ STATS_CHARTS_SECTION ],
};
// Skeleton placeholder only — these fields are masked by the loading skeleton
// and must never read as real data if they ever surface. Neutral title, zero
// value (no fabricated "9.074M" counter shown to users).
export const STATS_COUNTER: stats.Counter = {
id: 'stub',
value: '9074405',
title: 'Placeholder Counter',
description: 'Placeholder description',
value: '0',
title: '',
description: '',
units: '',
};
+1 -1
View File
@@ -1,6 +1,5 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import type { Channel } from 'phoenix';
import React from 'react';
import type { Address } from 'types/api/address';
@@ -11,6 +10,7 @@ import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { useMultichainContext } from 'lib/contexts/multichain';
import getQueryParamString from 'lib/router/getQueryParamString';
import type { Channel } from 'lib/socket/sse';
import * as stubs from 'stubs/contract';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
@@ -1,4 +1,4 @@
import type { Channel } from 'phoenix';
import { Alert } from '@luxfi/ui/alert';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
@@ -7,8 +7,8 @@ import type { SmartContract } from 'types/api/contract';
import { route } from 'nextjs-routes';
import type { Channel } from 'lib/socket/sse';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { Alert } from '@luxfi/ui/alert';
import { Link } from 'toolkit/next/link';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
+1 -1
View File
@@ -1,4 +1,3 @@
import type { Channel } from 'phoenix';
import React from 'react';
import type { Address } from 'types/api/address';
@@ -6,6 +5,7 @@ import type { ClusterChainConfig } from 'types/multichain';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import type { Channel } from 'lib/socket/sse';
import * as stubs from 'stubs/contract';
import { ContentLoader } from 'toolkit/components/loaders/ContentLoader';
import ContractDetails from 'ui/address/contract/ContractDetails';
+35 -123
View File
@@ -1,132 +1,25 @@
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tag } from '@luxfi/ui/tag';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import type { PrimaryVm } from 'configs/app/primaryChains';
import { getPrimaryVm } from 'configs/app/primaryChains';
import { useBlockchains, useCurrentValidators, useSubnets } from 'lib/api/pchain';
import type { PChainBlockchain, PChainValidator } from 'lib/api/pchain';
import { cn } from 'lib/utils/cn';
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tag } from '@luxfi/ui/tag';
import DexPage from 'ui/dex/DexPage';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import PageTitle from 'ui/shared/Page/PageTitle';
import PrimaryNetworkGuard from 'ui/shared/PrimaryNetworkGuard';
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY';
const LUX_DECIMALS = 6;
const PRIMARY_CHAIN_META: Readonly<Record<string, {
readonly name: string;
readonly fullName: string;
readonly vm: string;
readonly vmId: string;
readonly description: string;
}>> = {
'c-chain': {
name: 'C-Chain',
fullName: 'Contract Chain',
vm: 'EVM',
vmId: 'mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6',
description: 'The C-Chain is an EVM-compatible blockchain on the network used for smart contracts and DeFi applications.',
},
'p-chain': {
name: 'P-Chain',
fullName: 'Platform Chain',
vm: 'PVM',
vmId: 'rWhpuQPF1kb72esV2momhMuTYGkEb1oL29pt2EBXWsBY6MALT',
description: 'The P-Chain manages validators, staking, and subnet creation across the network.',
},
'x-chain': {
name: 'X-Chain',
fullName: 'UTXO Chain',
vm: 'XVM',
vmId: 'jvYyfQTxGMJLuGWa55kdP2p2zSUYsQ5Raupu4TW34ZAUBAbtq',
description: 'The X-Chain is a DAG-based chain for creating and exchanging digital assets on the network.',
},
'd-chain': {
name: 'D-Chain',
fullName: 'DEX Chain',
vm: 'DexVM',
vmId: '',
description: 'The D-Chain is a decentralized exchange chain on the network for on-chain order books and token swaps.',
},
'a-chain': {
name: 'A-Chain',
fullName: 'AI Chain',
vm: 'AIVM',
vmId: '',
description: 'The A-Chain powers AI workloads on the network, providing decentralized inference and model serving.',
},
'b-chain': {
name: 'B-Chain',
fullName: 'Bridge Chain',
vm: 'BridgeVM',
vmId: '',
description: 'The B-Chain is the bridge relay chain on the network, enabling cross-chain asset transfers via Teleporter.',
},
'q-chain': {
name: 'Q-Chain',
fullName: 'Quantum Chain',
vm: 'QuantumVM',
vmId: '',
description: 'The Q-Chain provides post-quantum cryptographic primitives and quantum-resistant operations on the network.',
},
't-chain': {
name: 'T-Chain',
fullName: 'Threshold Chain',
vm: 'ThresholdVM',
vmId: '',
description: 'The T-Chain enables threshold signature schemes and distributed key generation on the network.',
},
'z-chain': {
name: 'Z-Chain',
fullName: 'ZK Chain',
vm: 'ZKVM',
vmId: '',
description: 'The Z-Chain handles zero-knowledge proof generation and verification on the network.',
},
'g-chain': {
name: 'G-Chain',
fullName: 'Graph Chain',
vm: 'GraphVM',
vmId: '',
description: 'The G-Chain provides decentralized graph indexing and query services on the network.',
},
'k-chain': {
name: 'K-Chain',
fullName: 'Key Chain',
vm: 'KeyVM',
vmId: '',
description: 'The K-Chain provides decentralized key management and custody services on the network.',
},
'o-chain': {
name: 'O-Chain',
fullName: 'Oracle Chain',
vm: 'OracleVM',
vmId: '',
description: 'The O-Chain provides decentralized oracle services, bringing off-chain data on-chain for the network.',
},
'r-chain': {
name: 'R-Chain',
fullName: 'Relay Chain',
vm: 'RelayVM',
vmId: '',
description: 'The R-Chain handles cross-chain message relay and interoperability routing on the network.',
},
'i-chain': {
name: 'I-Chain',
fullName: 'Identity Chain',
vm: 'IdentityVM',
vmId: '',
description: 'The I-Chain manages decentralized identity, DIDs, and verifiable credentials on the network.',
},
'm-chain': {
name: 'M-Chain',
fullName: 'MPC Chain',
vm: 'MPCVM',
vmId: '',
description: 'The M-Chain coordinates CGGMP21 threshold ECDSA and FROST threshold EdDSA signing sessions across validators.',
},
};
// Primary-network VM identity (name / vm / vmId / description / view) comes from
// the single source of truth in configs/app/primaryChains.ts.
const SUBNET_CHAIN_IDS: Readonly<Record<string, number>> = {
zoo: 200200,
@@ -226,7 +119,10 @@ interface InfoRowProps {
}
const InfoRow = ({ label, value, isMono = false, canCopy = false }: InfoRowProps) => (
<div className="flex py-3 px-4 border-b border-[var(--color-border-divider)] odd:bg-[var(--color-gray-50)] dark:odd:bg-[var(--color-whiteAlpha-50)] gap-4 flex-wrap lg:flex-nowrap">
<div className={ cn(
'flex py-3 px-4 border-b border-[var(--color-border-divider)] gap-4 flex-wrap lg:flex-nowrap',
'odd:bg-[var(--color-gray-50)] dark:odd:bg-[var(--color-whiteAlpha-50)]',
) }>
<div className="min-w-full lg:min-w-[200px] shrink-0 text-[var(--color-text-secondary)] text-sm font-medium">
{ label }
</div>
@@ -249,7 +145,10 @@ const ValidatorRow = ({ validator, index }: ValidatorRowProps) => {
const uptime = parseFloat(validator.uptime || '0') * 100;
return (
<div className="flex py-3 px-4 border-b border-[var(--color-border-divider)] hover:bg-[var(--color-gray-50)] dark:hover:bg-[var(--color-whiteAlpha-50)] transition-colors duration-150 gap-4 items-center flex-wrap lg:flex-nowrap">
<div className={ cn(
'flex py-3 px-4 border-b border-[var(--color-border-divider)] gap-4 items-center flex-wrap lg:flex-nowrap',
'transition-colors duration-150 hover:bg-[var(--color-gray-50)] dark:hover:bg-[var(--color-whiteAlpha-50)]',
) }>
<div className="w-10 shrink-0 text-[var(--color-text-secondary)] text-sm">
{ index + 1 }
</div>
@@ -287,10 +186,10 @@ const ChainDetailPage = () => {
const resolvedChain = React.useMemo<{
blockchain: PChainBlockchain | undefined;
isPrimary: boolean;
meta: typeof PRIMARY_CHAIN_META[string] | undefined;
meta: PrimaryVm | undefined;
}>(() => {
// Check primary chains first
const primaryMeta = PRIMARY_CHAIN_META[slug];
const primaryMeta = getPrimaryVm(slug);
if (primaryMeta) {
return { blockchain: undefined, isPrimary: true, meta: primaryMeta };
}
@@ -333,8 +232,15 @@ const ChainDetailPage = () => {
[ validators ],
);
// D-Chain (DexVM) renders its native order-book model — the DEX UI — instead
// of the generic validators/stats detail. DexPage self-guards to the primary
// explorer, so /chains/d-chain can never leak onto a brand explorer.
if (resolvedChain.meta?.view === 'dex') {
return <DexPage/>;
}
return (
<>
<PrimaryNetworkGuard title={ chainName }>
<PageTitle
title={ chainName }
secondRow={ (
@@ -468,7 +374,10 @@ const ChainDetailPage = () => {
{ subnetChains.map((chain) => (
<div
key={ chain.id }
className="flex px-4 py-3 gap-4 border-b border-[var(--color-border-divider)] last:border-b-0 hover:bg-[var(--color-gray-50)] dark:hover:bg-[var(--color-whiteAlpha-50)] transition-colors duration-150 flex-wrap lg:flex-nowrap"
className={ cn(
'flex px-4 py-3 gap-4 border-b border-[var(--color-border-divider)] last:border-b-0 flex-wrap lg:flex-nowrap',
'transition-colors duration-150 hover:bg-[var(--color-gray-50)] dark:hover:bg-[var(--color-whiteAlpha-50)]',
) }
>
<div className="flex-1">
<span className="text-sm font-medium text-[var(--color-text-primary)]">
@@ -484,7 +393,10 @@ const ChainDetailPage = () => {
</div>
</div>
<div className="w-[120px]">
<div className="inline-block bg-[var(--color-gray-100)] dark:bg-[var(--color-whiteAlpha-100)] text-[var(--color-text-secondary)] rounded-sm px-2 py-0.5 text-xs font-mono">
<div className={ cn(
'inline-block bg-[var(--color-gray-100)] dark:bg-[var(--color-whiteAlpha-100)]',
'text-[var(--color-text-secondary)] rounded-sm px-2 py-0.5 text-xs font-mono',
) }>
{ KNOWN_VM_IDS[chain.vmID] ?? truncateId(chain.vmID, 12) }
</div>
</div>
@@ -542,7 +454,7 @@ const ChainDetailPage = () => {
)) }
</div>
</div>
</>
</PrimaryNetworkGuard>
);
};
+12 -25
View File
@@ -1,11 +1,13 @@
import { Skeleton } from '@luxfi/ui/skeleton';
import React from 'react';
import config from 'configs/app';
import { PRIMARY_VMS } from 'configs/app/primaryChains';
import type { PChainBlockchain } from 'lib/api/pchain';
import { useBlockchains } from 'lib/api/pchain';
import { cn } from 'lib/utils/cn';
import { Skeleton } from '@luxfi/ui/skeleton';
import PageTitle from 'ui/shared/Page/PageTitle';
import PrimaryNetworkGuard from 'ui/shared/PrimaryNetworkGuard';
import ChainRow from './ChainRow';
@@ -15,26 +17,9 @@ import ChainRow from './ChainRow';
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY' as const;
// All 15 primary network chains from ~/work/lux/node/node/vms_allvms.go
// Core: C (EVM), P (PlatformVM), X (XVM)
// Extended (allvms build): A, B, D, G, I, K, M, O, Q, R, T, Z
const PRIMARY_CHAINS = [
{ name: 'C-Chain', fullName: 'Contract Chain', vm: 'EVM', chainId: 96369, slug: 'c-chain' },
{ name: 'P-Chain', fullName: 'Platform Chain', vm: 'PVM', chainId: null, slug: 'p-chain' },
{ name: 'X-Chain', fullName: 'UTXO Chain', vm: 'XVM', chainId: null, slug: 'x-chain' },
{ name: 'D-Chain', fullName: 'DEX Chain', vm: 'DexVM', chainId: null, slug: 'd-chain' },
{ name: 'A-Chain', fullName: 'AI Chain', vm: 'AIVM', chainId: null, slug: 'a-chain' },
{ name: 'B-Chain', fullName: 'Bridge Chain', vm: 'BridgeVM', chainId: null, slug: 'b-chain' },
{ name: 'Q-Chain', fullName: 'Quantum Chain', vm: 'QuantumVM', chainId: null, slug: 'q-chain' },
{ name: 'T-Chain', fullName: 'Threshold Chain', vm: 'ThresholdVM', chainId: null, slug: 't-chain' },
{ name: 'M-Chain', fullName: 'MPC Chain', vm: 'MPCVM', chainId: null, slug: 'm-chain' },
{ name: 'Z-Chain', fullName: 'ZK Chain', vm: 'ZKVM', chainId: null, slug: 'z-chain' },
{ name: 'G-Chain', fullName: 'Graph Chain', vm: 'GraphVM', chainId: null, slug: 'g-chain' },
{ name: 'K-Chain', fullName: 'Key Chain', vm: 'KeyVM', chainId: null, slug: 'k-chain' },
{ name: 'O-Chain', fullName: 'Oracle Chain', vm: 'OracleVM', chainId: null, slug: 'o-chain' },
{ name: 'R-Chain', fullName: 'Relay Chain', vm: 'RelayVM', chainId: null, slug: 'r-chain' },
{ name: 'I-Chain', fullName: 'Identity Chain', vm: 'IdentityVM', chainId: null, slug: 'i-chain' },
] as const;
// Primary-network VM chains come from the single source of truth in
// configs/app/primaryChains.ts (mirrored from the node registry), so this page
// and the chain-detail page can never drift apart.
const SUBNET_CHAIN_IDS: Readonly<Record<string, number>> = {
Zoo: 200200,
@@ -96,7 +81,9 @@ const TabButton = ({ label, isActive, onClick }: TabButtonProps) => (
<button
className={ cn(
'px-4 py-2 text-sm bg-transparent cursor-pointer transition-all duration-150 border-b-2 hover:text-[var(--color-text-primary)]',
isActive ? 'font-semibold text-[var(--color-text-primary)] border-[var(--color-text-primary)]' : 'font-normal text-[var(--color-text-secondary)] border-transparent',
isActive ?
'font-semibold text-[var(--color-text-primary)] border-[var(--color-text-primary)]' :
'font-normal text-[var(--color-text-secondary)] border-transparent',
) }
onClick={ onClick }
>
@@ -128,7 +115,7 @@ const ChainsPage = () => {
}, []);
return (
<>
<PrimaryNetworkGuard title="Chains">
<PageTitle
title="Chains"
secondRow={ (
@@ -156,7 +143,7 @@ const ChainsPage = () => {
{ activeTab === TAB_IDS.primary && (
<div className="border border-[var(--color-border-divider)] rounded-md overflow-hidden">
<TableHeader showSubnetId={ false }/>
{ PRIMARY_CHAINS.map((chain) => (
{ PRIMARY_VMS.map((chain) => (
<ChainRow
key={ chain.name }
name={ chain.name }
@@ -202,7 +189,7 @@ const ChainsPage = () => {
)) }
</div>
) }
</>
</PrimaryNetworkGuard>
);
};
+13 -6
View File
@@ -1,6 +1,7 @@
import React from 'react';
import config from 'configs/app';
import { isPrimaryNetworkExplorer } from 'configs/app/chainRegistry';
import { useBlockchains, useChainHeights, useCurrentValidators } from 'lib/api/pchain';
import type { PChainBlockchain } from 'lib/api/pchain';
import { cn } from 'lib/utils/cn';
@@ -242,14 +243,20 @@ const NetworkOverview = () => {
// Hide validator metrics when API returned an error (show dashes instead of 0)
const hasValidatorData = !validatorsError && stats.validatorCount > 0;
// Primary-network panels (validator metrics strip + the 15 primary-network
// chains + the cross-L1 list) belong ONLY on the Lux primary-network explorer.
// Brand explorers (Hanzo / Zoo / SPC / Pars, vm: 'L2') show ONLY their own
// chain — Hero + Stats + Latest blocks/txns — never the parent's chains.
const isPrimary = isPrimaryNetworkExplorer();
return (
<HomeRpcDataContextProvider>
<div className="flex flex-col gap-6 lg:gap-8">
{ /* ── Hero ── */ }
<HeroBanner/>
{ /* ── Metrics strip ── */ }
<div className={ cn(
{ /* ── Metrics strip (Lux primary network only) ── */ }
{ isPrimary && <div className={ cn(
'flex items-center justify-center flex-wrap overflow-hidden rounded-lg',
'py-4 px-4 gap-x-6 gap-y-3 border border-[var(--color-border-divider)]',
'bg-[var(--color-stats-bg)]',
@@ -285,7 +292,7 @@ const NetworkOverview = () => {
value={ hasValidatorData ? `${ stats.connectedCount }/${ stats.validatorCount }` : '\u2014' }
isLoading={ validatorsLoading }
/>
</div>
</div> }
{ /* ── Stats widgets ── */ }
<Stats/>
@@ -300,8 +307,8 @@ const NetworkOverview = () => {
<Transactions/>
</div>
{ /* ── Chain Health ── */ }
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 lg:gap-6">
{ /* ── Chain Health (Lux primary network only) ── */ }
{ isPrimary && <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 lg:gap-6">
{ /* Primary Network chains */ }
<SectionCard
title="Primary Network"
@@ -414,7 +421,7 @@ const NetworkOverview = () => {
</>
) }
</SectionCard>
</div>
</div> }
</div>
</HomeRpcDataContextProvider>
);
+44 -24
View File
@@ -10,6 +10,12 @@ import NumberWidgetsList from '../stats/NumberWidgetsList';
import StatsFilters from '../stats/StatsFilters';
import useStats from '../stats/useStats';
// Aggregate counters and historical charts are served by the Blockscout stats
// microservice. On networks where it is not deployed we show the live,
// chain-sourced Network Overview (validators / chains / stake from the P-chain)
// plus an honest note — never fabricated counters.
const hasStatsService = config.features.stats.isEnabled;
const Stats = () => {
const {
isPlaceholderData,
@@ -34,32 +40,46 @@ const Stats = () => {
<NetworkStats/>
<div className="mb-6 sm:mb-8">
<NumberWidgetsList/>
</div>
{ hasStatsService ? (
<>
<div className="mb-6 sm:mb-8">
<NumberWidgetsList/>
</div>
<div className="mb-6 sm:mb-8">
<StatsFilters
isLoading={ isPlaceholderData }
initialFilterValue={ initialFilterQuery }
sections={ sections }
currentSection={ currentSection }
onSectionChange={ handleSectionChange }
interval={ interval }
onIntervalChange={ handleIntervalChange }
onFilterInputChange={ handleFilterChange }
/>
</div>
<div className="mb-6 sm:mb-8">
<StatsFilters
isLoading={ isPlaceholderData }
initialFilterValue={ initialFilterQuery }
sections={ sections }
currentSection={ currentSection }
onSectionChange={ handleSectionChange }
interval={ interval }
onIntervalChange={ handleIntervalChange }
onFilterInputChange={ handleFilterChange }
/>
</div>
<ChartsWidgetsList
initialFilterQuery={ initialFilterQuery }
isError={ isError }
isPlaceholderData={ isPlaceholderData }
charts={ displayedCharts }
interval={ interval }
sections={ sections }
selectedSectionId={ currentSection }
/>
<ChartsWidgetsList
initialFilterQuery={ initialFilterQuery }
isError={ isError }
isPlaceholderData={ isPlaceholderData }
charts={ displayedCharts }
interval={ interval }
sections={ sections }
selectedSectionId={ currentSection }
/>
</>
) : (
<div className="mt-8 rounded-lg border border-[var(--color-border-divider)] px-6 py-10 text-center">
<div className="text-base font-medium text-[var(--color-text-primary)]">
Historical statistics are being indexed
</div>
<div className="mt-2 text-sm text-[var(--color-text-secondary)]">
Live network data is shown above. Aggregate counters and charts will
appear here once the analytics indexer is online for { config.chain.name }.
</div>
</div>
) }
</>
);
};
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import { isPrimaryNetworkExplorer } from 'configs/app/chainRegistry';
import PageTitle from 'ui/shared/Page/PageTitle';
// Primary-network VM surfaces (Chains / Chain detail / DEX) belong to the Lux
// primary network only. On a brand explorer (Hanzo / Zoo / Pars / SPC / Osage)
// these chains must never appear — see the chain-visibility rule in
// chainRegistry.ts. The nav hides the entries; this guard backstops direct-URL
// access so the primary VMs can never leak onto a brand explorer.
interface Props {
readonly title: string;
readonly children: React.ReactNode;
}
const PrimaryNetworkGuard = ({ title, children }: Props) => {
if (!isPrimaryNetworkExplorer()) {
return (
<>
<PageTitle title={ title }/>
<div className="px-4 py-10 text-center text-sm text-[var(--color-text-secondary)]">
This explorer shows only its own chain. The Lux primary-network chains
are available on the Lux explorer.
</div>
</>
);
}
return children;
};
export default PrimaryNetworkGuard;
+2 -2
View File
@@ -25,7 +25,7 @@ const TxFee = ({ tx, accuracy, accuracyUsd, loading, noSymbol: noSymbolProp, noU
if ('celo' in tx && tx.celo?.gas_token) {
return (
<TokenValue
amount={ tx.fee.value || '0' }
amount={ tx.fee?.value || '0' }
token={ tx.celo.gas_token }
accuracy={ accuracy }
accuracyUsd={ accuracyUsd }
@@ -54,7 +54,7 @@ const TxFee = ({ tx, accuracy, accuracyUsd, loading, noSymbol: noSymbolProp, noU
return (
<NativeCoinValue
amount={ tx.fee.value || '0' }
amount={ tx.fee?.value || '0' }
noSymbol={ noSymbol }
exchangeRate={ noUsd ? null : exchangeRate }
historicalExchangeRate={ noUsd ? null : historicalExchangeRate }
@@ -1,11 +1,11 @@
import { Separator } from '@luxfi/ui/separator';
import { Tooltip } from '@luxfi/ui/tooltip';
import React from 'react';
import type { NavGroupItem } from 'types/client/navigation';
import { Link } from 'toolkit/next/link';
import { Separator } from '@luxfi/ui/separator';
import { Tooltip } from '@luxfi/ui/tooltip';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import { Link } from 'toolkit/next/link';
import IconSvg from 'ui/shared/IconSvg';
import LightningLabel from '../LightningLabel';
@@ -55,8 +55,12 @@ const NavLinkGroup = ({ item }: Props) => {
content={ content }
onOpenChange={ onOpenChange }
lazyMount={ false }
// The popover Content defaults to `overflow-hidden` + tight `py-2`, which
// clips the last nav item (e.g. "Verified Contracts") of a tall menu list.
// Override to let the full list render and give it breathing room.
contentProps={{ className: 'overflow-visible px-1.5 py-2' }}
positioning={{
placement: 'bottom',
placement: 'bottom-start',
offset: { mainAxis: 8 },
}}
interactive
+14 -23
View File
@@ -2,41 +2,32 @@ import React from 'react';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { useColorModeValue } from 'toolkit/next/color-mode';
import { Image } from '@luxfi/ui/image';
const IconFallback = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="30" height="30" aria-label={ `${ config.chain.name } icon` }>
<polygon points="25,46.65 50,3.35 0,3.35" fill="currentColor"/>
</svg>
);
};
import { getCurrentChain } from 'configs/app/chainRegistry';
type Props = {
className?: string;
};
// Renders the resolved chain's brand mark (inline SVG from chainRegistry),
// matching the desktop TopBar. No image-URL dependency / triangle fallback —
// the per-host branding (e.g. the Hanzo H) is authoritative.
const NetworkIcon = ({ className }: Props) => {
const iconSrc = useColorModeValue(config.UI.navigation.icon.default, config.UI.navigation.icon.dark || config.UI.navigation.icon.default);
const chain = getCurrentChain();
return (
<a
className={ className }
href={ route({ pathname: '/' }) }
aria-label="Link to main page"
aria-label={ `${ chain.branding.brandName } home` }
>
<Image
w="30px"
h="30px"
src={ iconSrc }
alt={ `${ config.chain.name } network icon` }
fallback={ <IconFallback/> }
objectFit="contain"
objectPosition="left"
className={ !config.UI.navigation.icon.dark ? 'dark:brightness-0 dark:invert' : undefined }
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={ chain.branding.logoViewBox }
width="30"
height="30"
className="shrink-0 text-[var(--color-text-primary)]"
aria-label={ `${ chain.branding.brandName } icon` }
dangerouslySetInnerHTML={{ __html: chain.branding.logoContent }}
/>
</a>
);
+19 -29
View File
@@ -2,47 +2,37 @@ import React from 'react';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { useColorModeValue } from 'toolkit/next/color-mode';
import { Image } from '@luxfi/ui/image';
const LogoFallback = () => {
return (
<div className="flex items-center gap-2 h-6">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="24" height="24">
<polygon points="25,46.65 50,3.35 0,3.35" fill="currentColor"/>
</svg>
<span className="font-bold text-lg tracking-tight">
{ config.chain.name }
</span>
</div>
);
};
import { getCurrentChain } from 'configs/app/chainRegistry';
type Props = {
className?: string;
};
// Renders the resolved chain's brand mark + name (inline SVG from
// chainRegistry), matching the desktop TopBar. No image-URL dependency /
// triangle fallback — the per-host branding (e.g. the Hanzo H) is authoritative.
const NetworkLogo = ({ className }: Props) => {
const logoSrc = useColorModeValue(config.UI.navigation.logo.default, config.UI.navigation.logo.dark || config.UI.navigation.logo.default);
const chain = getCurrentChain();
return (
<a
className={ className }
href={ route({ pathname: '/' }) }
aria-label="Link to main page"
aria-label={ `${ chain.branding.brandName } home` }
>
<Image
h="24px"
skeletonWidth="120px"
src={ logoSrc }
alt={ `${ config.chain.name } network logo` }
fallback={ <LogoFallback/> }
objectFit="contain"
objectPosition="left"
className={ !config.UI.navigation.logo.dark ? 'dark:brightness-0 dark:invert' : undefined }
/>
<div className="flex items-center gap-2 h-6">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={ chain.branding.logoViewBox }
width="24"
height="24"
className="shrink-0 text-[var(--color-text-primary)]"
dangerouslySetInnerHTML={{ __html: chain.branding.logoContent }}
/>
<span className="font-bold text-lg tracking-tight whitespace-nowrap text-[var(--color-text-primary)]">
{ chain.branding.brandName }
</span>
</div>
</a>
);
};
+3 -1
View File
@@ -63,7 +63,9 @@ const TopBar = () => {
return (
<div
className={ cn(
'sticky top-0 left-0 w-full max-w-[100vw] z-sticky',
// Desktop-only: HeaderMobile (block lg:hidden) is the sole header below
// lg. Without this, both render < lg → a double header (two sign-ins).
'hidden lg:block sticky top-0 left-0 w-full max-w-[100vw] z-sticky',
'border-b border-[var(--color-border-divider)]',
'backdrop-blur-[16px] bg-[rgba(255,255,255,0.97)] dark:bg-[rgba(16,17,18,0.97)]',
) }
@@ -1,17 +1,19 @@
// OIDC user profile desktop component.
// Login redirects to lux.id; popover shows account, wallet, settings, and logout.
// Login redirects to the configured OIDC server (NEXT_PUBLIC_OIDC_SERVER_URL,
// e.g. https://hanzo.id for the Hanzo build); popover shows account, wallet,
// settings, and logout.
import type { ButtonProps } from '@luxfi/ui/button';
import { Button } from '@luxfi/ui/button';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from '@luxfi/ui/popover';
import { Separator } from '@luxfi/ui/separator';
import React from 'react';
import config from 'configs/app';
import useWeb3AccountWithDomain from 'lib/web3/useAccountWithDomain';
import useWeb3Wallet from 'lib/web3/useWallet';
import type { ButtonProps } from '@luxfi/ui/button';
import { Button } from '@luxfi/ui/button';
import { Link } from 'toolkit/next/link';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from '@luxfi/ui/popover';
import { Separator } from '@luxfi/ui/separator';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import { Link } from 'toolkit/next/link';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import IconSvg from 'ui/shared/IconSvg';
import useLogout from 'ui/snippets/auth/useLogout';
@@ -25,6 +27,22 @@ import SettingsScamTokens from 'ui/snippets/topBar/settings/SettingsScamTokens';
const REDIRECT_URI_PATH = '/auth/callback';
const isWalletEnabled = config.features.blockchainInteraction.isEnabled;
// The identity provider's brand is the host of the configured OIDC server
// (e.g. "hanzo.id" for the Hanzo build, "lux.id" for Lux). Derive the
// sign-in label from it so the button is brand-correct on every surface
// instead of hard-coding a single brand.
function oidcProviderName(): string {
const feature = config.features.account;
if (!feature.isEnabled || feature.authProvider !== 'oidc' || !feature.oidc) {
return 'ID';
}
try {
return new URL(feature.oidc.serverUrl).host;
} catch {
return 'ID';
}
}
function buildOidcLoginUrl(): string {
const feature = config.features.account;
if (!feature.isEnabled || feature.authProvider !== 'oidc' || !feature.oidc) {
@@ -167,7 +185,7 @@ const UserProfileOidc = ({ buttonSize, buttonVariant = 'header' }: Props) => {
onClick={ handleLoginClick }
variant="solid"
>
Sign in with Lux ID
Sign in with { oidcProviderName() }
</Button>
<Separator className="my-3"/>
</>
+4
View File
@@ -5,6 +5,7 @@ import type * as stats from '@luxfi/stats-types';
import type { StatsIntervalIds } from 'types/client/stats';
import type { ExternalChainExtended } from 'types/externalChains';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import { STATS_CHARTS } from 'stubs/stats';
@@ -27,6 +28,9 @@ export default function useStats({ chain }: Props = {}) {
const { data, isPlaceholderData, isError } = useApiQuery('stats:lines', {
queryOptions: {
placeholderData: STATS_CHARTS,
// No stats microservice configured → don't fire a request that can only
// fail; the page renders the live Network Overview + an honest note.
enabled: config.features.stats.isEnabled,
},
chain,
});
+1 -1
View File
@@ -71,7 +71,7 @@ const TxDetailsWrapped = ({ data }: Props) => {
</DetailedInfo.ItemLabel>
<DetailedInfoNativeCoinValue amount={ data.value }/>
{ data.fee.value !== null && (
{ data.fee?.value != null && (
<>
<DetailedInfo.ItemLabel
hint="Total transaction fee"
+1 -1
View File
@@ -39,7 +39,7 @@ const TxDetailsTxFee = ({ isLoading, data }: Props) => {
return (
<>
<NativeCoinValue
amount={ data.fee.value }
amount={ data.fee?.value }
exchangeRate={ exchangeRate }
historicalExchangeRate={ historicalExchangeRate }
hasExchangeRateToggle
+1 -1
View File
@@ -677,7 +677,7 @@ const TxInfo = ({ data, tacOperations, isLoading, socketStatus, noTxActions }: P
<TxDetailsGasPrice gasPrice={ data.gas_price } gasToken={ data.celo?.gas_token } isLoading={ isLoading }/>
<TxDetailsFeePerGas txFee={ data.fee.value } gasUsed={ data.gas_used } isLoading={ isLoading }/>
<TxDetailsFeePerGas txFee={ data.fee?.value } gasUsed={ data.gas_used } isLoading={ isLoading }/>
{ !config.UI.views.tx.additionalFields?.set_max_gas_limit && <TxDetailsGasUsage isLoading={ isLoading } data={ data }/> }
+1 -1
View File
@@ -66,7 +66,7 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
noTooltip
/>
</div>
{ !config.UI.views.tx.hiddenFields?.tx_fee && (tx.stability_fee !== undefined || tx.fee.value !== null) && (
{ !config.UI.views.tx.hiddenFields?.tx_fee && (tx.stability_fee !== undefined || tx.fee?.value != null) && (
<div { ...sectionProps }>
<span { ...sectionTitleProps }>Transaction fee</span>
<TxFee tx={ tx } noTooltip/>
+1 -1
View File
@@ -124,7 +124,7 @@ const TxsListItem = ({
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<div className="flex">
{ (tx.stability_fee !== undefined || tx.fee.value !== null) && (
{ (tx.stability_fee !== undefined || tx.fee?.value != null) && (
<>
<Skeleton loading={ isLoading } className="inline-block whitespace-pre">Fee </Skeleton>
<TxFee tx={ tx } loading={ isLoading }/>
+2 -2
View File
@@ -10,9 +10,9 @@ export default function sortTxs(sorting: TransactionsSortingValue | undefined) {
case 'value-asc':
return compareBns(tx1.value, tx2.value);
case 'fee-desc':
return compareBns(tx2.fee.value || 0, tx1.fee.value || 0);
return compareBns(tx2.fee?.value || 0, tx1.fee?.value || 0);
case 'fee-asc':
return compareBns(tx1.fee.value || 0, tx2.fee.value || 0);
return compareBns(tx1.fee?.value || 0, tx2.fee?.value || 0);
case 'block_number-asc': {
if (tx1.block_number && tx2.block_number) {
return tx1.block_number - tx2.block_number;
+7 -4
View File
@@ -1,11 +1,14 @@
import { Skeleton } from '@luxfi/ui/skeleton';
import React from 'react';
import config from 'configs/app';
import type { PChainDelegator, PChainValidator } from 'lib/api/pchain';
import dayjs from 'lib/date/dayjs';
import { Skeleton } from '@luxfi/ui/skeleton';
import { formatStake, truncateNodeId } from './utils';
const CURRENCY = config.chain.currency.symbol || 'LUX';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -69,7 +72,7 @@ const DelegatorsList = ({ validators, isLoading }: DelegatorsListProps) => {
return (
<div
className="overflow-hidden"
className="overflow-hidden text-[var(--color-text-primary)]"
>
{ /* Header */ }
<div className="flex">
@@ -125,7 +128,7 @@ const DelegatorsList = ({ validators, isLoading }: DelegatorsListProps) => {
{ truncateNodeId(d.validatorNodeID) }
</div>
<div className="text-left lg:text-right">
{ formatStake(d.stakeAmount) } LUX
{ formatStake(d.stakeAmount) } { CURRENCY }
</div>
<div>
{ formatTimestamp(d.startTime) }
@@ -134,7 +137,7 @@ const DelegatorsList = ({ validators, isLoading }: DelegatorsListProps) => {
{ formatTimestamp(d.endTime) }
</div>
<div className="text-left lg:text-right">
{ formatStake(d.potentialReward) } LUX
{ formatStake(d.potentialReward) } { CURRENCY }
</div>
</div>
)) }
+10 -7
View File
@@ -1,10 +1,13 @@
import { Skeleton } from '@luxfi/ui/skeleton';
import React from 'react';
import config from 'configs/app';
import type { PChainValidator, ValidatorStats } from 'lib/api/pchain';
import { Skeleton } from '@luxfi/ui/skeleton';
import { formatStake, formatUptime, truncateNodeId } from './utils';
const CURRENCY = config.chain.currency.symbol || 'LUX';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -61,13 +64,13 @@ const StakeBreakdown = ({ stats, isLoading }: StakeBreakdownProps) => {
</div>
<Skeleton loading={ isLoading }>
<div className="font-bold">
{ formatStake(stats.totalStake) } LUX
{ formatStake(stats.totalStake) } { CURRENCY }
</div>
</Skeleton>
<Skeleton loading={ isLoading }>
<div className="flex gap-6 flex-wrap">
<div>Validators: { formatStake(validatorStake) } LUX ({ validatorPct }%)</div>
<div>Delegated: { formatStake(stats.totalDelegatedStake) } LUX ({ delegationPct }%)</div>
<div>Validators: { formatStake(validatorStake) } { CURRENCY } ({ validatorPct }%)</div>
<div>Delegated: { formatStake(stats.totalDelegatedStake) } { CURRENCY } ({ delegationPct }%)</div>
</div>
</Skeleton>
</div>
@@ -140,7 +143,7 @@ const ActiveValidatorsTable = ({ validators, isLoading }: ActiveValidatorsTableP
{ truncateNodeId(v.nodeID) }
</div>
<div className="flex-[2] text-right">
{ formatStake(v.stakeAmount ?? v.weight) } LUX
{ formatStake(v.stakeAmount ?? v.weight) } { CURRENCY }
</div>
<div className="flex-1 text-right">
{ v.delegationFee }%
@@ -169,11 +172,11 @@ interface ValidatorsDashboardProps {
const ValidatorsDashboard = ({ validators, stats, isLoading }: ValidatorsDashboardProps) => {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 text-[var(--color-text-primary)]">
{ /* Stat cards */ }
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(4, 1fr)" }}
style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}
>
<StatCard
label="Validators"
+8 -5
View File
@@ -1,11 +1,14 @@
import React from 'react';
import type { PChainValidator } from 'lib/api/pchain';
import { Input } from '@luxfi/ui/input';
import { Skeleton } from '@luxfi/ui/skeleton';
import React from 'react';
import config from 'configs/app';
import type { PChainValidator } from 'lib/api/pchain';
import { formatStake, formatUptime, truncateNodeId } from './utils';
const CURRENCY = config.chain.currency.symbol || 'LUX';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -48,7 +51,7 @@ const ValidatorsList = ({ validators, isLoading }: ValidatorsListProps) => {
}, [ validators, search ]);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 text-[var(--color-text-primary)]">
{ /* Search */ }
<div>
<Input
@@ -112,7 +115,7 @@ const ValidatorsList = ({ validators, isLoading }: ValidatorsListProps) => {
{ truncateNodeId(v.nodeID) }
</div>
<div className="flex-[2] text-right">
{ formatStake(v.stakeAmount ?? v.weight) } LUX
{ formatStake(v.stakeAmount ?? v.weight) } { CURRENCY }
</div>
<div className="flex-1 text-right">
{ v.delegationFee }%