Files
zeekayandClaude Opus 5.5 4432040ad3 Zoo explorer footer links to the zooai org and Zoo's own accounts
github.com/zoolabs and x.com/zoolabs belong to someone else. The links now
match @zooai/brand: github.com/zooai, x.com/zoo_labs and the Zoo Discord.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-23 16:10:59 -07:00

446 lines
17 KiB
TypeScript

// Multi-tenant chain registry. Maps hostnames to chain and network configuration.
// One Docker image serves all chains — the hostname determines the active chain
// for the bundled brand defaults; every field is overridable at deploy time
// via NEXT_PUBLIC_BRAND_* env vars (see applyBrandEnvOverrides).
import { getEnvValue } from 'configs/app/utils';
export interface ChainBranding {
/** Display name in the header (e.g. "Lux Network", "Zoo Chain") */
readonly brandName: string;
/** Sub-label rendered next to the logo (defaults to "Explorer") */
readonly productName: string;
/** Organization name for copyright (e.g. "Lux Industries Inc.") */
readonly orgName: string;
/** Primary website URL */
readonly websiteUrl: string;
/** One-line description for footer */
readonly description: string;
/** GitHub org URL */
readonly githubUrl: string;
/** Repository this frontend is built from, for the footer's version link.
* Every brand serves the same image from its own org's mirror, so the link
* has to follow the brand — a Lux URL on a Hanzo page is a cross-brand leak. */
readonly sourceUrl: string;
/** Twitter/X URL */
readonly twitterUrl: string;
/** Discord invite URL */
readonly discordUrl: string;
/** viewBox for the inline logo SVG */
readonly logoViewBox: string;
/** Inner SVG content for the inline logo (multi-path supported, uses currentColor) */
readonly logoContent: string;
/** Full inner SVG content for the favicon (viewBox "0 0 512 512") */
readonly faviconContent: string;
}
/** Apply NEXT_PUBLIC_BRAND_* env overrides on top of bundled defaults.
* Operators can override any branding field at deploy time without
* rebuilding the image. Empty env values fall through to the default. */
export function applyBrandEnvOverrides(b: ChainBranding): ChainBranding {
return {
brandName: getEnvValue('NEXT_PUBLIC_BRAND_NAME') || b.brandName,
productName: getEnvValue('NEXT_PUBLIC_BRAND_PRODUCT_NAME') || b.productName,
orgName: getEnvValue('NEXT_PUBLIC_BRAND_ORG_NAME') || b.orgName,
websiteUrl: getEnvValue('NEXT_PUBLIC_BRAND_WEBSITE_URL') || b.websiteUrl,
description: getEnvValue('NEXT_PUBLIC_BRAND_DESCRIPTION') || b.description,
githubUrl: getEnvValue('NEXT_PUBLIC_BRAND_GITHUB_URL') || b.githubUrl,
sourceUrl: getEnvValue('NEXT_PUBLIC_BRAND_SOURCE_URL') || b.sourceUrl,
twitterUrl: getEnvValue('NEXT_PUBLIC_BRAND_TWITTER_URL') || b.twitterUrl,
discordUrl: getEnvValue('NEXT_PUBLIC_BRAND_DISCORD_URL') || b.discordUrl,
logoViewBox: getEnvValue('NEXT_PUBLIC_BRAND_LOGO_VIEWBOX') || b.logoViewBox,
logoContent: getEnvValue('NEXT_PUBLIC_BRAND_LOGO_CONTENT') || b.logoContent,
faviconContent: getEnvValue('NEXT_PUBLIC_BRAND_FAVICON_CONTENT') || b.faviconContent,
};
}
export interface ChainEntry {
readonly name: string;
readonly label: string;
readonly vm: string;
readonly network: 'mainnet' | 'testnet' | 'devnet' | 'localnet';
/** EVM chain ID (uint64). Canonical values tracked in MEMORY.md. */
readonly chainId: number;
readonly hostnames: ReadonlyArray<string>;
readonly explorerUrl: string;
readonly apiUrl: string;
/**
* Origin of the chain's own node API, where `<origin>/v1/chain/P` answers
* platform.* — NOT the indexer at `apiUrl`. Every sovereign L1 runs its own
* P-Chain and its own validator set, so this is what makes a validator count
* readable per chain.
*
* Absent means there is no publicly reachable node for that chain. Absence
* is the honest signal: a chain without this is reported as unknown rather
* than counted as zero.
*/
readonly nodeApiUrl?: string;
readonly branding: ChainBranding;
}
export interface NetworkEntry {
readonly name: string;
readonly label: string;
readonly network: 'mainnet' | 'testnet' | 'devnet' | 'localnet';
readonly baseHostname: string;
readonly explorerUrl: string;
}
// ── Per-chain branding definitions ──
// Real logos sourced from ~/work/{org}/logo/ repos.
/** Lux — downward-pointing triangle (~/work/lux/logo/) */
const LUX_BRANDING: ChainBranding = {
brandName: 'Lux Network',
productName: 'Explorer',
orgName: 'Lux Industries Inc.',
websiteUrl: 'https://lux.network',
description: 'High-performance blockchain for decentralized applications.',
githubUrl: 'https://github.com/luxfi',
sourceUrl: 'https://github.com/luxfi/explore',
twitterUrl: 'https://x.com/luxdefi',
discordUrl: 'https://discord.gg/luxnetwork',
logoViewBox: '0 0 100 100',
logoContent: '<path d="M50 85 L15 25 L85 25 Z" fill="currentColor"/>',
faviconContent: '<circle cx="256" cy="256" r="256"/><path fill="#fff" d="m256 410 179-308H77z"/>',
};
/** Zoo — colorful interlocking circles from ~/work/zoo/logo/ */
const ZOO_BRANDING: ChainBranding = {
brandName: 'Zoo Chain',
productName: 'Explorer',
orgName: 'Zoo Labs Foundation',
websiteUrl: 'https://zoo.ngo',
description: 'Open AI research network — decentralized AI and science.',
githubUrl: 'https://github.com/zooai',
sourceUrl: 'https://github.com/zooai/explore',
twitterUrl: 'https://x.com/zoo_labs',
discordUrl: 'https://discord.gg/edmZPTZjH9',
logoViewBox: '0 0 1024 1024',
logoContent:
'<defs>' +
'<clipPath id="zooClip"><circle cx="512" cy="511" r="270"/></clipPath>' +
'<clipPath id="zooGClip"><circle cx="513" cy="369" r="234"/></clipPath>' +
'<clipPath id="zooRClip"><circle cx="365" cy="595" r="234"/></clipPath>' +
'</defs>' +
'<g clip-path="url(#zooClip)">' +
'<circle cx="513" cy="369" r="234" fill="#00A652"/>' +
'<circle cx="365" cy="595" r="234" fill="#ED1C24"/>' +
'<circle cx="643" cy="595" r="234" fill="#2E3192"/>' +
'<g clip-path="url(#zooGClip)">' +
'<circle cx="365" cy="595" r="234" fill="#FCF006"/>' +
'<circle cx="643" cy="595" r="234" fill="#01ACF1"/>' +
'</g>' +
'<g clip-path="url(#zooRClip)">' +
'<circle cx="643" cy="595" r="234" fill="#EA018E"/>' +
'</g>' +
'<g clip-path="url(#zooGClip)">' +
'<g clip-path="url(#zooRClip)">' +
'<circle cx="643" cy="595" r="234" fill="#FFFFFF"/>' +
'</g></g></g>',
faviconContent:
'<rect width="512" height="512" rx="64" fill="#000"/>' +
'<g transform="translate(0,-5) scale(0.5)">' +
'<defs>' +
'<clipPath id="zfClip"><circle cx="512" cy="511" r="270"/></clipPath>' +
'<clipPath id="zfG"><circle cx="513" cy="369" r="234"/></clipPath>' +
'<clipPath id="zfR"><circle cx="365" cy="595" r="234"/></clipPath>' +
'</defs>' +
'<g clip-path="url(#zfClip)">' +
'<circle cx="513" cy="369" r="234" fill="#00A652"/>' +
'<circle cx="365" cy="595" r="234" fill="#ED1C24"/>' +
'<circle cx="643" cy="595" r="234" fill="#2E3192"/>' +
'<g clip-path="url(#zfG)">' +
'<circle cx="365" cy="595" r="234" fill="#FCF006"/>' +
'<circle cx="643" cy="595" r="234" fill="#01ACF1"/>' +
'</g>' +
'<g clip-path="url(#zfR)">' +
'<circle cx="643" cy="595" r="234" fill="#EA018E"/>' +
'</g>' +
'<g clip-path="url(#zfG)">' +
'<g clip-path="url(#zfR)">' +
'<circle cx="643" cy="595" r="234" fill="#FFFFFF"/>' +
'</g></g></g></g>',
};
/** Hanzo — geometric H logo (~/work/hanzo/logo/) */
const HANZO_BRANDING: ChainBranding = {
brandName: 'Hanzo AI',
productName: 'Explorer',
orgName: 'Hanzo AI, Inc.',
websiteUrl: 'https://hanzo.ai',
description: 'AI blockchain — decentralized compute and inference.',
githubUrl: 'https://github.com/hanzoai',
sourceUrl: 'https://github.com/hanzoai/explore',
twitterUrl: 'https://x.com/hanaboratory',
discordUrl: 'https://discord.gg/hanzoai',
logoViewBox: '0 0 67 67',
logoContent:
'<path d="M22.21 67V44.64H0V67H22.21Z" fill="currentColor"/>' +
'<path d="M66.7 22.32H22.25L0.09 44.64H44.46L66.7 22.32Z" fill="currentColor"/>' +
'<path d="M22.21 0H0V22.32H22.21V0Z" fill="currentColor"/>' +
'<path d="M66.72 0H44.51V22.32H66.72V0Z" fill="currentColor"/>' +
'<path d="M66.72 67V44.64H44.51V67H66.72Z" fill="currentColor"/>',
faviconContent:
'<rect width="512" height="512" rx="64" fill="#000"/>' +
'<g transform="translate(64,64) scale(5.73)">' +
'<path d="M22.21 67V44.64H0V67H22.21Z" fill="#fff"/>' +
'<path d="M66.7 22.32H22.25L0.09 44.64H44.46L66.7 22.32Z" fill="#fff"/>' +
'<path d="M22.21 0H0V22.32H22.21V0Z" fill="#fff"/>' +
'<path d="M66.72 0H44.51V22.32H66.72V0Z" fill="#fff"/>' +
'<path d="M66.72 67V44.64H44.51V67H66.72Z" fill="#fff"/>' +
'</g>',
};
export const CHAINS: ReadonlyArray<ChainEntry> = [
{
name: 'C-Chain',
label: 'Contract Chain',
vm: 'EVM',
network: 'mainnet',
chainId: 96369,
// explore.lux.build is a legacy alias — the runtime ingress 301-redirects
// it to explore.lux.network, so we only register the canonical host here.
hostnames: [ 'explore.lux.network', 'localhost', '127.0.0.1', '0.0.0.0' ],
explorerUrl: 'https://explore.lux.network',
apiUrl: 'https://api-explore.lux.network',
nodeApiUrl: 'https://api.lux.network',
branding: LUX_BRANDING,
},
{
name: 'Zoo',
label: 'Zoo Chain',
vm: 'L2',
network: 'mainnet',
chainId: 200200,
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',
nodeApiUrl: 'https://api.zoo.network',
branding: ZOO_BRANDING,
},
{
name: 'Hanzo',
label: 'Hanzo AI',
vm: 'L2',
network: 'mainnet',
chainId: 36963,
hostnames: [ 'explore-hanzo.lux.network', 'explore.hanzo.network', 'explore.hanzo.ai' ],
explorerUrl: 'https://explore-hanzo.lux.network',
apiUrl: 'https://api-explore-hanzo.lux.network',
nodeApiUrl: 'https://api.hanzo.network',
branding: HANZO_BRANDING,
},
// Testnet chains
{
name: 'C-Chain',
label: 'Contract Chain',
vm: 'EVM',
network: 'testnet',
chainId: 96368,
hostnames: [ 'explore-test.lux.network', 'explore.lux-test.network' ],
explorerUrl: 'https://explore-test.lux.network',
apiUrl: 'https://api-explore-test.lux.network',
branding: LUX_BRANDING,
},
{
name: 'Zoo',
label: 'Zoo Chain (Testnet)',
vm: 'L2',
network: 'testnet',
chainId: 200201,
hostnames: [ 'explore-zoo-test.lux.network' ],
explorerUrl: 'https://explore-zoo-test.lux.network',
apiUrl: 'https://api-explore-zoo-test.lux.network',
branding: ZOO_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
// selectors remain visible without env gymnastics.
{
name: 'C-Chain',
label: 'Contract Chain',
vm: 'EVM',
network: 'localnet',
chainId: 1337,
hostnames: [ 'explore.localnet', 'explore-local.lux.network' ],
explorerUrl: 'http://localhost:3000',
apiUrl: 'http://localhost:4000',
branding: LUX_BRANDING,
},
];
export const NETWORKS: ReadonlyArray<NetworkEntry> = [
{
name: 'Mainnet',
label: 'Production network',
network: 'mainnet',
baseHostname: 'explore.lux.network',
explorerUrl: 'https://explore.lux.network',
},
{
name: 'Testnet',
label: 'Test network',
network: 'testnet',
baseHostname: 'explore.lux-test.network',
explorerUrl: 'https://explore.lux-test.network',
},
{
name: 'Localnet',
label: 'Local development (chainId 1337)',
network: 'localnet',
baseHostname: 'localhost',
explorerUrl: 'http://localhost:3000',
},
];
// ── White-label support ──
// When the hostname doesn't match any known chain, build branding from env vars.
// This allows a single Docker image to serve any EVM chain.
function buildWhiteLabelBranding(): ChainBranding {
return {
brandName: getEnvValue('NEXT_PUBLIC_NETWORK_NAME') || 'Explorer',
productName: getEnvValue('NEXT_PUBLIC_BRAND_PRODUCT_NAME') || 'Explorer',
orgName: getEnvValue('NEXT_PUBLIC_NETWORK_ORG_NAME') || '',
websiteUrl: getEnvValue('NEXT_PUBLIC_NETWORK_WEBSITE_URL') || '',
description: getEnvValue('NEXT_PUBLIC_NETWORK_DESCRIPTION') || 'Blockchain explorer.',
githubUrl: getEnvValue('NEXT_PUBLIC_NETWORK_GITHUB_URL') || '',
twitterUrl: getEnvValue('NEXT_PUBLIC_NETWORK_TWITTER_URL') || '',
discordUrl: getEnvValue('NEXT_PUBLIC_NETWORK_DISCORD_URL') || '',
logoViewBox: '0 0 100 100',
logoContent: '<circle cx="50" cy="50" r="40" fill="currentColor"/>',
faviconContent: '<circle cx="256" cy="256" r="256"/>',
};
}
function buildWhiteLabelChain(hostname: string): ChainEntry {
const branding = buildWhiteLabelBranding();
const appHost = getEnvValue('NEXT_PUBLIC_APP_HOST') || hostname;
const apiHost = getEnvValue('NEXT_PUBLIC_API_HOST') || '';
const protocol = getEnvValue('NEXT_PUBLIC_APP_PROTOCOL') || 'https';
const apiProtocol = getEnvValue('NEXT_PUBLIC_API_PROTOCOL') || 'https';
return {
name: getEnvValue('NEXT_PUBLIC_NETWORK_SHORT_NAME') || branding.brandName,
label: branding.brandName,
vm: 'EVM',
network: (getEnvValue('NEXT_PUBLIC_IS_TESTNET') === 'true' ? 'testnet' : 'mainnet') as 'mainnet' | 'testnet' | 'devnet' | 'localnet',
chainId: Number(getEnvValue('NEXT_PUBLIC_NETWORK_ID')) || 0,
hostnames: [ hostname ],
explorerUrl: `${ protocol }://${ appHost }`,
apiUrl: apiHost ? `${ apiProtocol }://${ apiHost }` : '',
branding,
};
}
function getHostname(): string {
// Client: trust the browser — this is the request origin.
if (typeof window !== 'undefined') {
return window.location.hostname;
}
// Server: prefer the per-request Host header captured in AsyncLocalStorage
// at the start of each SSR render (see pages/_document.tsx). This keeps the
// FE stateless — one docker image serves any hostname. Fall back to the
// build-time env only when called outside a request (static prerender,
// health checks, etc.).
try {
// Lazy require so browser bundles don't pick up the ALS module.
const { getRequestHost } = require('lib/requestHost') as { getRequestHost: () => string };
const reqHost = getRequestHost();
if (reqHost) return reqHost;
} catch { /* Falls through to env fallback. */ }
return getEnvValue('NEXT_PUBLIC_APP_HOST') || 'explore.lux.network';
}
export function isWhiteLabelMode(): boolean {
const hostname = getHostname();
return !CHAINS.some((c) => c.hostnames.includes(hostname));
}
export function isChainSelectorEnabled(): boolean {
const envVal = getEnvValue('NEXT_PUBLIC_CHAIN_SELECTOR_ENABLED');
if (envVal !== undefined && envVal !== '') {
return envVal === 'true';
}
// Disabled by default in white-label mode
return !isWhiteLabelMode();
}
export function isNetworkSelectorEnabled(): boolean {
const envVal = getEnvValue('NEXT_PUBLIC_NETWORK_SELECTOR_ENABLED');
if (envVal !== undefined && envVal !== '') {
return envVal === 'true';
}
// Disabled by default in white-label mode
return !isWhiteLabelMode();
}
export function getCurrentChain(): ChainEntry {
const hostname = getHostname();
const found = CHAINS.find((c) => c.hostnames.includes(hostname));
const base = found ?? buildWhiteLabelChain(hostname);
// Apply NEXT_PUBLIC_BRAND_* env overrides on every read so deploys can
// rebrand without rebuilding the bundle.
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 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: 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 isPrimaryNetworkHost(getHostname());
}
/** isPrimaryNetworkExplorer() for an explicit host, e.g. a request's Host header. */
export function isPrimaryNetworkHost(hostname: string): boolean {
const chain = CHAINS.find((c) => c.hostnames.includes(hostname));
return chain !== undefined && chain.vm === 'EVM';
}
export function getCurrentNetwork(): NetworkEntry {
const chain = getCurrentChain();
if (isWhiteLabelMode()) {
// In white-label mode, return a single network entry from env vars
const branding = buildWhiteLabelBranding();
const appHost = getEnvValue('NEXT_PUBLIC_APP_HOST') || '';
const protocol = getEnvValue('NEXT_PUBLIC_APP_PROTOCOL') || 'https';
return {
name: chain.network === 'testnet' ? 'Testnet' : 'Mainnet',
label: branding.brandName,
network: chain.network,
baseHostname: appHost,
explorerUrl: appHost ? `${ protocol }://${ appHost }` : '',
};
}
return NETWORKS.find((n) => n.network === chain.network) ?? NETWORKS[0];
}
export function getChainsForNetwork(network: 'mainnet' | 'testnet' | 'devnet' | 'localnet'): ReadonlyArray<ChainEntry> {
return CHAINS.filter((c) => c.network === network);
}