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]>
This commit is contained in:
2026-08-07 08:03:30 -07:00
co-authored by Hanzo Dev
parent 63530dd9d7
commit f434a701dc
11 changed files with 614 additions and 132 deletions
+3
View File
@@ -44,6 +44,9 @@
"platformvm",
"aatx",
"CLOB",
// Mainnet 96369 token symbols
"WLUX",
"LZOO",
"hanzoai",
"zooai",
"parsdao",
+3
View File
@@ -0,0 +1,3 @@
export type { AmmFactory, AmmOverview, AmmPool, AmmSwap, AmmSwapView, AmmToken } from './types';
export type { UseAmmDataResult } from './useAmmData';
export { useAmmData } from './useAmmData';
+64
View File
@@ -0,0 +1,64 @@
// C-Chain AMM (Uniswap-V3-style concentrated-liquidity pools) subgraph types.
// Source: the explorer's embedded luxfi/graph "amm" subgraph, served at
// `${config.apis.general.endpoint}/v1/graph/cchain/amm/graphql`. Pools come
// from the factory's PoolCreated event and swaps from each pool's Swap event.
//
// That engine answers a fixed shape per entity and ignores the selection set,
// so these types describe what it sends rather than what we ask for.
export interface AmmToken {
readonly id: string;
readonly symbol: string;
readonly decimals: number;
}
export interface AmmPool {
readonly id: string;
readonly feeTier: number;
readonly token0: AmmToken;
readonly token1: AmmToken;
}
export interface AmmSwap {
readonly id: string;
// A BLOCK NUMBER, not a unix time — the indexer writes the block height into
// this field. Rendering it as a date would be a lie.
readonly timestamp: number;
// Signed, in the token's own base units, from the POOL's point of view:
// positive is what the pool received, negative is what it paid out.
readonly amount0: string;
readonly amount1: string;
readonly sender: string;
readonly pool: AmmPool;
}
export interface AmmFactory {
readonly poolCount: number;
readonly totalValueLockedUSD: string;
readonly totalVolumeUSD: string;
}
// A swap resolved into the direction a human reads it: what was sold, what was
// bought, both scaled by their own token's decimals.
export interface AmmSwapView {
readonly id: string;
readonly txHash: string;
readonly block: number;
readonly sender: string;
readonly pair: string;
readonly feeTier: number;
readonly soldSymbol: string;
readonly soldAmount: string;
readonly boughtSymbol: string;
readonly boughtAmount: string;
}
// Headline aggregates. Every field is read straight off the indexer — none is
// inferred, and the swap total is deliberately absent because the engine caps
// a result set and ignores `skip`, so we cannot count what we cannot page.
export interface AmmOverview {
readonly poolCount: number;
readonly tvlUsd: string;
readonly volumeUsd: string;
readonly latestBlock: number | null;
}
+120
View File
@@ -0,0 +1,120 @@
import type { AmmFactory, AmmSwap } from './types';
import { deriveVenueStatus } from 'lib/api/venueStatus';
import { expect, test, describe } from 'vitest';
import { buildSwapViews, computeAmmOverview } from './useAmmData';
// Real shapes returned by the mainnet cchain/amm subgraph — the WLUX/LZOO
// 0.30% pool at block 1,098,074. `timestamp` really is the block number.
const POOL = {
id: '0x1c000d5dbe1246fb84ad431e933e5563f212a62b',
feeTier: 3000,
token0: { id: '0x4888e4a2ee0f03051c72d2bd3acf755ed3498b3e', symbol: 'WLUX', decimals: 18 },
token1: { id: '0x5e5290f350352768bd2bfc59c2da15dd04a7cb88', symbol: 'LZOO', decimals: 18 },
} as const;
// Trader sold WLUX: the pool received amount0 and paid out amount1.
const SELL_TOKEN0: AmmSwap = {
id: '0xf1c526609ce9aaadc70ba77e4f27db9ea79a64ac35edbc96bf719a2956e7abfc#0x2',
timestamp: 1098074,
amount0: '100000000000000000000',
amount1: '-2168447796713672942095',
sender: '0x939bc0bca6f9b9c52e6e3ad8a3c590b5d9b9d10e',
pool: POOL,
};
// The mirror trade: the pool received LZOO and paid out WLUX.
const SELL_TOKEN1: AmmSwap = {
id: '0x7dece36d553a609f37a209d65eeb851513539de111ac935840149f4b577200fb#0x2',
timestamp: 1098073,
amount0: '-99400900225431259546',
amount1: '2168447806549312938443',
sender: '0x939bc0bca6f9b9c52e6e3ad8a3c590b5d9b9d10e',
pool: POOL,
};
const FACTORY: AmmFactory = {
poolCount: 32,
totalValueLockedUSD: '125992.93',
totalVolumeUSD: '2572.68',
};
describe('buildSwapViews', () => {
test('reads a positive amount0 as the trader selling token0', () => {
const [ view ] = buildSwapViews([ SELL_TOKEN0 ]);
expect(view.soldSymbol).toBe('WLUX');
expect(view.boughtSymbol).toBe('LZOO');
});
test('reads a negative amount0 as the trader buying token0', () => {
const [ view ] = buildSwapViews([ SELL_TOKEN1 ]);
expect(view.soldSymbol).toBe('LZOO');
expect(view.boughtSymbol).toBe('WLUX');
});
test('scales each amount by its own token decimals and drops the sign', () => {
const [ view ] = buildSwapViews([ SELL_TOKEN0 ]);
expect(view.soldAmount).toBe('100');
expect(view.boughtAmount).toBe('2,168.4478');
});
test('splits the subgraph key into a linkable tx hash', () => {
const [ view ] = buildSwapViews([ SELL_TOKEN0 ]);
expect(view.txHash).toBe('0xf1c526609ce9aaadc70ba77e4f27db9ea79a64ac35edbc96bf719a2956e7abfc');
expect(view.block).toBe(1098074);
});
test('labels the pair and keeps the fee tier', () => {
const [ view ] = buildSwapViews([ SELL_TOKEN0 ]);
expect(view.pair).toBe('WLUX/LZOO');
expect(view.feeTier).toBe(3000);
});
test('returns an empty list for no swaps (empty state)', () => {
expect(buildSwapViews([])).toHaveLength(0);
});
});
describe('computeAmmOverview', () => {
test('passes factory totals through untouched and takes the newest block', () => {
expect(computeAmmOverview(FACTORY, [ SELL_TOKEN0, SELL_TOKEN1 ])).toEqual({
poolCount: 32,
tvlUsd: '125992.93',
volumeUsd: '2572.68',
latestBlock: 1098074,
});
});
test('reports no latest block when nothing has traded', () => {
expect(computeAmmOverview(FACTORY, []).latestBlock).toBeNull();
});
test('is zeroed when the factory is unreadable', () => {
expect(computeAmmOverview(null, [])).toEqual({
poolCount: 0, tvlUsd: '0', volumeUsd: '0', latestBlock: null,
});
});
});
describe('deriveVenueStatus', () => {
test('an unreachable indexer is unavailable, never empty', () => {
expect(deriveVenueStatus(null, 1098100, false)).toBe('unavailable');
});
test('a reachable venue with no trades at all is empty', () => {
expect(deriveVenueStatus(null, 1098100, true)).toBe('empty');
});
test('a trade inside the recent window is live', () => {
expect(deriveVenueStatus(1098074, 1098100, true)).toBe('live');
});
test('a trade far behind the head is idle', () => {
expect(deriveVenueStatus(1000000, 1098100, true)).toBe('idle');
});
test('without a chain head, recency is unknown rather than guessed', () => {
expect(deriveVenueStatus(1098074, null, true)).toBe('unknown');
});
});
+112
View File
@@ -0,0 +1,112 @@
import { useQuery } from '@tanstack/react-query';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AmmFactory, AmmOverview, AmmSwap, AmmSwapView } from './types';
import { getApiBase, fetchSubgraph } from 'lib/api/subgraph';
import { useHeadBlock } from 'lib/api/useHeadBlock';
import type { VenueStatus } from 'lib/api/venueStatus';
import { deriveVenueStatus } from 'lib/api/venueStatus';
// One root field per request — the subgraph engine processes a single root
// field per query, so swaps and factory totals are fetched separately.
const SWAPS_QUERY = '{ swaps(first:50, orderBy: timestamp, orderDirection: desc) ' +
'{ id timestamp amount0 amount1 sender pool { id feeTier token0 { id symbol decimals } token1 { id symbol decimals } } } }';
const FACTORY_QUERY = '{ factories { id poolCount totalValueLockedUSD totalVolumeUSD } }';
const AMM_GRAPHQL_PATH = '/v1/graph/cchain/amm/graphql';
const POLL_INTERVAL_MS = 30_000;
const AMOUNT_ACCURACY = 4;
// Absolute value of a signed subgraph amount, scaled by its own token's
// decimals. The sign only encodes direction, which the caller has already read.
function formatAmount(raw: string, decimals: number): string {
const value = BigNumber(raw).abs().div(BigNumber(10).pow(decimals));
return value.isNaN() ? '0' : value.dp(AMOUNT_ACCURACY).toFormat();
}
// Pure: turn pool-signed amounts into the trade a person made.
export function buildSwapViews(swaps: ReadonlyArray<AmmSwap>): ReadonlyArray<AmmSwapView> {
return swaps.map((swap) => {
const { token0, token1, feeTier } = swap.pool;
// Positive amount0 means the pool received token0, so the trader sold it.
const soldIsToken0 = !swap.amount0.startsWith('-');
const sold = soldIsToken0 ? token0 : token1;
const bought = soldIsToken0 ? token1 : token0;
return {
id: swap.id,
// The subgraph keys a swap as `<txHash>#<logIndex>`.
txHash: swap.id.split('#')[0],
block: swap.timestamp,
sender: swap.sender,
pair: `${ token0.symbol }/${ token1.symbol }`,
feeTier,
soldSymbol: sold.symbol,
soldAmount: formatAmount(soldIsToken0 ? swap.amount0 : swap.amount1, sold.decimals),
boughtSymbol: bought.symbol,
boughtAmount: formatAmount(soldIsToken0 ? swap.amount1 : swap.amount0, bought.decimals),
};
});
}
// Pure: headline figures, all read off the indexer rather than inferred.
export function computeAmmOverview(
factory: AmmFactory | null,
swaps: ReadonlyArray<AmmSwap>,
): AmmOverview {
return {
poolCount: factory?.poolCount ?? 0,
tvlUsd: factory?.totalValueLockedUSD ?? '0',
volumeUsd: factory?.totalVolumeUSD ?? '0',
latestBlock: swaps.length > 0 ? Math.max(...swaps.map((swap) => swap.timestamp)) : null,
};
}
async function fetchSwaps(): Promise<ReadonlyArray<AmmSwap> | null> {
const data = await fetchSubgraph<{ swaps: ReadonlyArray<AmmSwap> | null }>(AMM_GRAPHQL_PATH, SWAPS_QUERY);
return data ? data.swaps ?? [] : null;
}
async function fetchFactory(): Promise<AmmFactory | null> {
const data = await fetchSubgraph<{ factories: ReadonlyArray<AmmFactory> | null }>(AMM_GRAPHQL_PATH, FACTORY_QUERY);
return data?.factories?.[0] ?? null;
}
export interface UseAmmDataResult {
readonly swaps: ReadonlyArray<AmmSwapView>;
readonly overview: AmmOverview;
readonly status: VenueStatus;
readonly isLoading: boolean;
}
export function useAmmData(): UseAmmDataResult {
const swapsQuery = useQuery({
queryKey: [ 'amm:swaps', getApiBase() ],
queryFn: fetchSwaps,
refetchInterval: POLL_INTERVAL_MS,
});
const factoryQuery = useQuery({
queryKey: [ 'amm:factory', getApiBase() ],
queryFn: fetchFactory,
refetchInterval: POLL_INTERVAL_MS,
});
const headBlock = useHeadBlock();
const rawSwaps = swapsQuery.data ?? null;
const overview = React.useMemo(
() => computeAmmOverview(factoryQuery.data ?? null, rawSwaps ?? []),
[ factoryQuery.data, rawSwaps ],
);
const swaps = React.useMemo(() => buildSwapViews(rawSwaps ?? []), [ rawSwaps ]);
return {
swaps,
overview,
status: deriveVenueStatus(overview.latestBlock, headBlock, rawSwaps !== null),
isLoading: swapsQuery.isLoading || factoryQuery.isLoading,
};
}
+21 -41
View File
@@ -3,7 +3,10 @@ import React from 'react';
import type { DexMarket, DexFill, DexMarketView, DexOverview } from './types';
import config from 'configs/app';
import { getApiBase, fetchSubgraph } from 'lib/api/subgraph';
import { useHeadBlock } from 'lib/api/useHeadBlock';
import type { VenueStatus } from 'lib/api/venueStatus';
import { deriveVenueStatus } from 'lib/api/venueStatus';
import shortenString from 'lib/shortenString';
// One root field per request — the subgraph engine processes a single root
@@ -17,40 +20,6 @@ const DEX_STALE_TIME_MS = 30_000;
const EMPTY_MARKETS: ReadonlyArray<DexMarketView> = [];
const EMPTY_FILLS: ReadonlyArray<DexFill> = [];
// The DEX subgraph lives on the same API host the rest of the SPA already
// talks to (NEXT_PUBLIC_API_HOST → config.apis.general.endpoint), so the
// network is selected exactly like every other resource — by deployment.
function getApiBase(): string | undefined {
return config.apis.general?.endpoint;
}
async function fetchGraphql<T>(query: string): Promise<T | null> {
const base = getApiBase();
if (!base) {
return null;
}
let res: Response;
try {
res = await fetch(base + DEX_GRAPHQL_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
} catch {
return null;
}
// A net without the subgraph mounted answers 404 — that is an empty DEX,
// not an error, so we surface it as "no data" and render the empty state.
if (!res.ok) {
return null;
}
const json = await res.json().catch(() => null) as { data?: T } | null;
return json?.data ?? null;
}
async function fetchTokenSymbol(address: string): Promise<string | null> {
const base = getApiBase();
if (!base) {
@@ -98,9 +67,13 @@ export function computeOverview(markets: ReadonlyArray<DexMarketView>): DexOverv
};
}
async function fetchMarkets(): Promise<ReadonlyArray<DexMarketView>> {
const data = await fetchGraphql<{ markets: ReadonlyArray<DexMarket> | null }>(MARKETS_QUERY);
const markets = data?.markets ?? [];
// `null` means the subgraph never answered — an outage, not an empty venue.
async function fetchMarkets(): Promise<ReadonlyArray<DexMarketView> | null> {
const data = await fetchSubgraph<{ markets: ReadonlyArray<DexMarket> | null }>(DEX_GRAPHQL_PATH, MARKETS_QUERY);
if (!data) {
return null;
}
const markets = data.markets ?? [];
// Resolve base/quote symbols via the existing token metadata endpoint,
// falling back to a shortened address when a token isn't indexed.
@@ -111,15 +84,16 @@ async function fetchMarkets(): Promise<ReadonlyArray<DexMarketView>> {
return buildMarketViews(markets, symbols);
}
async function fetchFills(): Promise<ReadonlyArray<DexFill>> {
const data = await fetchGraphql<{ fills: ReadonlyArray<DexFill> | null }>(FILLS_QUERY);
return data?.fills ?? [];
async function fetchFills(): Promise<ReadonlyArray<DexFill> | null> {
const data = await fetchSubgraph<{ fills: ReadonlyArray<DexFill> | null }>(DEX_GRAPHQL_PATH, FILLS_QUERY);
return data ? data.fills ?? [] : null;
}
export interface UseDexDataResult {
readonly markets: ReadonlyArray<DexMarketView>;
readonly fills: ReadonlyArray<DexFill>;
readonly overview: DexOverview;
readonly status: VenueStatus;
readonly isLoading: boolean;
readonly isError: boolean;
}
@@ -137,15 +111,21 @@ export function useDexData(): UseDexDataResult {
staleTime: DEX_STALE_TIME_MS,
});
const headBlock = useHeadBlock();
const markets = marketsQuery.data ?? EMPTY_MARKETS;
const fills = fillsQuery.data ?? EMPTY_FILLS;
const overview = React.useMemo<DexOverview>(() => computeOverview(markets), [ markets ]);
// The CLOB stamps a fill with the block it landed in, same as the AMM.
const latestBlock = fills.length > 0 ? Math.max(...fills.map((fill) => fill.timestamp)) : null;
return {
markets,
fills,
overview,
status: deriveVenueStatus(latestBlock, headBlock, marketsQuery.data !== null),
isLoading: marketsQuery.isLoading || fillsQuery.isLoading,
isError: marketsQuery.isError || fillsQuery.isError,
};
+39
View File
@@ -0,0 +1,39 @@
// One way to reach the chain subgraphs the explorer serves under
// `${config.apis.general.endpoint}/v1/graph/cchain/<name>/graphql`. The network
// is selected exactly like every other resource — by deployment, via
// NEXT_PUBLIC_API_HOST.
//
// `null` means the endpoint did not answer. That is NOT the same as an answer
// carrying no rows, and callers must keep the two apart: one is an outage, the
// other is a fact about the venue.
import config from 'configs/app';
export function getApiBase(): string | undefined {
return config.apis.general?.endpoint;
}
export async function fetchSubgraph<T>(path: string, query: string): Promise<T | null> {
const base = getApiBase();
if (!base) {
return null;
}
try {
const res = await fetch(base + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
if (!res.ok) {
return null;
}
const json = await res.json() as { data?: T } | null;
return json?.data ?? null;
} catch {
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { useQuery } from '@tanstack/react-query';
import { getApiBase } from 'lib/api/subgraph';
const STATS_PATH = '/v1/stats';
const POLL_INTERVAL_MS = 30_000;
// The chain head, read straight off the API host the same way the subgraphs
// are, so it resolves identically in dev and in production. `null` means we
// could not read it, and callers must report that rather than guess a height.
async function fetchHeadBlock(): Promise<number | null> {
const base = getApiBase();
if (!base) {
return null;
}
try {
const res = await fetch(base + STATS_PATH);
if (!res.ok) {
return null;
}
const json = await res.json() as { head_block?: number } | null;
return typeof json?.head_block === 'number' ? json.head_block : null;
} catch {
return null;
}
}
export function useHeadBlock(): number | null {
const { data } = useQuery({
queryKey: [ 'headBlock', getApiBase() ],
queryFn: fetchHeadBlock,
refetchInterval: POLL_INTERVAL_MS,
});
return data ?? null;
}
+38
View File
@@ -0,0 +1,38 @@
// How much we actually know about a trading venue. Every word already carries
// this meaning elsewhere in the codebase (lib/api/pchain/useNetworkValidators,
// lib/api/cchain/useFeeSplit) and keeps it here:
//
// 'unavailable' — the indexer never answered, so we learned nothing at all.
// 'unknown' — it answered, but the chain head is unreadable, so we cannot
// judge how recent its newest trade is.
// 'empty' — it answered and has never recorded a single trade.
// 'idle' — it has history, but nothing inside the recent window.
// 'live' — its newest trade is inside the recent window.
//
// There is deliberately no 'stale'. Telling a stalled indexer apart from a
// quiet venue needs the indexer's own head block, which this API does not
// serve, so claiming that distinction would be a guess.
export type VenueStatus = 'live' | 'idle' | 'empty' | 'unavailable' | 'unknown';
// ~30 minutes at the ~9s cadence C-Chain actually runs.
export const RECENT_WINDOW_BLOCKS = 200;
export function deriveVenueStatus(
latestBlock: number | null,
headBlock: number | null,
reachable: boolean,
): VenueStatus {
if (!reachable) {
return 'unavailable';
}
if (latestBlock === null) {
return 'empty';
}
if (headBlock === null) {
return 'unknown';
}
return headBlock - latestBlock <= RECENT_WINDOW_BLOCKS ? 'live' : 'idle';
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@luxfi/explore",
"version": "1.1.24",
"version": "1.1.25",
"private": false,
"homepage": "https://github.com/luxfi/explore#readme",
"engines": {
+173 -90
View File
@@ -1,33 +1,52 @@
// C-Chain native DEX (0x9999 V4 PoolManager) page for the Lux explorer.
// Renders live markets and recent fills from the embedded dex (CLOB) subgraph.
// All data is real and network-aware — there is no mock/demo path.
// 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 TAB_IDS = {
markets: 'markets',
trades: 'trades',
} as const;
type TabId = typeof TAB_IDS[keyof typeof TAB_IDS];
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-4 flex-wrap lg:flex-nowrap';
'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 {
@@ -38,45 +57,98 @@ function formatFeeTier(feeTier: number): string {
return `${ (feeTier / 10_000).toFixed(2) }%`;
}
// ── Sub-components ──
interface StatCardProps {
readonly label: string;
readonly value: string;
readonly isLoading: boolean;
function formatUsd(value: string): string {
const amount = BigNumber(value);
return amount.isNaN() ? '—' : `$${ amount.dp(2).toFormat() }`;
}
const StatCard = ({ label, value, isLoading }: StatCardProps) => (
<div className="border border-[var(--color-border-divider)] rounded-lg p-5 bg-gray-50 dark:bg-white/5">
<div className="text-xs text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider mb-1">
// ── 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-2xl font-bold text-[var(--color-text-primary)]">
<div className="text-xl font-bold text-[var(--color-text-primary)] truncate">
{ value }
</div>
</Skeleton>
</div>
);
interface TabButtonProps {
readonly label: string;
readonly isActive: boolean;
readonly onClick: () => void;
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 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',
) }
onClick={ onClick }
>
{ label }
</button>
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 }) => (
@@ -114,7 +186,7 @@ const FillRow = ({ fill, pair }: { readonly fill: DexFill; readonly pair: string
</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 }>
{ fill.timestamp }
{ groupDigits(String(fill.timestamp)) }
</a>
</div>
<div className="shrink-0 ml-0 lg:ml-auto">
@@ -143,16 +215,27 @@ const EmptyState = ({ text }: { readonly text: string }) => (
// ── Main component ──
const DexPage = () => {
const [ activeTab, setActiveTab ] = React.useState<TabId>(TAB_IDS.markets);
const { markets, fills, overview, isLoading } = useDexData();
const amm = useAmmData();
const clob = useDexData();
const pairByMarket = React.useMemo(
() => new Map(markets.map((m) => [ m.id, m.pair ])),
[ markets ],
() => new Map(clob.markets.map((market) => [ market.id, market.pair ])),
[ clob.markets ],
);
const handleMarketsClick = React.useCallback(() => setActiveTab(TAB_IDS.markets), []);
const handleTradesClick = React.useCallback(() => setActiveTab(TAB_IDS.trades), []);
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 (
<>
@@ -160,59 +243,59 @@ const DexPage = () => {
title="DEX"
secondRow={ (
<div className="text-sm text-[var(--color-text-secondary)]">
C-Chain native exchange — live markets and fills from the 0x9999 PoolManager
Two independent venues trade on the C-Chain. Their figures are reported separately and never combined.
</div>
) }
/>
{ /* Stats cards */ }
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">
<StatCard label="Markets" value={ String(overview.totalMarkets) } isLoading={ isLoading }/>
<StatCard label="24h Volume" value={ groupDigits(overview.volume24h) } isLoading={ isLoading }/>
<StatCard label="Total Trades" value={ String(overview.totalTrades) } isLoading={ isLoading }/>
</div>
{ /* Tabs */ }
<div className="flex border-b border-[var(--color-border-divider)] mb-4">
<TabButton label="Markets" isActive={ activeTab === TAB_IDS.markets } onClick={ handleMarketsClick }/>
<TabButton label="Trades" isActive={ activeTab === TAB_IDS.trades } onClick={ handleTradesClick }/>
</div>
{ /* Markets tab */ }
{ activeTab === TAB_IDS.markets && (
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
<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>
{ isLoading && <LoadingSkeleton/> }
{ !isLoading && markets.length === 0 && <EmptyState text="No active markets"/> }
{ !isLoading && markets.map((market) => (
<MarketRow key={ market.id } market={ market }/>
)) }
<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>
{ /* Trades tab */ }
{ activeTab === TAB_IDS.trades && (
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
<div className={ HEADER_BASE }>
<div className={ cn(COL_HEADER, 'min-w-[160px]') }>Pair</div>
<div className={ cn(COL_HEADER, 'min-w-[140px]') }>Taker</div>
<div className={ cn(COL_HEADER, 'min-w-[140px] text-right') }>Amount Out</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>
{ isLoading && <LoadingSkeleton/> }
{ !isLoading && fills.length === 0 && <EmptyState text="No recent trades"/> }
{ !isLoading && fills.map((fill) => (
<FillRow key={ fill.id } fill={ fill } pair={ pairByMarket.get(fill.market) ?? shortenString(fill.market, 10) }/>
)) }
<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>
</>
);
};