fix(balances): don't hide unknown-price tokens as dust (renders Lux treasury)

The dust filter hid any token with fiatBalance < $0.01. On chains with no
fiat price oracle the Client Gateway returns fiatConversion "0" (and thus
fiatBalance "0") for every token — Lux/Zoo/Pars C-Chains — so the filter
wrongly hid the entire native balance. safe.lux.network showed the Lux
Foundation treasury (0xE54cAf7E, ~994.7B LUX) as an empty "1 small balance"
instead of its real holdings.

A token is only dust if we can price it. Keep tokens whose price is unknown
(fiatConversion <= 0) and the native token; only hide genuinely-priced dust.

Decomplected: the two duplicate filterDustTokens copies (useVisibleBalances,
useHiddenTokenCounts) are now one price-aware helper in utils/balances.ts, so
the assets table and the "N small balances" indicator can never disagree.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-21 16:58:02 -07:00
co-authored by Hanzo Dev
parent 54a4e86232
commit 5fa0830cf0
4 changed files with 95 additions and 12 deletions
@@ -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'])
})
})
+1 -6
View File
@@ -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<typeof useBalances>['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()
+1 -6
View File
@@ -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
+29
View File
@@ -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 = <T extends DustFilterableItem>(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,
)
}