From e4f1afa1cef4a879df1ac4208862c9d9c0f06d7a Mon Sep 17 00:00:00 2001 From: artoo-claw Date: Tue, 17 Feb 2026 04:24:18 +0000 Subject: [PATCH] feat: add LightLink token detail page fallback via subgraph - Add useLightLinkTokenQuery hook that fetches token data from LightLink v3 subgraph - Wire fallback into useCreateTDPContext for LightLink chain (skips Uniswap GraphQL) - Include token logo map for known LightLink tokens (WETH, USDC) - Subgraph provides volume, TVL, and price data; market cap/FDV gracefully absent --- apps/web/src/hooks/useLightLinkTokenQuery.ts | 167 +++++++++++++++++++ apps/web/src/pages/TokenDetails/index.tsx | 31 +++- 2 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/hooks/useLightLinkTokenQuery.ts diff --git a/apps/web/src/hooks/useLightLinkTokenQuery.ts b/apps/web/src/hooks/useLightLinkTokenQuery.ts new file mode 100644 index 00000000000..9b7bd5c36c8 --- /dev/null +++ b/apps/web/src/hooks/useLightLinkTokenQuery.ts @@ -0,0 +1,167 @@ +import { useEffect, useMemo, useState } from 'react' +import { Token } from '@uniswap/sdk-core' +import { GraphQLApi } from '@universe/api' +import { UniverseChainId } from 'uniswap/src/features/chains/types' + +const LIGHTLINK_SUBGRAPH_URL = + 'https://graph.phoenix.lightlink.io/query/subgraphs/name/uniswap-v3-lightlink' + +// Known LightLink token logos +const LIGHTLINK_TOKEN_LOGOS: Record = { + '0x7ebef2a4b1b09381ec5b9df8c5c6f2dbeca59c73': + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + '0xbcf8c1b03bbdda88d579330bdf236b58f8bb2cfd': + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', +} + +export function getLightLinkTokenLogoUrl(address: string): string | undefined { + return LIGHTLINK_TOKEN_LOGOS[address.toLowerCase()] +} + +interface SubgraphToken { + id: string + symbol: string + name: string + decimals: string + volumeUSD: string + totalValueLockedUSD: string + derivedETH: string + tokenDayData: Array<{ + date: number + priceUSD: string + volumeUSD: string + }> +} + +interface LightLinkTokenQueryResult { + data: { token: GraphQLApi.TokenWebQuery['token'] } | undefined + loading: boolean + error: Error | undefined + currency: Token | undefined +} + +export function useLightLinkTokenQuery(address: string | undefined): LightLinkTokenQueryResult { + const [subgraphToken, setSubgraphToken] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState() + + useEffect(() => { + if (!address) { + setLoading(false) + return + } + + let cancelled = false + setLoading(true) + + const query = `{ + token(id: "${address.toLowerCase()}") { + id + symbol + name + decimals + volumeUSD + totalValueLockedUSD + derivedETH + tokenDayData(first: 365, orderBy: date, orderDirection: desc) { + date + priceUSD + volumeUSD + } + } + }` + + fetch(LIGHTLINK_SUBGRAPH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }) + .then((res) => res.json()) + .then((json) => { + if (!cancelled) { + setSubgraphToken(json.data?.token ?? null) + setLoading(false) + } + }) + .catch((err) => { + if (!cancelled) { + setError(err) + setLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [address]) + + const currency = useMemo(() => { + if (!subgraphToken || !address) return undefined + return new Token( + UniverseChainId.LightLink, + address, + parseInt(subgraphToken.decimals, 10), + subgraphToken.symbol, + subgraphToken.name, + ) + }, [subgraphToken, address]) + + // Shape the data to match TokenWebQuery['token'] as closely as possible + const data = useMemo(() => { + if (!subgraphToken) return undefined + + const logoUrl = getLightLinkTokenLogoUrl(subgraphToken.id) + const volume24H = parseFloat(subgraphToken.volumeUSD) + const tvl = parseFloat(subgraphToken.totalValueLockedUSD) + + const token: GraphQLApi.TokenWebQuery['token'] = { + __typename: 'Token' as const, + id: `lightlink-${subgraphToken.id}`, + name: subgraphToken.name, + symbol: subgraphToken.symbol, + decimals: parseInt(subgraphToken.decimals, 10), + address: subgraphToken.id, + chain: GraphQLApi.Chain.UnknownChain, + standard: GraphQLApi.TokenStandard.Erc20, + feeData: null, + market: { + __typename: 'TokenMarket' as const, + id: `lightlink-market-${subgraphToken.id}`, + volume24H: { __typename: 'Amount' as const, id: `vol-${subgraphToken.id}`, value: volume24H, currency: GraphQLApi.Currency.Usd }, + totalValueLocked: { __typename: 'Amount' as const, id: `tvl-${subgraphToken.id}`, value: tvl, currency: GraphQLApi.Currency.Usd }, + price: subgraphToken.tokenDayData.length > 0 + ? { + __typename: 'Amount' as const, + id: `price-${subgraphToken.id}`, + value: parseFloat(subgraphToken.tokenDayData[0].priceUSD), + currency: GraphQLApi.Currency.Usd, + } + : null, + pricePercentChange24h: null, + priceHigh52W: null, + priceLow52W: null, + }, + project: { + __typename: 'TokenProject' as const, + id: `lightlink-project-${subgraphToken.id}`, + name: subgraphToken.name, + description: null, + homepageUrl: null, + twitterName: null, + logoUrl: logoUrl ?? null, + tokens: [ + { + __typename: 'Token' as const, + id: `lightlink-${subgraphToken.id}`, + chain: GraphQLApi.Chain.UnknownChain, + address: subgraphToken.id, + }, + ], + }, + } + + return { token } + }, [subgraphToken]) + + return { data, loading, error, currency } +} diff --git a/apps/web/src/pages/TokenDetails/index.tsx b/apps/web/src/pages/TokenDetails/index.tsx index fe76bd63a5e..90129fa8f07 100644 --- a/apps/web/src/pages/TokenDetails/index.tsx +++ b/apps/web/src/pages/TokenDetails/index.tsx @@ -6,6 +6,7 @@ import { TokenDetailsPageSkeleton } from 'components/Tokens/TokenDetails/Skeleto import { NATIVE_CHAIN_ID } from 'constants/tokens' import { useActiveAddresses } from 'features/accounts/store/hooks' import { useSrcColor } from 'hooks/useColor' +import { useLightLinkTokenQuery } from 'hooks/useLightLinkTokenQuery' import { ExploreTab } from 'pages/Explore/constants' import { useDynamicMetatags } from 'pages/metatags' import { LoadedTDPContext, MultiChainMap, PendingTDPContext, TDPProvider } from 'pages/TokenDetails/TDPContext' @@ -79,24 +80,46 @@ function useCreateTDPContext(): PendingTDPContext | LoadedTDPContext { } const currencyChainInfo = getChainInfo(useChainIdFromUrlParam() ?? UniverseChainId.Mainnet) + const isLightLink = currencyChainInfo.id === UniverseChainId.LightLink const isNative = tokenAddress === NATIVE_CHAIN_ID const tokenDBAddress = isNative ? getNativeTokenDBAddress(currencyChainInfo.backendChain.chain) : tokenAddress + // Standard GraphQL query (skipped for LightLink) const tokenQuery = GraphQLApi.useTokenWebQuery({ variables: { address: tokenDBAddress, chain: currencyChainInfo.backendChain.chain }, errorPolicy: 'all', + skip: isLightLink, }) + + // LightLink subgraph fallback + const lightLinkResult = useLightLinkTokenQuery(isLightLink ? tokenAddress : undefined) + + // Merge: for LightLink, overlay subgraph data onto tokenQuery shape + const effectiveTokenQuery = useMemo(() => { + if (!isLightLink) return tokenQuery + // Create a compatible object that looks like an Apollo QueryResult + return { + ...tokenQuery, + data: lightLinkResult.data as GraphQLApi.TokenWebQuery | undefined, + loading: lightLinkResult.loading, + error: lightLinkResult.error as any, + } + }, [isLightLink, tokenQuery, lightLinkResult.data, lightLinkResult.loading, lightLinkResult.error]) + const currency = useMemo(() => { if (isNative) { return nativeOnChain(currencyChainInfo.id) } + if (isLightLink) { + return lightLinkResult.currency + } if (tokenQuery.data?.token) { return gqlToCurrency(tokenQuery.data.token) } return undefined - }, [tokenQuery.data?.token, isNative, currencyChainInfo.id]) + }, [tokenQuery.data?.token, isNative, currencyChainInfo.id, isLightLink, lightLinkResult.currency]) const chartState = useCreateTDPChartState(tokenDBAddress, currencyChainInfo.backendChain.chain) @@ -106,7 +129,7 @@ function useCreateTDPContext(): PendingTDPContext | LoadedTDPContext { const colors = useSporeColors() // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const { preloadedLogoSrc } = (useLocation().state as { preloadedLogoSrc?: string }) ?? {} - const extractedColorSrc = tokenQuery.data?.token?.project?.logoUrl ?? preloadedLogoSrc + const extractedColorSrc = effectiveTokenQuery.data?.token?.project?.logoUrl ?? preloadedLogoSrc const tokenColor = useSrcColor({ src: extractedColorSrc, @@ -121,7 +144,7 @@ function useCreateTDPContext(): PendingTDPContext | LoadedTDPContext { currencyChainId: currencyChainInfo.id, // `currency.address` is checksummed, whereas the `tokenAddress` url param may not be address: (currency?.isNative ? NATIVE_CHAIN_ID : currency?.address) ?? tokenAddress, - tokenQuery, + tokenQuery: effectiveTokenQuery as any, chartState, multiChainMap, tokenColor, @@ -131,7 +154,7 @@ function useCreateTDPContext(): PendingTDPContext | LoadedTDPContext { currencyChainInfo.backendChain.chain, currencyChainInfo.id, tokenAddress, - tokenQuery, + effectiveTokenQuery, chartState, multiChainMap, tokenColor,