Files
zeekayandHanzo Dev f434a701dc dex: show the AMM trades that are actually landing, next to an honest CLOB
The AMM subgraph has been indexed and at chain head the whole time; nothing
read it. /dex queried only the native CLOB at 0x9999, which has genuinely
never emitted an event, so the page reported MARKETS 0 / VOLUME 0 while ~1
swap per block was landing in the WLUX/LZOO pool.

Wire the amm subgraph in as a SECOND venue rather than folding it into the
first. The two are different exchanges and their figures are never summed —
each section reports only its own state, so "live" can never be borrowed by
the venue that is not trading.

Swaps were rendering as anonymous EVM traffic; they now read as trades:
pool-signed amounts resolved into sold/bought, scaled by each token's own
decimals, with symbols, pair, fee tier and links to sender, block and txn.

Status uses the words already in use elsewhere (useNetworkValidators,
useFeeSplit) — live/idle/empty/unavailable/unknown — defined once in
lib/api/venueStatus. There is deliberately no 'stale': separating a stalled
indexer from a quiet venue needs an indexer head this API does not serve.

Also decomplected while here: one fetcher for both subgraphs, and an
unreachable endpoint is no longer flattened into "empty" — an outage and a
venue that has never traded are different facts and now render differently.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:03:30 -07:00

304 lines
12 KiB
TypeScript

// C-Chain exchange page for the Lux explorer.
//
// Two venues trade on this chain and they are NOT the same thing, so their
// figures are never summed: the AMM (Uniswap-V3-style pools, where the trading
// actually happens today) and the native CLOB at 0x9999. Each section reports
// only its own state, in the vocabulary of lib/api/venueStatus. All data is
// real and network-aware — there is no mock/demo path.
import { Skeleton } from '@luxfi/ui/skeleton';
import BigNumber from 'bignumber.js';
import React from 'react';
import { route } from 'nextjs/routes';
import type { AmmSwapView } from 'lib/api/amm';
import { useAmmData } from 'lib/api/amm';
import type { DexFill, DexMarketView } from 'lib/api/dchain';
import { useDexData } from 'lib/api/dchain';
import type { VenueStatus } from 'lib/api/venueStatus';
import shortenString from 'lib/shortenString';
import { cn } from 'lib/utils/cn';
import PageTitle from 'ui/shared/Page/PageTitle';
// ── Constants ──
const ROW_BASE = 'flex items-center py-3 px-4 border-b border-[var(--color-border-divider)] ' +
'hover:bg-gray-50 dark:hover:bg-white/5 transition-[background] duration-150 gap-x-4 gap-y-2 flex-wrap lg:flex-nowrap';
const HEADER_BASE = 'hidden lg:flex px-4 py-2 gap-4 border-b border-[var(--color-border-divider)]';
const COL_HEADER = 'text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider';
const LINK_CLASS = 'text-sm font-mono text-[var(--color-link-primary)] hover:text-[var(--color-link-primary-hover)] hover:underline';
// What each status word means to a reader. The dot colour says the same thing
// without words; neither ever claims more than lib/api/venueStatus established.
const STATUS_LABEL: Record<VenueStatus, string> = {
live: 'Live',
idle: 'No recent trades',
empty: 'Never traded',
unavailable: 'Indexer unreachable',
unknown: 'Recency unknown',
};
const STATUS_DOT: Record<VenueStatus, string> = {
live: 'bg-[var(--color-green-500)]',
idle: 'bg-warn',
empty: 'bg-[var(--color-gray-400)]',
unavailable: 'bg-[var(--color-gray-400)]',
unknown: 'bg-[var(--color-gray-400)]',
};
// ── Helpers ──
function groupDigits(value: string): string {
return /^\d+$/.test(value) ? value.replace(/\B(?=(?:\d{3})+(?!\d))/g, ',') : value;
}
function formatFeeTier(feeTier: number): string {
return `${ (feeTier / 10_000).toFixed(2) }%`;
}
function formatUsd(value: string): string {
const amount = BigNumber(value);
return amount.isNaN() ? '—' : `$${ amount.dp(2).toFormat() }`;
}
// ── Sub-components ──
interface Stat {
readonly label: string;
readonly value: string;
}
const StatCard = ({ label, value, isLoading }: Stat & { readonly isLoading: boolean }) => (
<div className="border border-[var(--color-border-divider)] rounded-lg p-4 bg-gray-50 dark:bg-white/5 min-w-0">
<div className="text-xs text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider mb-1 truncate">
{ label }
</div>
<Skeleton loading={ isLoading }>
<div className="text-xl font-bold text-[var(--color-text-primary)] truncate">
{ value }
</div>
</Skeleton>
</div>
);
const StatusBadge = ({ status, isLoading }: { readonly status: VenueStatus; readonly isLoading: boolean }) => (
<Skeleton loading={ isLoading }>
<span className="inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] whitespace-nowrap">
<span className={ cn('w-2 h-2 rounded-full shrink-0', STATUS_DOT[status]) }/>
{ STATUS_LABEL[status] }
</span>
</Skeleton>
);
interface VenueSectionProps {
readonly title: string;
readonly subtitle: string;
readonly status: VenueStatus;
readonly isLoading: boolean;
readonly stats: ReadonlyArray<Stat>;
readonly children: React.ReactNode;
}
const VenueSection = ({ title, subtitle, status, isLoading, stats, children }: VenueSectionProps) => (
<section className="mb-10">
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
<div className="min-w-0">
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">{ title }</h2>
<div className="text-sm text-[var(--color-text-secondary)]">{ subtitle }</div>
</div>
<StatusBadge status={ status } isLoading={ isLoading }/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
{ stats.map((stat) => (
<StatCard key={ stat.label } label={ stat.label } value={ stat.value } isLoading={ isLoading }/>
)) }
</div>
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
{ children }
</div>
</section>
);
const SwapRow = ({ swap }: { readonly swap: AmmSwapView }) => (
<div className={ ROW_BASE }>
<div className="min-w-[240px] grow basis-full lg:basis-auto">
<span className="text-sm font-mono text-[var(--color-text-primary)]">
{ swap.soldAmount } <span className="font-semibold">{ swap.soldSymbol }</span>
<span className="text-[var(--color-text-secondary)]"> → </span>
{ swap.boughtAmount } <span className="font-semibold">{ swap.boughtSymbol }</span>
</span>
</div>
<div className="min-w-[130px] shrink-0">
<span className="text-sm text-[var(--color-text-secondary)]">{ swap.pair }</span>
<span className="text-xs text-[var(--color-text-secondary)] ml-1">{ formatFeeTier(swap.feeTier) }</span>
</div>
<div className="min-w-[120px] shrink-0">
<a href={ route({ pathname: '/address/[hash]', query: { hash: swap.sender } }) } className={ LINK_CLASS }>
{ shortenString(swap.sender, 10) }
</a>
</div>
<div className="min-w-[90px] shrink-0 text-right">
<a href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(swap.block) } }) } className={ LINK_CLASS }>
{ groupDigits(String(swap.block)) }
</a>
</div>
<div className="shrink-0 ml-0 lg:ml-auto">
<a href={ route({ pathname: '/tx/[hash]', query: { hash: swap.txHash } }) } className={ LINK_CLASS }>
{ shortenString(swap.txHash, 12) }
</a>
</div>
</div>
);
const MarketRow = ({ market }: { readonly market: DexMarketView }) => (
<div className={ ROW_BASE }>
<div className="min-w-[160px] shrink-0">
<span className="font-semibold text-sm text-[var(--color-text-primary)]">{ market.pair }</span>
</div>
<div className="min-w-[80px] shrink-0 text-right">
<span className="text-sm font-mono text-[var(--color-text-secondary)]">{ formatFeeTier(market.feeTier) }</span>
</div>
<div className="min-w-[100px] shrink-0 text-right">
<span className="text-sm font-mono text-[var(--color-text-primary)]">{ market.lastPrice }</span>
</div>
<div className="min-w-[140px] shrink-0 text-right">
<span className="text-sm font-mono text-[var(--color-text-secondary)]">{ groupDigits(market.volume24h) }</span>
</div>
<div className="shrink-0 text-right ml-0 lg:ml-auto">
<span className="text-sm text-[var(--color-text-secondary)]">{ market.tradeCount }</span>
</div>
</div>
);
const FillRow = ({ fill, pair }: { readonly fill: DexFill; readonly pair: string }) => (
<div className={ ROW_BASE }>
<div className="min-w-[160px] shrink-0">
<span className="font-medium text-sm text-[var(--color-text-primary)]">{ pair }</span>
</div>
<div className="min-w-[140px] shrink-0">
<a href={ route({ pathname: '/address/[hash]', query: { hash: fill.taker } }) } className={ LINK_CLASS }>
{ shortenString(fill.taker, 10) }
</a>
</div>
<div className="min-w-[140px] shrink-0 text-right">
<span className="text-sm font-mono text-[var(--color-text-primary)]">{ groupDigits(fill.amountOut) }</span>
</div>
<div className="min-w-[90px] shrink-0 text-right">
<a href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(fill.timestamp) } }) } className={ LINK_CLASS }>
{ groupDigits(String(fill.timestamp)) }
</a>
</div>
<div className="shrink-0 ml-0 lg:ml-auto">
<a href={ route({ pathname: '/tx/[hash]', query: { hash: fill.txHash } }) } className={ LINK_CLASS }>
{ shortenString(fill.txHash, 12) }
</a>
</div>
</div>
);
const LoadingSkeleton = () => (
<div className="px-4 py-6">
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px"/>
</div>
);
const EmptyState = ({ text }: { readonly text: string }) => (
<div className="px-4 py-10 text-center text-sm text-[var(--color-text-secondary)]">
{ text }
</div>
);
// ── Main component ──
const DexPage = () => {
const amm = useAmmData();
const clob = useDexData();
const pairByMarket = React.useMemo(
() => new Map(clob.markets.map((market) => [ market.id, market.pair ])),
[ clob.markets ],
);
const ammStats: ReadonlyArray<Stat> = [
{ label: 'Pools', value: String(amm.overview.poolCount) },
{ label: 'Value locked', value: formatUsd(amm.overview.tvlUsd) },
{ label: 'Volume', value: formatUsd(amm.overview.volumeUsd) },
{ label: 'Last swap', value: amm.overview.latestBlock === null ? '—' : `#${ groupDigits(String(amm.overview.latestBlock)) }` },
];
const clobStats: ReadonlyArray<Stat> = [
{ label: 'Markets', value: String(clob.overview.totalMarkets) },
{ label: '24h volume', value: groupDigits(clob.overview.volume24h) },
{ label: 'Trades', value: String(clob.overview.totalTrades) },
{ label: 'Fills shown', value: String(clob.fills.length) },
];
return (
<>
<PageTitle
title="DEX"
secondRow={ (
<div className="text-sm text-[var(--color-text-secondary)]">
Two independent venues trade on the C-Chain. Their figures are reported separately and never combined.
</div>
) }
/>
<VenueSection
title="AMM pools"
subtitle="Uniswap-V3-style concentrated liquidity"
status={ amm.status }
isLoading={ amm.isLoading }
stats={ ammStats }
>
<div className={ HEADER_BASE }>
<div className={ cn(COL_HEADER, 'min-w-[240px] grow') }>Swap</div>
<div className={ cn(COL_HEADER, 'min-w-[130px]') }>Pool</div>
<div className={ cn(COL_HEADER, 'min-w-[120px]') }>Sender</div>
<div className={ cn(COL_HEADER, 'min-w-[90px] text-right') }>Block</div>
<div className={ cn(COL_HEADER, 'ml-auto text-right') }>Txn</div>
</div>
{ amm.isLoading && <LoadingSkeleton/> }
{ !amm.isLoading && amm.swaps.length === 0 && (
<EmptyState text={ amm.status === 'unavailable' ? 'The AMM indexer did not answer' : 'No swaps indexed yet' }/>
) }
{ !amm.isLoading && amm.swaps.map((swap) => <SwapRow key={ swap.id } swap={ swap }/>) }
</VenueSection>
<VenueSection
title="Native order book"
subtitle="The 0x9999 PoolManager CLOB, built into the C-Chain"
status={ clob.status }
isLoading={ clob.isLoading }
stats={ clobStats }
>
<div className={ HEADER_BASE }>
<div className={ cn(COL_HEADER, 'min-w-[160px]') }>Pair</div>
<div className={ cn(COL_HEADER, 'min-w-[80px] text-right') }>Fee</div>
<div className={ cn(COL_HEADER, 'min-w-[100px] text-right') }>Last price</div>
<div className={ cn(COL_HEADER, 'min-w-[140px] text-right') }>24h volume</div>
<div className={ cn(COL_HEADER, 'ml-auto text-right') }>Trades</div>
</div>
{ clob.isLoading && <LoadingSkeleton/> }
{ !clob.isLoading && clob.markets.length === 0 && (
<EmptyState text={
clob.status === 'unavailable' ?
'The order-book indexer did not answer' :
'No market has ever been opened on the native order book'
}/>
) }
{ !clob.isLoading && clob.markets.map((market) => <MarketRow key={ market.id } market={ market }/>) }
{ !clob.isLoading && clob.fills.map((fill) => (
<FillRow key={ fill.id } fill={ fill } pair={ pairByMarket.get(fill.market) ?? shortenString(fill.market, 10) }/>
)) }
</VenueSection>
</>
);
};
export default React.memo(DexPage);