Skip to content
12 changes: 6 additions & 6 deletions apps/cow-fi/app/(main)/tokens/[tokenId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ type Props = {

function getTokenMetaData(token: TokenDetails) {
const { name, symbol, change24h, priceUsd } = token
const change24 = parseFloat(change24h as string)
const change24hFormatted = change24.toFixed(2)
const isIncrease = parseFloat(change24h as string) >= 0
const priceChangeEmoji = isIncrease ? '🟢' : '🔴'
const changeDirection = isIncrease ? '▲' : '▼'
const title = `${priceChangeEmoji} ${name} (${symbol}) $${priceUsd} (${change24hFormatted}% ${changeDirection}) - ${CONFIG.metatitle_tokenDetail} - ${CONFIG.title.default}`
const priceSegment = typeof priceUsd === 'number' ? `$${priceUsd}` : 'Price unavailable'
const change24hFormatted = typeof change24h === 'number' ? change24h.toFixed(2) : '0.00'
const isIncrease = typeof change24h === 'number' ? change24h >= 0 : true
const priceChangeEmoji = typeof change24h === 'number' ? (isIncrease ? '🟢' : '🔴') : '⚪'
const changeDirection = typeof change24h === 'number' ? (isIncrease ? '▲' : '▼') : '•'
const title = `${priceChangeEmoji} ${name} (${symbol}) ${priceSegment} (${change24hFormatted}% ${changeDirection}) - ${CONFIG.metatitle_tokenDetail} - ${CONFIG.title.default}`
const description = `Track the latest ${name} (${symbol}) price, market cap, trading volume, and more with CoW DAO's live ${name} price chart.`

return { title, description }
Expand Down
74 changes: 74 additions & 0 deletions apps/cow-fi/components/ChartSection/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** @jest-environment jsdom */

import type { JSX, ReactNode } from 'react'

import { render } from '@testing-library/react'

import { ChartSection } from '.'

const mockUseQuery = jest.fn()

jest.mock('@apollo/client', () => ({
useQuery: (...args: unknown[]) => mockUseQuery(...args),
}))

jest.mock('@visx/responsive', () => ({
ParentSize: ({ children }: { children: ({ width }: { width: number }) => JSX.Element }) => children({ width: 320 }),
}))

jest.mock('services/uniswap-price/queries', () => ({
tokenPriceQuery: 'token-price-query',
HistoryDuration: {
Day: 'day',
},
Chain: {
Ethereum: 'ethereum',
},
}))

jest.mock('util/fixChart', () => ({
fixChart: jest.fn(() => ({ prices: [], blanks: [] })),
}))

jest.mock('lib/hooks/usePriceHistory', () => ({
usePriceHistory: jest.fn(() => null),
}))

jest.mock('../Chart/LoadingChart', () => ({
ChartContainer: ({ children }: { children: ReactNode }) => <div>{children}</div>,
LoadingChart: () => <div>Loading chart</div>,
}))

jest.mock('../Chart', () => ({
Chart: () => <div>Chart</div>,
TimePeriod: {
DAY: 'DAY',
},
}))

describe('ChartSection', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseQuery.mockReturnValue({ data: undefined, loading: false })
})

it('skips the Ethereum price query when the ethereum platform is missing', () => {
expect(() =>
render(
<ChartSection
platforms={{
base: {
contractAddress: '0x4200000000000000000000000000000000000006',
decimalPlace: 18,
},
}}
/>,
),
).not.toThrow()

expect(mockUseQuery).toHaveBeenCalledWith('token-price-query', {
variables: undefined,
skip: true,
})
})
})
14 changes: 8 additions & 6 deletions apps/cow-fi/components/ChartSection/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { JSX } from 'react'
import { useMemo } from 'react'

import { useQuery } from '@apollo/client'
Expand All @@ -10,8 +11,7 @@ import { fixChart } from 'util/fixChart'
import { usePriceHistory } from 'lib/hooks/usePriceHistory'

import { Chart, TimePeriod } from '../Chart'
import { ChartContainer } from '../Chart/LoadingChart'
import { LoadingChart } from '../Chart/LoadingChart'
import { ChartContainer, LoadingChart } from '../Chart/LoadingChart'

type ChartSectionProps = {
platforms: Platforms
Expand All @@ -23,18 +23,20 @@ type QueryVars = {
address: string
}

export function ChartSection({ platforms }: ChartSectionProps) {
export function ChartSection({ platforms }: ChartSectionProps): JSX.Element {
const ethereumAddress = platforms.ethereum?.contractAddress

const queryVariables = useMemo<QueryVars | undefined>(() => {
if (!platforms.ethereum.contractAddress) {
if (!ethereumAddress) {
return undefined
}

return {
duration: HistoryDuration.Day,
chain: Chain.Ethereum,
address: platforms.ethereum.contractAddress,
address: ethereumAddress,
}
}, [platforms.ethereum.contractAddress])
}, [ethereumAddress])

const { data, loading } = useQuery(tokenPriceQuery, {
variables: queryVariables,
Expand Down
2 changes: 1 addition & 1 deletion apps/cow-fi/data/cow-amm/const.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export const FAQ_DATA = [
<>
CoW AMM pools live on Balancer can be found at{' '}
<Link
href="http://balancer.fi/pools/cow"
href="https://balancer.fi/pools/cow"
external
utmContent="cow-amm-balancer-pools"
onClick={() =>
Expand Down
113 changes: 69 additions & 44 deletions apps/cow-fi/services/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
import { COW_CDN } from '@cowprotocol/common-const'

import { backOff } from 'exponential-backoff'
import { PlatformData, Platforms, TokenDetails, TokenInfo } from 'types'
import { TokenDetails, TokenInfo } from 'types'

import fs from 'fs'
import path from 'path'

import {
isRawTokenData,
normalizeOptionalUsdMetric,
normalizePlatforms,
normalizeTokenMarketCapRank,
normalizeTokenSymbol,
} from './validation'

import { DATA_CACHE_TIME_SECONDS } from '@/const/meta'
import { Network } from '@/const/networkMap'

Expand Down Expand Up @@ -39,14 +47,12 @@ export async function getTokensInfo(): Promise<TokenInfo[]> {
const tokens = tokensRaw.map(_toTokenInfo)

const sortedTokens = tokens.sort(_sortTokensInfoByMarketCap)
const cowTokenIndex = sortedTokens.findIndex((item) => item.id === COW_TOKEN_ID)

// Move COW at the top
sortedTokens.unshift(
tokens.splice(
tokens.findIndex((item) => item.id === COW_TOKEN_ID),
1,
)[0],
)
if (cowTokenIndex > 0) {
const [cowToken] = sortedTokens.splice(cowTokenIndex, 1)
sortedTokens.unshift(cowToken)
}

return sortedTokens
}
Expand All @@ -67,7 +73,7 @@ function _getDescriptionFilePaths(): string[] {
return fs.readdirSync(DESCRIPTIONS_DIR_PATH, 'utf-8')
}

async function fetchWithBackoff(url: string) {
async function fetchWithBackoff(url: string): Promise<unknown> {
return backOff(
() => {
return fetch(url, {
Expand All @@ -90,7 +96,7 @@ async function fetchWithBackoff(url: string) {
}

async function _getAllTokensData(): Promise<TokenDetails[]> {
let tokenRawData: TokenDetails[]
let tokenRawData: unknown

try {
tokenRawData = await fetchWithBackoff(TOKEN_LISTS_URL)
Expand All @@ -99,13 +105,22 @@ async function _getAllTokensData(): Promise<TokenDetails[]> {
return []
}

if (!Array.isArray(tokenRawData)) {
console.error('[cow-fi] Token list payload was not an array')
return []
}

// Get manual descriptions
const descriptionFilePaths = _getDescriptionFilePaths()
const descriptionFiles = descriptionFilePaths.map((f) => f.replace('.md', ''))

// Enhance description and transform to token details
const tokens = tokenRawData
.map((tokenRaw: TokenDetails) => {
.map((tokenRaw) => {
if (!isRawTokenData(tokenRaw)) {
return undefined
}

// if the token does not have a description file, skip it
if (!descriptionFiles.includes(tokenRaw.id)) {
return undefined
Expand All @@ -126,42 +141,58 @@ function _getTokenDescription(id: string): string {
return fs.readFileSync(filePath, 'utf-8')
}

function _toTokenDetails(tokenRaw: any, description: string): TokenDetails {
// Add platform information
const detailPlatforms = tokenRaw.detail_platforms

const platforms = NETWORKS.reduce<Platforms>((acc, network) => {
const platformRaw = detailPlatforms[network]
if (platformRaw) {
acc[network] = _toPlatform(platformRaw)
function _toTokenDetails(
tokenRaw: {
detail_platforms?: unknown
id: string
image?: {
large?: unknown
}

return acc
}, {})

// Return the details
market_cap_rank?: unknown
market_data?: {
ath?: {
usd?: unknown
}
atl?: {
usd?: unknown
}
current_price?: {
usd?: unknown
}
market_cap?: {
usd?: unknown
}
price_change_percentage_24h?: unknown
total_volume?: {
usd?: unknown
}
}
name: string
symbol: string
},
description: string,
): TokenDetails {
const platforms = normalizePlatforms(tokenRaw.detail_platforms, NETWORKS)
const marketData = tokenRaw.market_data
const token = {
const change24h = marketData?.price_change_percentage_24h

return {
id: tokenRaw.id,
name: tokenRaw.name,
symbol: tokenRaw.symbol?.toUpperCase(),
symbol: normalizeTokenSymbol(tokenRaw.symbol),
description,
metaDescription: '',
// description: description || token?.description?.en || token?.ico_data?.desc || '-', // Replicate old behavior (but not needed, since manual description is always required, leaving for now to double check with Nenad)
marketCapRank: tokenRaw.market_cap_rank,
marketCap: marketData?.market_cap?.usd ?? null,
allTimeHigh: marketData?.ath.usd ?? null,
allTimeLow: marketData?.atl.usd ?? null,
volume: marketData?.total_volume?.usd ?? null,
priceUsd: marketData?.current_price?.usd ?? null,
change24h: marketData?.price_change_percentage_24h ?? null,
marketCapRank: normalizeTokenMarketCapRank(tokenRaw.market_cap_rank),
marketCap: normalizeOptionalUsdMetric(marketData?.market_cap),
allTimeHigh: normalizeOptionalUsdMetric(marketData?.ath),
allTimeLow: normalizeOptionalUsdMetric(marketData?.atl),
volume: normalizeOptionalUsdMetric(marketData?.total_volume),
priceUsd: normalizeOptionalUsdMetric(marketData?.current_price),
change24h: typeof change24h === 'number' && Number.isFinite(change24h) ? change24h : null,
image: {
large: tokenRaw?.image?.large ?? null,
large: typeof tokenRaw.image?.large === 'string' ? tokenRaw.image.large : null,
},
platforms,
}

return { ...token }
}

function _toTokenInfo(token: TokenDetails): TokenInfo {
Expand All @@ -170,12 +201,6 @@ function _toTokenInfo(token: TokenDetails): TokenInfo {
return { id, name, symbol, image, marketCapRank, priceUsd, change24h, volume, marketCap }
}

function _toPlatform(platform: any): PlatformData {
return {
contractAddress: platform.contract_address || '',
decimalPlace: platform.decimal_place || 18,
}
}
function _sortTokensInfoByMarketCap(a: TokenInfo, b: TokenInfo): number {
// Sort by market cap
if (a.marketCapRank === null) {
Expand Down
72 changes: 72 additions & 0 deletions apps/cow-fi/services/tokens/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { TextDecoder, TextEncoder } from 'util'

Object.assign(globalThis, { TextDecoder, TextEncoder })

const { isRawTokenData, normalizePlatformData, normalizePlatforms, normalizeTokenMarketCapRank } =
require('./validation') as typeof import('./validation')

describe('token validation', () => {
it('accepts only token payloads with the required string fields', () => {
expect(
isRawTokenData({
id: 'cow-protocol',
name: 'CoW Protocol',
symbol: 'cow',
}),
).toBe(true)

expect(
isRawTokenData({
id: 'cow-protocol',
name: 'CoW Protocol',
symbol: 1,
}),
).toBe(false)
})

it('keeps only valid EVM addresses in platform data', () => {
expect(
normalizePlatformData({
contract_address: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41',
decimal_place: 18,
}),
).toEqual({
contractAddress: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41',
decimalPlace: 18,
})

expect(
normalizePlatformData({
contract_address: 'javascript:alert(1)',
decimal_place: 18,
}),
).toBeNull()
})

it('drops unsupported or malformed platform entries when building swap platforms', () => {
expect(
normalizePlatforms(
{
ethereum: {
contract_address: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41',
},
'polygon-pos': {
contract_address: 'not-an-address',
},
},
['ethereum', 'polygon-pos'],
),
).toEqual({
ethereum: {
contractAddress: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41',
decimalPlace: 18,
},
})
})

it('normalizes market-cap rank only for positive integers', () => {
expect(normalizeTokenMarketCapRank(12)).toBe(12)
expect(normalizeTokenMarketCapRank(0)).toBeNull()
expect(normalizeTokenMarketCapRank('12')).toBeNull()
})
})
Loading
Loading