From 7b5f99debb9269a72a0c8ae651291f0b0f3a9b52 Mon Sep 17 00:00:00 2001 From: zeekay Date: Sun, 28 Jun 2026 22:23:58 -0700 Subject: [PATCH 1/3] fix(dex): wire /dex to live cchain dex subgraph; fix block txs crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded demo DEX dataset (incl. the placeholder pi-million 24h volume) with live markets + fills from the C-Chain 0x9999 subgraph at the per-network /v1/graph/cchain/dex/graphql endpoint, selected via config.apis.general like every other resource. Token pairs resolve through the existing token metadata endpoint, falling back to a short address. Empty/unavailable nets render 'No active markets' — never mock. Guard tx.transaction_types in TxsListItem so coinbase/precompile txs (absent transaction_types in the API payload) no longer crash the block Transactions tab; drop an unused destructured prop. Allow CLOB in cspell. --- cspell.jsonc | 1 + lib/api/dchain/index.ts | 11 +- lib/api/dchain/types.ts | 69 +++---- lib/api/dchain/useDexData.spec.ts | 68 +++++++ lib/api/dchain/useDexData.ts | 282 ++++++++++++-------------- ui/dex/DexPage.tsx | 326 +++++++++--------------------- ui/txs/TxsListItem.tsx | 7 +- 7 files changed, 322 insertions(+), 442 deletions(-) create mode 100644 lib/api/dchain/useDexData.spec.ts diff --git a/cspell.jsonc b/cspell.jsonc index e8ebd14ba9..fffba0af15 100644 --- a/cspell.jsonc +++ b/cspell.jsonc @@ -34,6 +34,7 @@ // words - list of words to be always considered correct "words": [ "aatx", + "CLOB", "hanzoai", "zooai", "parsdao", diff --git a/lib/api/dchain/index.ts b/lib/api/dchain/index.ts index c82a4a36a4..464a2aac14 100644 --- a/lib/api/dchain/index.ts +++ b/lib/api/dchain/index.ts @@ -1,13 +1,12 @@ -// D-Chain (DEX) API hooks and types. +// C-Chain native DEX (0x9999) subgraph hooks and types. export { useDexData } from './useDexData'; export type { UseDexDataResult } from './useDexData'; export type { - DexOrder, - DexTrade, - DexPool, - DexSymbolStats, - DexOverviewStats, + DexMarket, + DexFill, + DexMarketView, + DexOverview, } from './types'; diff --git a/lib/api/dchain/types.ts b/lib/api/dchain/types.ts index af794c63d6..4e9bce7817 100644 --- a/lib/api/dchain/types.ts +++ b/lib/api/dchain/types.ts @@ -1,55 +1,36 @@ -// D-Chain (DEX Chain) API response types for the Lux DexVM indexer. +// C-Chain native DEX (0x9999 V4 PoolManager) subgraph types. +// Source: the explorer's embedded luxfi/graph "dex" (CLOB) subgraph, served at +// `${config.apis.general.endpoint}/v1/graph/cchain/dex/graphql`. Markets are +// emitted by the on-chain Initialize event and fills by the DEXFill event. -export interface DexOrder { +export interface DexMarket { readonly id: string; - readonly symbol: string; - readonly side: 'buy' | 'sell'; - readonly price: string; - readonly quantity: string; - readonly filled: string; - readonly status: 'open' | 'filled' | 'cancelled' | 'partial'; - readonly maker: string; - readonly timestamp: string; + readonly baseToken: string; + readonly quoteToken: string; + readonly feeTier: number; + readonly volume24h: string; + readonly tradeCount: number; + readonly lastPrice: string; + readonly lastUpdate: number; } -export interface DexTrade { +export interface DexFill { readonly id: string; - readonly symbol: string; - readonly price: string; - readonly quantity: string; - readonly buyer: string; - readonly seller: string; - readonly fee: string; - readonly timestamp: string; - readonly blockHeight: number; + readonly market: string; + readonly taker: string; + readonly amountOut: string; + readonly timestamp: number; + readonly txHash: string; } -export interface DexPool { - readonly id: string; - readonly tokenA: string; - readonly tokenB: string; - readonly reserveA: string; - readonly reserveB: string; - readonly tvl: string; - readonly volume24h: string; - readonly fee: string; +// A market with its base/quote token symbols resolved to a human pair label. +export interface DexMarketView extends DexMarket { + readonly pair: string; } -export interface DexSymbolStats { - readonly symbol: string; - readonly lastPrice: string; - readonly change24h: number; - readonly volume24h: string; - readonly high24h: string; - readonly low24h: string; - readonly trades24h: number; -} - -// Aggregated DEX statistics - -export interface DexOverviewStats { - readonly totalPairs: number; +// Aggregated headline stats derived from the live market set. +export interface DexOverview { + readonly totalMarkets: number; readonly volume24h: string; - readonly activeOrders: number; - readonly tradesToday: number; + readonly totalTrades: number; } diff --git a/lib/api/dchain/useDexData.spec.ts b/lib/api/dchain/useDexData.spec.ts new file mode 100644 index 0000000000..939ac1a636 --- /dev/null +++ b/lib/api/dchain/useDexData.spec.ts @@ -0,0 +1,68 @@ +import type { DexMarket } from './types'; + +import { expect, test, describe } from 'vitest'; + +import { buildMarketViews, computeOverview } from './useDexData'; + +// Real shapes returned by the testnet cchain/dex subgraph. +const MARKET_A: DexMarket = { + id: '0x70ed78d5fb8fd83110ffb5b6726edb3d76e0968d5dba042e59e0c97af1a1f2cd', + baseToken: '0xaa1c9a2527f70aee8c163fc053ccbba0e0df7a37', + quoteToken: '0xf90434869b41d554c6446aacd73c06406b6ce9c1', + feeTier: 3000, + volume24h: '10', + tradeCount: 1, + lastPrice: '0', + lastUpdate: 11, +}; + +const MARKET_B: DexMarket = { + id: '0x2b0a2ff82be3beb2d2b58d0766b1c6c0b3f9bb44689e469bb49934e4e32802f1', + baseToken: '0xa811aac7a2e132c167650f93f3e14305e6205f95', + quoteToken: '0xccba5e1036dcab7ff4885975d339cafab2a4c0c4', + feeTier: 3000, + volume24h: '1258432996', + tradeCount: 21, + lastPrice: '0', + lastUpdate: 325, +}; + +describe('buildMarketViews', () => { + test('falls back to a shortened address when a token has no metadata', () => { + const [ view ] = buildMarketViews([ MARKET_A ], new Map()); + expect(view.pair).toBe('0xaa1c...7a37/0xf904...e9c1'); + }); + + test('resolves symbols from the metadata map (case-insensitive)', () => { + const symbols = new Map([ + [ MARKET_A.baseToken.toLowerCase(), 'LUX' ], + [ MARKET_A.quoteToken.toLowerCase(), 'USDC' ], + ]); + const [ view ] = buildMarketViews([ MARKET_A ], symbols); + expect(view.pair).toBe('LUX/USDC'); + }); + + test('preserves the source market fields', () => { + const [ view ] = buildMarketViews([ MARKET_A ], new Map()); + expect(view).toMatchObject({ id: MARKET_A.id, feeTier: 3000, tradeCount: 1, volume24h: '10' }); + }); + + test('returns an empty list for no markets (empty state)', () => { + expect(buildMarketViews([], new Map())).toHaveLength(0); + }); +}); + +describe('computeOverview', () => { + test('aggregates market count, big-int volume, and trade count', () => { + const views = buildMarketViews([ MARKET_A, MARKET_B ], new Map()); + expect(computeOverview(views)).toEqual({ + totalMarkets: 2, + volume24h: '1258433006', + totalTrades: 22, + }); + }); + + test('is zeroed for an empty market set', () => { + expect(computeOverview([])).toEqual({ totalMarkets: 0, volume24h: '0', totalTrades: 0 }); + }); +}); diff --git a/lib/api/dchain/useDexData.ts b/lib/api/dchain/useDexData.ts index ae54515911..50a681ba11 100644 --- a/lib/api/dchain/useDexData.ts +++ b/lib/api/dchain/useDexData.ts @@ -1,185 +1,155 @@ import { useQuery } from '@tanstack/react-query'; import React from 'react'; -import type { - DexOrder, - DexTrade, - DexPool, - DexSymbolStats, - DexOverviewStats, -} from './types'; +import type { DexMarket, DexFill, DexMarketView, DexOverview } from './types'; -import { getDChain } from 'lib/api/luxnet/chains'; +import config from 'configs/app'; +import shortenString from 'lib/shortenString'; +// One root field per request — the subgraph engine processes a single root +// field per query, so markets and fills are fetched separately. +const MARKETS_QUERY = '{ markets { id baseToken quoteToken feeTier volume24h tradeCount lastPrice lastUpdate } }'; +const FILLS_QUERY = '{ fills(first:25) { id market taker amountOut timestamp txHash } }'; + +const DEX_GRAPHQL_PATH = '/v1/graph/cchain/dex/graphql'; const DEX_STALE_TIME_MS = 30_000; -const DEX_QUERY_KEY = 'dchain:dexData' as const; - -/* eslint-disable max-len */ -const DEMO_SYMBOLS: ReadonlyArray = [ - { symbol: 'LUX/USDT', lastPrice: '24.85', change24h: 3.42, volume24h: '1,245,890', high24h: '25.10', low24h: '23.90', trades24h: 482 }, - { symbol: 'LUX/USDC', lastPrice: '24.83', change24h: 3.18, volume24h: '892,340', high24h: '25.05', low24h: '23.88', trades24h: 356 }, - { symbol: 'ZOO/LUX', lastPrice: '0.0842', change24h: -1.25, volume24h: '456,120', high24h: '0.0870', low24h: '0.0830', trades24h: 198 }, - { symbol: 'HNZ/LUX', lastPrice: '0.152', change24h: 7.04, volume24h: '334,560', high24h: '0.158', low24h: '0.140', trades24h: 145 }, - { symbol: 'SPC/LUX', lastPrice: '0.0215', change24h: -0.47, volume24h: '123,450', high24h: '0.0220', low24h: '0.0210', trades24h: 87 }, - { symbol: 'PARS/LUX', lastPrice: '0.0034', change24h: 12.50, volume24h: '89,230', high24h: '0.0036', low24h: '0.0030', trades24h: 64 }, -]; - -const DEMO_ORDERS: ReadonlyArray = [ - { id: 'ord-001', symbol: 'LUX/USDT', side: 'buy', price: '24.50', quantity: '100.00', filled: '0.00', status: 'open', maker: '0x1a2b...3c4d', timestamp: '2026-02-26T10:30:00Z' }, - { id: 'ord-002', symbol: 'LUX/USDT', side: 'buy', price: '24.45', quantity: '250.00', filled: '50.00', status: 'partial', maker: '0x5e6f...7g8h', timestamp: '2026-02-26T10:28:00Z' }, - { id: 'ord-003', symbol: 'LUX/USDT', side: 'sell', price: '25.10', quantity: '75.00', filled: '0.00', status: 'open', maker: '0x9i0j...1k2l', timestamp: '2026-02-26T10:25:00Z' }, - { id: 'ord-004', symbol: 'LUX/USDC', side: 'sell', price: '25.05', quantity: '150.00', filled: '150.00', status: 'filled', maker: '0x3m4n...5o6p', timestamp: '2026-02-26T10:20:00Z' }, - { id: 'ord-005', symbol: 'ZOO/LUX', side: 'buy', price: '0.0840', quantity: '5000.00', filled: '0.00', status: 'open', maker: '0x7q8r...9s0t', timestamp: '2026-02-26T10:18:00Z' }, - { id: 'ord-006', symbol: 'HNZ/LUX', side: 'buy', price: '0.150', quantity: '2000.00', filled: '800.00', status: 'partial', maker: '0x1u2v...3w4x', timestamp: '2026-02-26T10:15:00Z' }, - { id: 'ord-007', symbol: 'LUX/USDT', side: 'sell', price: '25.20', quantity: '300.00', filled: '0.00', status: 'open', maker: '0x5y6z...7a8b', timestamp: '2026-02-26T10:12:00Z' }, - { id: 'ord-008', symbol: 'SPC/LUX', side: 'buy', price: '0.0210', quantity: '10000.00', filled: '0.00', status: 'open', maker: '0x9c0d...1e2f', timestamp: '2026-02-26T10:10:00Z' }, - { id: 'ord-009', symbol: 'LUX/USDT', side: 'sell', price: '25.00', quantity: '120.00', filled: '120.00', status: 'filled', maker: '0x3g4h...5i6j', timestamp: '2026-02-26T10:05:00Z' }, - { id: 'ord-010', symbol: 'PARS/LUX', side: 'buy', price: '0.0033', quantity: '50000.00', filled: '0.00', status: 'open', maker: '0x7k8l...9m0n', timestamp: '2026-02-26T10:00:00Z' }, -]; - -const DEMO_TRADES: ReadonlyArray = [ - { id: 'trd-001', symbol: 'LUX/USDT', price: '24.85', quantity: '50.00', buyer: '0x1a2b...3c4d', seller: '0x3m4n...5o6p', fee: '0.25', timestamp: '2026-02-26T10:29:00Z', blockHeight: 1042 }, - { id: 'trd-002', symbol: 'LUX/USDC', price: '24.83', quantity: '100.00', buyer: '0x5e6f...7g8h', seller: '0x9i0j...1k2l', fee: '0.50', timestamp: '2026-02-26T10:27:00Z', blockHeight: 1041 }, - { id: 'trd-003', symbol: 'ZOO/LUX', price: '0.0842', quantity: '2000.00', buyer: '0x7q8r...9s0t', seller: '0x1u2v...3w4x', fee: '0.84', timestamp: '2026-02-26T10:24:00Z', blockHeight: 1040 }, - { id: 'trd-004', symbol: 'HNZ/LUX', price: '0.152', quantity: '500.00', buyer: '0x5y6z...7a8b', seller: '0x9c0d...1e2f', fee: '0.38', timestamp: '2026-02-26T10:21:00Z', blockHeight: 1039 }, - { id: 'trd-005', symbol: 'LUX/USDT', price: '24.80', quantity: '75.00', buyer: '0x3g4h...5i6j', seller: '0x7k8l...9m0n', fee: '0.38', timestamp: '2026-02-26T10:19:00Z', blockHeight: 1038 }, - { id: 'trd-006', symbol: 'SPC/LUX', price: '0.0215', quantity: '8000.00', buyer: '0x1a2b...3c4d', seller: '0x5e6f...7g8h', fee: '0.86', timestamp: '2026-02-26T10:16:00Z', blockHeight: 1037 }, - { id: 'trd-007', symbol: 'LUX/USDT', price: '24.75', quantity: '200.00', buyer: '0x9i0j...1k2l', seller: '0x3m4n...5o6p', fee: '1.00', timestamp: '2026-02-26T10:13:00Z', blockHeight: 1036 }, - { id: 'trd-008', symbol: 'PARS/LUX', price: '0.0034', quantity: '25000.00', buyer: '0x7q8r...9s0t', seller: '0x1u2v...3w4x', fee: '0.43', timestamp: '2026-02-26T10:11:00Z', blockHeight: 1035 }, -]; - -const DEMO_POOLS: ReadonlyArray = [ - { id: 'pool-001', tokenA: 'LUX', tokenB: 'USDT', reserveA: '52,340', reserveB: '1,300,652', tvl: '2,601,304', volume24h: '1,245,890', fee: '0.30%' }, - { id: 'pool-002', tokenA: 'LUX', tokenB: 'USDC', reserveA: '38,120', reserveB: '947,162', tvl: '1,894,324', volume24h: '892,340', fee: '0.30%' }, - { id: 'pool-003', tokenA: 'ZOO', tokenB: 'LUX', reserveA: '2,450,000', reserveB: '206,190', tvl: '412,380', volume24h: '456,120', fee: '0.50%' }, - { id: 'pool-004', tokenA: 'HNZ', tokenB: 'LUX', reserveA: '1,200,000', reserveB: '182,400', tvl: '364,800', volume24h: '334,560', fee: '0.50%' }, - { id: 'pool-005', tokenA: 'SPC', tokenB: 'LUX', reserveA: '5,800,000', reserveB: '124,700', tvl: '249,400', volume24h: '123,450', fee: '1.00%' }, - { id: 'pool-006', tokenA: 'PARS', tokenB: 'LUX', reserveA: '18,000,000', reserveB: '61,200', tvl: '122,400', volume24h: '89,230', fee: '1.00%' }, -]; -/* eslint-enable max-len */ - -const DEMO_OVERVIEW: DexOverviewStats = { - totalPairs: 6, - volume24h: '3,141,590', - activeOrders: 7, - tradesToday: 1332, -}; -export interface UseDexDataResult { - readonly symbols: ReadonlyArray; - readonly orders: ReadonlyArray; - readonly trades: ReadonlyArray; - readonly pools: ReadonlyArray; - readonly overview: DexOverviewStats; - readonly isLoading: boolean; - readonly isError: boolean; - readonly error: Error | null; +const EMPTY_MARKETS: ReadonlyArray = []; +const EMPTY_FILLS: ReadonlyArray = []; + +// 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; } -interface DexDataPayload { - readonly symbols: ReadonlyArray; - readonly orders: ReadonlyArray; - readonly trades: ReadonlyArray; - readonly pools: ReadonlyArray; - readonly overview: DexOverviewStats; +async function fetchGraphql(query: string): Promise { + 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; } -const EMPTY_SYMBOLS: ReadonlyArray = []; -const EMPTY_ORDERS: ReadonlyArray = []; -const EMPTY_TRADES: ReadonlyArray = []; -const EMPTY_POOLS: ReadonlyArray = []; -const EMPTY_OVERVIEW: DexOverviewStats = { - totalPairs: 0, - volume24h: '0', - activeOrders: 0, - tradesToday: 0, -}; - -async function fetchDexData(): Promise { - // Try luxnet SDK D-Chain RPC first +async function fetchTokenSymbol(address: string): Promise { + const base = getApiBase(); + if (!base) { + return null; + } + try { - const dchain = getDChain(); - const stats = await dchain.getStats(); - - if (stats && stats.trades24h > 0) { - const [ pools, markets ] = await Promise.all([ - dchain.getPools(), - dchain.getMarkets(), - ]); - - return { - symbols: DEMO_SYMBOLS, - orders: DEMO_ORDERS, - trades: DEMO_TRADES, - pools: pools.length > 0 ? pools.map((p, i) => ({ - id: `pool-${ i }`, - tokenA: String(p.tokenA), - tokenB: String(p.tokenB), - reserveA: String(p.reserveA), - reserveB: String(p.reserveB), - tvl: String(p.totalLiquidity), - volume24h: DEMO_POOLS[i]?.volume24h ?? '0', - fee: String(p.fee), - })) : DEMO_POOLS, - overview: { - totalPairs: markets.length || DEMO_OVERVIEW.totalPairs, - volume24h: stats.volume24h || DEMO_OVERVIEW.volume24h, - activeOrders: DEMO_OVERVIEW.activeOrders, - tradesToday: stats.trades24h || DEMO_OVERVIEW.tradesToday, - }, - }; + const res = await fetch(`${ base }/tokens/${ address }`); + if (!res.ok) { + return null; } - } catch { /* D-Chain RPC unavailable — fall through to demo data */ } + const json = await res.json().catch(() => null) as { symbol?: string | null } | null; + return json?.symbol || null; + } catch { + return null; + } +} + +function tokenLabel(address: string, symbols: ReadonlyMap): string { + return symbols.get(address.toLowerCase()) || shortenString(address, 10); +} + +// Pure: attach a human pair label to each market from a resolved symbol map. +export function buildMarketViews( + markets: ReadonlyArray, + symbols: ReadonlyMap, +): ReadonlyArray { + return markets.map((m) => ({ + ...m, + pair: `${ tokenLabel(m.baseToken, symbols) }/${ tokenLabel(m.quoteToken, symbols) }`, + })); +} +// Pure: headline aggregates over the live market set. +export function computeOverview(markets: ReadonlyArray): DexOverview { return { - symbols: DEMO_SYMBOLS, - orders: DEMO_ORDERS, - trades: DEMO_TRADES, - pools: DEMO_POOLS, - overview: DEMO_OVERVIEW, + totalMarkets: markets.length, + volume24h: markets.reduce((sum, m) => sum + safeBigInt(m.volume24h), BigInt(0)).toString(), + totalTrades: markets.reduce((sum, m) => sum + (m.tradeCount || 0), 0), }; } +async function fetchMarkets(): Promise> { + const data = await fetchGraphql<{ markets: ReadonlyArray | null }>(MARKETS_QUERY); + 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. + const addresses = Array.from(new Set(markets.flatMap((m) => [ m.baseToken, m.quoteToken ]))); + const resolved = await Promise.all(addresses.map(async(addr) => [ addr.toLowerCase(), await fetchTokenSymbol(addr) ] as const)); + const symbols = new Map(resolved.filter((entry): entry is readonly [string, string] => Boolean(entry[1]))); + + return buildMarketViews(markets, symbols); +} + +async function fetchFills(): Promise> { + const data = await fetchGraphql<{ fills: ReadonlyArray | null }>(FILLS_QUERY); + return data?.fills ?? []; +} + +export interface UseDexDataResult { + readonly markets: ReadonlyArray; + readonly fills: ReadonlyArray; + readonly overview: DexOverview; + readonly isLoading: boolean; + readonly isError: boolean; +} + export function useDexData(): UseDexDataResult { - const query = useQuery({ - queryKey: [ DEX_QUERY_KEY ], - queryFn: fetchDexData, + const marketsQuery = useQuery({ + queryKey: [ 'dchain:markets', getApiBase() ], + queryFn: fetchMarkets, staleTime: DEX_STALE_TIME_MS, }); - const symbols = React.useMemo( - () => query.data?.symbols ?? EMPTY_SYMBOLS, - [ query.data?.symbols ], - ); - - const orders = React.useMemo( - () => query.data?.orders ?? EMPTY_ORDERS, - [ query.data?.orders ], - ); - - const trades = React.useMemo( - () => query.data?.trades ?? EMPTY_TRADES, - [ query.data?.trades ], - ); + const fillsQuery = useQuery({ + queryKey: [ 'dchain:fills', getApiBase() ], + queryFn: fetchFills, + staleTime: DEX_STALE_TIME_MS, + }); - const pools = React.useMemo( - () => query.data?.pools ?? EMPTY_POOLS, - [ query.data?.pools ], - ); + const markets = marketsQuery.data ?? EMPTY_MARKETS; + const fills = fillsQuery.data ?? EMPTY_FILLS; - const overview = React.useMemo( - () => query.data?.overview ?? EMPTY_OVERVIEW, - [ query.data?.overview ], - ); + const overview = React.useMemo(() => computeOverview(markets), [ markets ]); return { - symbols, - orders, - trades, - pools, + markets, + fills, overview, - isLoading: query.isLoading, - isError: query.isError, - error: query.error, + isLoading: marketsQuery.isLoading || fillsQuery.isLoading, + isError: marketsQuery.isError || fillsQuery.isError, }; } + +function safeBigInt(value: string): bigint { + try { + return BigInt(value); + } catch { + return BigInt(0); + } +} diff --git a/ui/dex/DexPage.tsx b/ui/dex/DexPage.tsx index 12321d3326..0e6cc65747 100644 --- a/ui/dex/DexPage.tsx +++ b/ui/dex/DexPage.tsx @@ -1,51 +1,44 @@ -// DEX Chain (D-Chain) orderbook page for the Lux multi-chain explorer. -// Displays market stats, orderbook, trade history, and liquidity pools -// sourced from the DexVM indexer. +// 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. +import { Skeleton } from '@luxfi/ui/skeleton'; import React from 'react'; -import { cn } from 'lib/utils/cn'; +import { route } from 'nextjs/routes'; +import type { DexFill, DexMarketView } from 'lib/api/dchain'; import { useDexData } from 'lib/api/dchain'; -import type { DexOrder, DexTrade, DexPool, DexSymbolStats } from 'lib/api/dchain'; -import { Skeleton } from '@luxfi/ui/skeleton'; -import { Tag } from '@luxfi/ui/tag'; +import shortenString from 'lib/shortenString'; +import { cn } from 'lib/utils/cn'; import PageTitle from 'ui/shared/Page/PageTitle'; // ── Constants ── const TAB_IDS = { markets: 'markets', - orderbook: 'orderbook', trades: 'trades', - pools: 'pools', } 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'; +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'; 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'; -// ── Sub-components ── +// ── Helpers ── -interface TabButtonProps { - readonly label: string; - readonly isActive: boolean; - readonly onClick: () => void; +function groupDigits(value: string): string { + return /^\d+$/.test(value) ? value.replace(/\B(?=(?:\d{3})+(?!\d))/g, ',') : value; } -const TabButton = ({ label, isActive, onClick }: TabButtonProps) => ( - -); +function formatFeeTier(feeTier: number): string { + return `${ (feeTier / 10_000).toFixed(2) }%`; +} + +// ── Sub-components ── interface StatCardProps { readonly label: string; @@ -66,152 +59,72 @@ const StatCard = ({ label, value, isLoading }: StatCardProps) => ( ); -// ── Symbol row ── - -interface SymbolRowProps { - readonly stat: DexSymbolStats; -} - -const SymbolRow = ({ stat }: SymbolRowProps) => { - const isPositive = stat.change24h >= 0; - - return ( -
-
- { stat.symbol } -
-
- { stat.lastPrice } -
-
- - { isPositive ? '+' : '' }{ stat.change24h.toFixed(2) }% - -
-
- { stat.high24h } -
-
- { stat.low24h } -
-
- ${ stat.volume24h } -
-
- { stat.trades24h } -
-
- ); -}; - -// ── Order row ── - -interface OrderRowProps { - readonly order: DexOrder; +interface TabButtonProps { + readonly label: string; + readonly isActive: boolean; + readonly onClick: () => void; } -const OrderRow = ({ order }: OrderRowProps) => { - const isBuy = order.side === 'buy'; - - return ( -
-
- { order.symbol } -
-
- - { order.side.toUpperCase() } - -
-
- { order.price } -
-
- { order.quantity } -
-
- - { (parseFloat(order.price) * parseFloat(order.quantity)).toFixed(2) } - -
-
- { order.maker } -
-
- { formatTime(order.timestamp) } -
-
- - { order.status } - -
-
- ); -}; - -// ── Trade row ── - -interface TradeRowProps { - readonly trade: DexTrade; -} +const TabButton = ({ label, isActive, onClick }: TabButtonProps) => ( + +); -const TradeRow = ({ trade }: TradeRowProps) => ( +const MarketRow = ({ market }: { readonly market: DexMarketView }) => (
-
- { trade.symbol } +
+ { market.pair }
-
- { trade.price } +
+ { formatFeeTier(market.feeTier) }
- { trade.quantity } + { market.lastPrice }
-
- { trade.buyer } -
-
- { trade.seller } -
-
- { trade.fee } +
+ { groupDigits(market.volume24h) }
- { formatTime(trade.timestamp) } + { market.tradeCount }
); -// ── Pool row ── - -interface PoolRowProps { - readonly pool: DexPool; -} - -const PoolRow = ({ pool }: PoolRowProps) => ( +const FillRow = ({ fill, pair }: { readonly fill: DexFill; readonly pair: string }) => (
-
- { pool.tokenA }/{ pool.tokenB } -
-
- { pool.reserveA } +
+ { pair }
-
- { pool.reserveB } + -
- ${ pool.tvl } +
+ { groupDigits(fill.amountOut) }
-
- ${ pool.volume24h } + - ); -// ── Loading skeleton ── - const LoadingSkeleton = () => (
@@ -221,16 +134,25 @@ const LoadingSkeleton = () => (
); +const EmptyState = ({ text }: { readonly text: string }) => ( +
+ { text } +
+); + // ── Main component ── const DexPage = () => { const [ activeTab, setActiveTab ] = React.useState(TAB_IDS.markets); - const { symbols, orders, trades, pools, overview, isLoading } = useDexData(); + const { markets, fills, overview, isLoading } = useDexData(); + + const pairByMarket = React.useMemo( + () => new Map(markets.map((m) => [ m.id, m.pair ])), + [ markets ], + ); const handleMarketsClick = React.useCallback(() => setActiveTab(TAB_IDS.markets), []); - const handleOrderbookClick = React.useCallback(() => setActiveTab(TAB_IDS.orderbook), []); const handleTradesClick = React.useCallback(() => setActiveTab(TAB_IDS.trades), []); - const handlePoolsClick = React.useCallback(() => setActiveTab(TAB_IDS.pools), []); return ( <> @@ -238,62 +160,38 @@ const DexPage = () => { title="DEX" secondRow={ (
- D-Chain decentralized exchange orderbook and market data + C-Chain native exchange — live markets and fills from the 0x9999 PoolManager
) } /> { /* Stats cards */ } -
- - - - +
+ + +
{ /* Tabs */ }
- -
{ /* Markets tab */ } { activeTab === TAB_IDS.markets && (
-
Symbol
-
Price
-
24h Change
-
24h High
-
24h Low
-
24h Volume
+
Pair
+
Fee
+
Last Price
+
24h Volume
Trades
{ isLoading && } - { !isLoading && symbols.map((stat) => ( - - )) } -
- ) } - - { /* Orderbook tab */ } - { activeTab === TAB_IDS.orderbook && ( -
-
-
Symbol
-
Side
-
Price
-
Quantity
-
Total
-
Maker
-
Time
-
Status
-
- { isLoading && } - { !isLoading && orders.map((order) => ( - + { !isLoading && markets.length === 0 && } + { !isLoading && markets.map((market) => ( + )) }
) } @@ -302,35 +200,16 @@ const DexPage = () => { { activeTab === TAB_IDS.trades && (
-
Symbol
-
Price
-
Quantity
-
Buyer
-
Seller
-
Fee
-
Time
-
- { isLoading && } - { !isLoading && trades.map((trade) => ( - - )) } -
- ) } - - { /* Pools tab */ } - { activeTab === TAB_IDS.pools && ( -
-
-
Pair
-
Reserve A
-
Reserve B
-
TVL
-
24h Volume
-
Fee
+
Pair
+
Taker
+
Amount Out
+
Block
+
Txn
{ isLoading && } - { !isLoading && pools.map((pool) => ( - + { !isLoading && fills.length === 0 && } + { !isLoading && fills.map((fill) => ( + )) }
) } @@ -338,21 +217,4 @@ const DexPage = () => { ); }; -// ── Helpers ── - -function formatTime(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); -} - -function getStatusClassName(status: DexOrder['status']): string { - switch (status) { - case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; - case 'filled': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; - case 'partial': return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200'; - case 'cancelled': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; - default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'; - } -} - export default React.memo(DexPage); diff --git a/ui/txs/TxsListItem.tsx b/ui/txs/TxsListItem.tsx index 8fb7c23f30..447fdaf5db 100644 --- a/ui/txs/TxsListItem.tsx +++ b/ui/txs/TxsListItem.tsx @@ -1,3 +1,4 @@ +import { Skeleton } from '@luxfi/ui/skeleton'; import React from 'react'; import type { NovesDescribeTxsResponse } from 'types/api/noves'; @@ -5,7 +6,6 @@ import type { Transaction } from 'types/api/transaction'; import type { ClusterChainConfig } from 'types/multichain'; import config from 'configs/app'; -import { Skeleton } from '@luxfi/ui/skeleton'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; @@ -39,7 +39,6 @@ const TxsListItem = ({ showBlockInfo, currentAddress, enableTimeIncrement, - animation, chainData, translationIsLoading, translationData, @@ -73,7 +72,7 @@ const TxsListItem = ({ hash={ tx.hash } truncation="constant_long" className="font-bold" - icon={ !tx.is_pending_update && tx.transaction_types.includes('blob_transaction') ? { name: 'blob' } : undefined } + icon={ !tx.is_pending_update && tx.transaction_types?.includes('blob_transaction') ? { name: 'blob' } : undefined } chain={ chainData } isPendingUpdate={ tx.is_pending_update } /> @@ -81,7 +80,7 @@ const TxsListItem = ({ timestamp={ tx.timestamp } enableIncrement={ enableTimeIncrement } isLoading={ isLoading } - + />
{ tx.method && ( From 3b14051b8db58c85f4d0a6d0d662851f813781f5 Mon Sep 17 00:00:00 2001 From: zeekay Date: Sun, 28 Jun 2026 23:12:03 -0700 Subject: [PATCH 2/3] ci: build on the lux-build ARC scale set (the only live luxfi linux pool; lux-build-linux-amd64 was removed, [self-hosted,linux,amd64] never matched) --- .github/workflows/build-lux.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-lux.yml b/.github/workflows/build-lux.yml index caa7158b7c..8f3cfbb1be 100644 --- a/.github/workflows/build-lux.yml +++ b/.github/workflows/build-lux.yml @@ -31,7 +31,7 @@ env: jobs: build: - runs-on: [self-hosted, linux, amd64] + runs-on: lux-build permissions: contents: read packages: write @@ -80,7 +80,7 @@ jobs: deploy: needs: build if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - runs-on: [self-hosted, linux, amd64] + runs-on: lux-build permissions: contents: read steps: From e9371bde92850215c567fc00f06627c98f40f9ac Mon Sep 17 00:00:00 2001 From: zeekay Date: Tue, 30 Jun 2026 15:25:56 -0700 Subject: [PATCH 3/3] fix(dex): prefer subgraph-bound BASE/QUOTE symbol for market pair label Request the indexer's bound `symbol` in the markets query and use it as the authoritative pair label when it parses as BASE/QUOTE, falling back to live token-metadata resolution then short addresses. One pair, one source of truth. Co-Authored-By: Claude Opus 4.8 --- lib/api/dchain/types.ts | 3 +++ lib/api/dchain/useDexData.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/api/dchain/types.ts b/lib/api/dchain/types.ts index 4e9bce7817..36feefda31 100644 --- a/lib/api/dchain/types.ts +++ b/lib/api/dchain/types.ts @@ -5,6 +5,9 @@ export interface DexMarket { readonly id: string; + // BASE/QUOTE pair the indexer bound from the two currencies' ERC-20 symbols + // (empty/poolId-hex when a token has no clean symbol). Authoritative when set. + readonly symbol?: string; readonly baseToken: string; readonly quoteToken: string; readonly feeTier: number; diff --git a/lib/api/dchain/useDexData.ts b/lib/api/dchain/useDexData.ts index 50a681ba11..211f49f32a 100644 --- a/lib/api/dchain/useDexData.ts +++ b/lib/api/dchain/useDexData.ts @@ -8,7 +8,7 @@ import shortenString from 'lib/shortenString'; // One root field per request — the subgraph engine processes a single root // field per query, so markets and fills are fetched separately. -const MARKETS_QUERY = '{ markets { id baseToken quoteToken feeTier volume24h tradeCount lastPrice lastUpdate } }'; +const MARKETS_QUERY = '{ markets { id symbol baseToken quoteToken feeTier volume24h tradeCount lastPrice lastUpdate } }'; const FILLS_QUERY = '{ fills(first:25) { id market taker amountOut timestamp txHash } }'; const DEX_GRAPHQL_PATH = '/v1/graph/cchain/dex/graphql'; @@ -80,7 +80,12 @@ export function buildMarketViews( ): ReadonlyArray { return markets.map((m) => ({ ...m, - pair: `${ tokenLabel(m.baseToken, symbols) }/${ tokenLabel(m.quoteToken, symbols) }`, + // Prefer the subgraph's bound BASE/QUOTE symbol (the indexer derives it from + // both currencies' ERC-20 symbols — one pair, one source of truth). Fall back + // to live token-metadata resolution, then to short addresses. + pair: m.symbol && m.symbol.includes('/') ? + m.symbol : + `${ tokenLabel(m.baseToken, symbols) }/${ tokenLabel(m.quoteToken, symbols) }`, })); }