Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
import { ChakraProvider } from '@chakra-ui/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { zeroAddress } from 'viem'
import { describe, expect, it } from 'vitest'
import { system } from '@/src/components/ui/provider'
import type { Token } from '@/src/types/token'
import TokenLogo from './TokenLogo'

const system = createSystem(defaultConfig)
import TokenLogo from '.'

const mockToken: Token = {
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
Expand Down Expand Up @@ -72,4 +72,46 @@ describe('TokenLogo', () => {
const img = screen.getByRole('img')
expect(img.getAttribute('src')).toBe('https://ipfs.io/ipfs/QmHash123')
})

it('renders the chain icon (not the img or placeholder) for a native token on a mapped chain', () => {
const nativeEthToken: Token = {
address: zeroAddress,
chainId: 1,
decimals: 18,
name: 'Ether',
symbol: 'ETH',
}
const { container } = renderTokenLogo(nativeEthToken)
expect(screen.queryByRole('img')).toBeNull()
expect(screen.queryByText('E')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
})

it('renders the chain icon for the native POL on Polygon (chainId 137)', () => {
const nativePolToken: Token = {
address: zeroAddress,
chainId: 137,
decimals: 18,
name: 'POL',
symbol: 'POL',
}
const { container } = renderTokenLogo(nativePolToken)
expect(screen.queryByRole('img')).toBeNull()
expect(screen.queryByText('P')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
})

it('falls back to placeholder for a native token on an unmapped chain', () => {
const nativeUnknownToken: Token = {
address: zeroAddress,
chainId: 999999,
decimals: 18,
name: 'Unknown',
symbol: 'XXX',
}
const { container } = renderTokenLogo(nativeUnknownToken)
expect(screen.queryByRole('img')).toBeNull()
expect(container.querySelector('svg')).toBeNull()
expect(screen.getByText('X')).toBeDefined()
})
})
Original file line number Diff line number Diff line change
@@ -1,46 +1,41 @@
import { Flex } from '@chakra-ui/react'
import { type ComponentProps, type FC, useCallback, useEffect, useState } from 'react'
import { type ComponentProps, type FC, useEffect, useMemo, useState } from 'react'

import { nativeTokenIcons } from '@/src/components/sharedComponents/TokenLogo/nativeTokenIcons'
import type { ChainsIds } from '@/src/lib/networks.config'
import type { Token } from '@/src/types/token'
import { isNativeToken } from '@/src/utils/address'

interface PlaceholderProps extends ComponentProps<'div'> {
size: number
symbol: string
}

const Placeholder: FC<PlaceholderProps> = ({ size, symbol, ...restProps }) => {
const [backgroundColor, setBackgroundColor] = useState<string>('')

const generateHexColor = useCallback((symbol: string): string => {
// Convert symbol to a hash number
let hash = 0
for (let i = 0; i < symbol.length; i++) {
hash = symbol.charCodeAt(i) + ((hash << 5) - hash)
}

// Convert hash to a hexadecimal string and ensure it is 6 characters long
const baseColor =
((hash >> 24) & 0xff).toString(16).padStart(2, '0') +
((hash >> 16) & 0xff).toString(16).padStart(2, '0') +
((hash >> 8) & 0xff).toString(16).padStart(2, '0')

// Ensure the baseColor is dark-ish by making sure each component is less than 196
const r = Number.parseInt(baseColor.slice(0, 2), 16) % 196
const g = Number.parseInt(baseColor.slice(2, 4), 16) % 196
const b = Number.parseInt(baseColor.slice(4, 6), 16) % 196

// Convert back to hex string and pad with leading 6s if necessary and also
// because I love Satan
const color =
r.toString(16).padStart(2, '6') +
g.toString(16).padStart(2, '6') +
b.toString(16).padStart(2, '6')

return `#${color}`
}, [])
const generateHexColor = (symbol: string): string => {
let hash = 0
for (let i = 0; i < symbol.length; i++) {
hash = symbol.charCodeAt(i) + ((hash << 5) - hash)
}

useEffect(() => {
setBackgroundColor(generateHexColor(symbol))
}, [symbol, generateHexColor])
const baseColor =
((hash >> 24) & 0xff).toString(16).padStart(2, '0') +
((hash >> 16) & 0xff).toString(16).padStart(2, '0') +
((hash >> 8) & 0xff).toString(16).padStart(2, '0')

const r = Number.parseInt(baseColor.slice(0, 2), 16) % 196
const g = Number.parseInt(baseColor.slice(2, 4), 16) % 196
const b = Number.parseInt(baseColor.slice(4, 6), 16) % 196

const color =
r.toString(16).padStart(2, '0') +
g.toString(16).padStart(2, '0') +
b.toString(16).padStart(2, '0')

return `#${color}`
}

const Placeholder: FC<PlaceholderProps> = ({ size, symbol, ...restProps }) => {
const backgroundColor = useMemo(() => generateHexColor(symbol), [symbol])

return (
<Flex
Expand Down Expand Up @@ -75,10 +70,15 @@ interface TokenLogoProps {
/**
* TokenLogo component, displays a token logo based on the provided token object.
*
* Native tokens (detected via `isNativeToken(token.address)`, a case-insensitive
* match against `env.PUBLIC_NATIVE_TOKEN_ADDRESS`) render the chain-specific icon
* from `@web3icons/react` when the chain is mapped in `nativeTokenIcons`. Otherwise
* the component renders `logoURI` as an image, falling back to the colored-letter
* Placeholder on load failure or missing URI.
*
* @param {TokenLogoProps} props - TokenLogo component props.
* @param {Token} props.token - The token object to display the logo for.
* @param {number} [props.size=24] - The size of the logo in pixels.
* @param {ComponentProps<'img'>} [props.restProps] - Additional props for the img element.
*
* @example
* ```tsx
Expand All @@ -97,6 +97,19 @@ const TokenLogo: FC<TokenLogoProps> = ({ size = 24, token }) => {
setHasError(false)
}, [logoURI])

const NativeIcon = isNativeToken(token.address)
? nativeTokenIcons[token.chainId as ChainsIds]
: undefined

if (NativeIcon) {
return (
<NativeIcon
size={size}
variant="background"
/>
)
}

return logoURI && !hasError ? (
<img
alt={token.name}
Expand Down
20 changes: 20 additions & 0 deletions src/components/sharedComponents/TokenLogo/nativeTokenIcons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { IconComponent } from '@web3icons/react'
import {
NetworkArbitrumOne,
NetworkEthereum,
NetworkOptimism,
NetworkOptimismSepolia,
NetworkPolygon,
NetworkSepolia,
} from '@web3icons/react'

import type { ChainsIds } from '@/src/lib/networks.config'

export const nativeTokenIcons: Partial<Record<ChainsIds, IconComponent>> = {
1: NetworkEthereum,
10: NetworkOptimism,
137: NetworkPolygon,
42161: NetworkArbitrumOne,
11155111: NetworkSepolia,
11155420: NetworkOptimismSepolia,
}
Loading
Loading