mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
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:
@@ -44,6 +44,9 @@
|
||||
"platformvm",
|
||||
"aatx",
|
||||
"CLOB",
|
||||
// Mainnet 96369 token symbols
|
||||
"WLUX",
|
||||
"LZOO",
|
||||
"hanzoai",
|
||||
"zooai",
|
||||
"parsdao",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { AmmFactory, AmmOverview, AmmPool, AmmSwap, AmmSwapView, AmmToken } from './types';
|
||||
export type { UseAmmDataResult } from './useAmmData';
|
||||
export { useAmmData } from './useAmmData';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user