diff --git a/apps/web/src/hooks/__tests__/useVisibleBalances.test.ts b/apps/web/src/hooks/__tests__/useVisibleBalances.test.ts index fcd82453a..cf72e1853 100644 --- a/apps/web/src/hooks/__tests__/useVisibleBalances.test.ts +++ b/apps/web/src/hooks/__tests__/useVisibleBalances.test.ts @@ -384,4 +384,68 @@ describe('useVisibleBalances', () => { expect(result.current.balances.items).toHaveLength(2) }) + + test('keeps tokens with unknown price (no oracle) even when hideDust is enabled', () => { + // On chains with no fiat price oracle the CGW returns fiatConversion "0" and + // fiatBalance "0" for every token (Lux/Zoo/Pars C-Chains). Such a token's fiat + // value is unknown, not zero, so it must not be filtered as dust — this is the + // Lux Foundation treasury case (~994.7B LUX, fiatConversion "0"). + const nativeAddress = toBeHex('0x0', 20) + const balance: Balances = { + fiatTotal: '0', + items: [ + { + balance: '994729897350342903214765220166', + fiatBalance: '0', + fiatConversion: '0', + tokenInfo: { + address: nativeAddress, + decimals: 18, + logoUri: '', + name: 'Lux', + symbol: 'LUX', + type: TokenType.NATIVE_TOKEN, + }, + }, + { + balance: '5000', + fiatBalance: '0', + fiatConversion: '0', + tokenInfo: { + address: visibleTokenAddress, + decimals: 18, + logoUri: '', + name: 'Unpriced ERC20', + symbol: 'UNP', + type: TokenType.ERC20, + }, + }, + ], + } + + jest.spyOn(useBalancesHooks, 'default').mockImplementation(() => ({ + balances: balance, + error: undefined, + loading: false, + loaded: true, + })) + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + settings: { + currency: 'USD', + shortName: { copy: true, qr: true, show: true }, + theme: { darkMode: false }, + hiddenTokens: { ['4']: [] }, + hideDust: true, + }, + chains: { data: [], error: undefined, loading: false, loaded: true }, + } as unknown as store.RootState), + ) + + const { result } = renderHook(() => useVisibleBalances()) + + expect(result.current.balances.items).toHaveLength(2) + expect(result.current.balances.items.map((i) => i.tokenInfo.symbol)).toEqual(['LUX', 'UNP']) + }) }) diff --git a/apps/web/src/hooks/useHiddenTokenCounts.ts b/apps/web/src/hooks/useHiddenTokenCounts.ts index b38ce17b7..df6df7a2d 100644 --- a/apps/web/src/hooks/useHiddenTokenCounts.ts +++ b/apps/web/src/hooks/useHiddenTokenCounts.ts @@ -5,7 +5,7 @@ import { useHasFeature } from './useChains' import useSafeInfo from './useSafeInfo' import { useBalancesGetBalancesV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/balances' import { FEATURES } from '@safe-global/utils/utils/chains' -import { DUST_THRESHOLD } from '@/config/constants' +import { filterDustTokens } from '@/utils/balances' import useBalances from './useBalances' import useHiddenTokens from './useHiddenTokens' @@ -14,11 +14,6 @@ export interface HiddenTokenCounts { hiddenByDustFilter: number } -const filterDustTokens = (items: ReturnType['balances']['items'], hideDust: boolean) => { - if (!hideDust) return items - return items.filter((balanceItem) => Number(balanceItem.fiatBalance) >= DUST_THRESHOLD) -} - export const useHiddenTokenCounts = (): HiddenTokenCounts => { const { balances: currentBalances } = useBalances() const hiddenTokens = useHiddenTokens() diff --git a/apps/web/src/hooks/useVisibleBalances.ts b/apps/web/src/hooks/useVisibleBalances.ts index 6e873ae26..cd524a1e4 100644 --- a/apps/web/src/hooks/useVisibleBalances.ts +++ b/apps/web/src/hooks/useVisibleBalances.ts @@ -5,7 +5,7 @@ import useHiddenTokens from './useHiddenTokens' import type { PortfolioBalances } from './loadables/useLoadBalances' import { useAppSelector } from '@/store' import { selectHideDust } from '@/store/settingsSlice' -import { DUST_THRESHOLD } from '@/config/constants' +import { filterDustTokens } from '@/utils/balances' import useSafeInfo from './useSafeInfo' import { useNativeTokenDisplay } from './useNativeTokenDisplay' import { TokenType } from '@safe-global/store/gateway/types' @@ -29,11 +29,6 @@ const truncateNumber = (balance: string): string => { const filterHiddenTokens = (items: PortfolioBalances['items'], hiddenAssets: string[]) => items.filter((balanceItem) => !hiddenAssets.includes(balanceItem.tokenInfo.address)) -const filterDustTokens = (items: PortfolioBalances['items'], hideDust: boolean) => { - if (!hideDust) return items - return items.filter((balanceItem) => Number(balanceItem.fiatBalance) >= DUST_THRESHOLD) -} - const getVisibleFiatTotal = (balances: PortfolioBalances, hiddenAssets: string[]): string => { return safeFormatUnits( balances.items diff --git a/apps/web/src/utils/balances.ts b/apps/web/src/utils/balances.ts new file mode 100644 index 000000000..f32659743 --- /dev/null +++ b/apps/web/src/utils/balances.ts @@ -0,0 +1,29 @@ +import { DUST_THRESHOLD } from '@/config/constants' +import { TokenType } from '@safe-global/store/gateway/types' + +type DustFilterableItem = { + fiatBalance: string + fiatConversion: string + tokenInfo: { type: string } +} + +/** + * Hide "dust" — tokens worth less than DUST_THRESHOLD in the selected fiat currency. + * + * A token can only be dust if we can actually price it. On chains with no fiat + * price oracle the Client Gateway returns `fiatConversion: "0"` (e.g. the + * Lux/Zoo/Pars C-Chains), which makes `fiatBalance` always "0" too. That means + * the fiat value is UNKNOWN, not zero — filtering on it would wrongly hide the + * entire balance, however large (the Lux Foundation treasury holds ~994.7B LUX). + * So a token is kept when it is the native token, when its price is unknown, or + * when its priced value clears the threshold. Only genuinely-priced dust is hidden. + */ +export const filterDustTokens = (items: T[], hideDust: boolean): T[] => { + if (!hideDust) return items + return items.filter( + (item) => + item.tokenInfo.type === TokenType.NATIVE_TOKEN || + !(Number(item.fiatConversion) > 0) || + Number(item.fiatBalance) >= DUST_THRESHOLD, + ) +}