From 32cdc6cca5a6d14ba68ad132f67d66bc26dff597 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:41:49 -0300 Subject: [PATCH 1/5] feat(token-logo): render chain icons for native tokens Native tokens (ETH, POL, OP, ARB, etc.) previously rendered the letter-placeholder because buildNativeToken() has no logoURI. Detect native tokens in TokenLogo via isNativeToken(token.address) and render the matching @web3icons/react NetworkXxx component keyed by chainId, falling back to the existing img/placeholder for unmapped chains. Closes #164 --- .../{ => TokenLogo}/TokenLogo.test.tsx | 44 ++++++++++++++++++- .../{TokenLogo.tsx => TokenLogo/index.tsx} | 23 +++++++++- .../TokenLogo/nativeTokenIcons.tsx | 20 +++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) rename src/components/sharedComponents/{ => TokenLogo}/TokenLogo.test.tsx (62%) rename src/components/sharedComponents/{TokenLogo.tsx => TokenLogo/index.tsx} (80%) create mode 100644 src/components/sharedComponents/TokenLogo/nativeTokenIcons.tsx diff --git a/src/components/sharedComponents/TokenLogo.test.tsx b/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx similarity index 62% rename from src/components/sharedComponents/TokenLogo.test.tsx rename to src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx index b8d4aa93..3adefb70 100644 --- a/src/components/sharedComponents/TokenLogo.test.tsx +++ b/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx @@ -1,8 +1,9 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { fireEvent, render, screen } from '@testing-library/react' +import { zeroAddress } from 'viem' import { describe, expect, it } from 'vitest' import type { Token } from '@/src/types/token' -import TokenLogo from './TokenLogo' +import TokenLogo from '.' const system = createSystem(defaultConfig) @@ -72,4 +73,45 @@ 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', + } + renderTokenLogo(nativeUnknownToken) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByText('X')).toBeDefined() + }) }) diff --git a/src/components/sharedComponents/TokenLogo.tsx b/src/components/sharedComponents/TokenLogo/index.tsx similarity index 80% rename from src/components/sharedComponents/TokenLogo.tsx rename to src/components/sharedComponents/TokenLogo/index.tsx index de2f6041..37e23b46 100644 --- a/src/components/sharedComponents/TokenLogo.tsx +++ b/src/components/sharedComponents/TokenLogo/index.tsx @@ -1,6 +1,10 @@ import { Flex } from '@chakra-ui/react' import { type ComponentProps, type FC, useCallback, useEffect, 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 @@ -75,10 +79,14 @@ interface TokenLogoProps { /** * TokenLogo component, displays a token logo based on the provided token object. * + * Native tokens (detected via `token.address === 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 @@ -97,6 +105,19 @@ const TokenLogo: FC = ({ size = 24, token }) => { setHasError(false) }, [logoURI]) + const NativeIcon = isNativeToken(token.address) + ? nativeTokenIcons[token.chainId as ChainsIds] + : undefined + + if (NativeIcon) { + return ( + + ) + } + return logoURI && !hasError ? ( {token.name}> = { + 1: NetworkEthereum, + 10: NetworkOptimism, + 137: NetworkPolygon, + 42161: NetworkArbitrumOne, + 11155111: NetworkSepolia, + 11155420: NetworkOptimismSepolia, +} From 7253982315fa91e6fe0193469b9c7670e6bc0d89 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:00:18 -0300 Subject: [PATCH 2/5] test(token-logo): assert no svg in unmapped-chain fallback test --- src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx b/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx index 3adefb70..b0e4e6cc 100644 --- a/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx +++ b/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx @@ -110,8 +110,9 @@ describe('TokenLogo', () => { name: 'Unknown', symbol: 'XXX', } - renderTokenLogo(nativeUnknownToken) + const { container } = renderTokenLogo(nativeUnknownToken) expect(screen.queryByRole('img')).toBeNull() + expect(container.querySelector('svg')).toBeNull() expect(screen.getByText('X')).toBeDefined() }) }) From 0454a809a73ec9a44d3ea28d67063720064b1691 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:20:35 -0300 Subject: [PATCH 3/5] refactor(token-logo): apply code-review fixes - hoist Chakra system to module scope in provider.tsx and export it - use real project system in TokenLogo tests instead of bare defaultConfig - replace Placeholder useState+useEffect+useCallback with useMemo - fix padStart pad char from '6' to '0' in generateHexColor - tighten nativeTokenIcons from Partial to Record so adding a chain to networks.config.ts surfaces a TS error on missing icon entry --- .../TokenLogo/TokenLogo.test.tsx | 5 +- .../sharedComponents/TokenLogo/index.tsx | 59 +++--- .../TokenLogo/nativeTokenIcons.tsx | 2 +- src/components/ui/provider.tsx | 200 +++++++++--------- 4 files changed, 128 insertions(+), 138 deletions(-) diff --git a/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx b/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx index b0e4e6cc..6a8f12a5 100644 --- a/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx +++ b/src/components/sharedComponents/TokenLogo/TokenLogo.test.tsx @@ -1,12 +1,11 @@ -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 '.' -const system = createSystem(defaultConfig) - const mockToken: Token = { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', chainId: 1, diff --git a/src/components/sharedComponents/TokenLogo/index.tsx b/src/components/sharedComponents/TokenLogo/index.tsx index 37e23b46..e2e0fc7a 100644 --- a/src/components/sharedComponents/TokenLogo/index.tsx +++ b/src/components/sharedComponents/TokenLogo/index.tsx @@ -1,5 +1,5 @@ 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' @@ -11,40 +11,31 @@ interface PlaceholderProps extends ComponentProps<'div'> { symbol: string } -const Placeholder: FC = ({ size, symbol, ...restProps }) => { - const [backgroundColor, setBackgroundColor] = useState('') - - 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 = ({ size, symbol, ...restProps }) => { + const backgroundColor = useMemo(() => generateHexColor(symbol), [symbol]) return ( > = { +export const nativeTokenIcons: Record = { 1: NetworkEthereum, 10: NetworkOptimism, 137: NetworkPolygon, diff --git a/src/components/ui/provider.tsx b/src/components/ui/provider.tsx index e9451067..1d6fa447 100644 --- a/src/components/ui/provider.tsx +++ b/src/components/ui/provider.tsx @@ -3,129 +3,129 @@ import { ChakraProvider, createSystem, defaultConfig, defineConfig } from '@chakra-ui/react' import { ColorModeProvider, type ColorModeProviderProps } from './color-mode' -export function Provider(props: ColorModeProviderProps) { - const customConfig = defineConfig({ - theme: { - // Use tokens for values that don't change with light / dark themes - tokens: { - fonts: { - body: { - value: '"Manrope", "Arial", "Helvetica Neue", "Helvetica", sans-serif', - }, - heading: { - value: '{fonts.body}', - }, - mono: { - value: '"Roboto Mono", "Courier New", monospace', - }, +const customConfig = defineConfig({ + theme: { + // Use tokens for values that don't change with light / dark themes + tokens: { + fonts: { + body: { + value: '"Manrope", "Arial", "Helvetica Neue", "Helvetica", sans-serif', + }, + heading: { + value: '{fonts.body}', + }, + mono: { + value: '"Roboto Mono", "Courier New", monospace', }, }, - // Use semantic tokens for light / dark values - semanticTokens: { - colors: { - bg: { - default: { - value: { - _light: '#f7f7f7', - _dark: '#292B43', - }, - }, - emphasized: { - value: { - _light: '#ccc', - _dark: '#888', - }, + }, + // Use semantic tokens for light / dark values + semanticTokens: { + colors: { + bg: { + default: { + value: { + _light: '#f7f7f7', + _dark: '#292B43', }, }, - primary: { - default: { - value: { - _light: '#692581', - _dark: '#8b46a4', - }, + emphasized: { + value: { + _light: '#ccc', + _dark: '#888', }, }, - text: { - default: { - value: { - _light: '#4b4d60', - _dark: '#e2e0e7', - }, + }, + primary: { + default: { + value: { + _light: '#692581', + _dark: '#8b46a4', }, }, - danger: { - default: { - value: { - _light: '#800', - _dark: '#ff6666', - }, + }, + text: { + default: { + value: { + _light: '#4b4d60', + _dark: '#e2e0e7', }, }, - ok: { - default: { - value: { - _light: '#006600', - _dark: '#66ee66', - }, + }, + danger: { + default: { + value: { + _light: '#800', + _dark: '#ff6666', }, }, - warning: { - default: { - value: { - _light: '#996600', - _dark: '#e6b800', - }, + }, + ok: { + default: { + value: { + _light: '#006600', + _dark: '#66ee66', }, }, }, - }, - // Some custom animations - keyframes: { - rotateSwitch: { - from: { - transform: 'rotate(0)', - }, - to: { - transform: 'rotate(360deg)', + warning: { + default: { + value: { + _light: '#996600', + _dark: '#e6b800', + }, }, }, }, }, - globalCss: { - ////////////////////////////////////////////////// - // Just some basic stuff, don't add too much here. - ////////////////////////////////////////////////// - html: { - scrollBehavior: 'smooth', - overflowX: 'hidden', - }, - body: { - '--moz-osx-font-smoothing': 'grayscale', - '--webkit-font-smoothing': 'antialiased', - background: '{colors.bg.default}', - backgroundPosition: '100% 0', - backgroundRepeat: 'no-repeat', - color: '{colors.text.default}', - fontFamily: '{fonts.body}', - lineHeight: 1.5, - outlineColor: '{colors.text.default}', - overflowX: 'hidden', - }, - code: { - fontFamily: '{fonts.mono}', - }, - a: { - color: '{colors.primary.default}', - }, - img: { - display: 'block', - maxInlineSize: '100%', + // Some custom animations + keyframes: { + rotateSwitch: { + from: { + transform: 'rotate(0)', + }, + to: { + transform: 'rotate(360deg)', + }, }, }, - }) + }, + globalCss: { + ////////////////////////////////////////////////// + // Just some basic stuff, don't add too much here. + ////////////////////////////////////////////////// + html: { + scrollBehavior: 'smooth', + overflowX: 'hidden', + }, + body: { + '--moz-osx-font-smoothing': 'grayscale', + '--webkit-font-smoothing': 'antialiased', + background: '{colors.bg.default}', + backgroundPosition: '100% 0', + backgroundRepeat: 'no-repeat', + color: '{colors.text.default}', + fontFamily: '{fonts.body}', + lineHeight: 1.5, + outlineColor: '{colors.text.default}', + overflowX: 'hidden', + }, + code: { + fontFamily: '{fonts.mono}', + }, + a: { + color: '{colors.primary.default}', + }, + img: { + display: 'block', + maxInlineSize: '100%', + }, + }, +}) - const system = createSystem(defaultConfig, customConfig) +export const system = createSystem(defaultConfig, customConfig) +export function Provider(props: ColorModeProviderProps) { return ( From 9ae8e55fc77ac399de6f50e4c7e7026e292a280b Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:38:34 -0300 Subject: [PATCH 4/5] docs(token-logo): correct native-token detection reference in JSDoc --- src/components/sharedComponents/TokenLogo/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/sharedComponents/TokenLogo/index.tsx b/src/components/sharedComponents/TokenLogo/index.tsx index e2e0fc7a..0fbcfcdd 100644 --- a/src/components/sharedComponents/TokenLogo/index.tsx +++ b/src/components/sharedComponents/TokenLogo/index.tsx @@ -70,10 +70,11 @@ interface TokenLogoProps { /** * TokenLogo component, displays a token logo based on the provided token object. * - * Native tokens (detected via `token.address === 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. + * 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. From 505e6efda4467388bc55637351b76c52359565bb Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:38:50 -0300 Subject: [PATCH 5/5] refactor(token-logo): type native icon map as Partial to model unmapped chains --- src/components/sharedComponents/TokenLogo/nativeTokenIcons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sharedComponents/TokenLogo/nativeTokenIcons.tsx b/src/components/sharedComponents/TokenLogo/nativeTokenIcons.tsx index da366a80..56c0849a 100644 --- a/src/components/sharedComponents/TokenLogo/nativeTokenIcons.tsx +++ b/src/components/sharedComponents/TokenLogo/nativeTokenIcons.tsx @@ -10,7 +10,7 @@ import { import type { ChainsIds } from '@/src/lib/networks.config' -export const nativeTokenIcons: Record = { +export const nativeTokenIcons: Partial> = { 1: NetworkEthereum, 10: NetworkOptimism, 137: NetworkPolygon,