mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
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:
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -39,9 +39,14 @@
|
||||
"zooai",
|
||||
"parsdao",
|
||||
"Kaniko",
|
||||
"unstub",
|
||||
"abfnrtv",
|
||||
"abitype",
|
||||
"AIVM",
|
||||
"MPCVM",
|
||||
"CGGMP",
|
||||
"UTXO",
|
||||
"decomplects",
|
||||
"allvms",
|
||||
"abkw",
|
||||
"ACMRT",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Generated
+623
-1133
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -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,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,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
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"/>
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,7 +39,7 @@ const TxDetailsTxFee = ({ isLoading, data }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<NativeCoinValue
|
||||
amount={ data.fee.value }
|
||||
amount={ data.fee?.value }
|
||||
exchangeRate={ exchangeRate }
|
||||
historicalExchangeRate={ historicalExchangeRate }
|
||||
hasExchangeRateToggle
|
||||
|
||||
@@ -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 }/> }
|
||||
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)) }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }%
|
||||
|
||||
Reference in New Issue
Block a user