Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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