Skip to content

Commit 1ad7b83

Browse files
committed
Fix comments and organise helpers
1 parent 98e590d commit 1ad7b83

2 files changed

Lines changed: 230 additions & 58 deletions

File tree

app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,4 +1056,128 @@ describe('useAccountTokens', () => {
10561056
expect(mockFormatFiat).not.toHaveBeenCalled();
10571057
});
10581058
});
1059+
1060+
describe('sort key follows displayed fiat', () => {
1061+
it('sorts by derived fiat (balance * rate), not asset.fiat.balance', () => {
1062+
const stablecoinLikeAssets = {
1063+
'0x1': [
1064+
{
1065+
chainId: '0x1',
1066+
address: '0xstable',
1067+
accountType: 'eip155:1/erc20:0xstable',
1068+
balance: '1000',
1069+
fiat: { balance: '998' },
1070+
rawBalance: '0x1234',
1071+
symbol: 'USDC',
1072+
},
1073+
{
1074+
chainId: '0x1',
1075+
address: '0xvolatile',
1076+
accountType: 'eip155:1/erc20:0xvolatile',
1077+
balance: '10',
1078+
fiat: { balance: '999' },
1079+
rawBalance: '0x5678',
1080+
symbol: 'VOL',
1081+
},
1082+
],
1083+
};
1084+
1085+
mockSelectAssetsBySelectedAccountGroup.mockReturnValue(
1086+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1087+
stablecoinLikeAssets as any,
1088+
);
1089+
mockUseSelector.mockImplementation((selector) => {
1090+
if (selector === selectAssetsBySelectedAccountGroup) {
1091+
return stablecoinLikeAssets;
1092+
}
1093+
return undefined;
1094+
});
1095+
// USDC rate = 1 (stablecoin bypass shape), VOL rate = 99.9.
1096+
// Derived fiat: USDC=1000*1=1000, VOL=10*99.9=999.
1097+
// asset.fiat.balance would sort VOL(999) above USDC(998).
1098+
// Correct sort by derived fiat puts USDC(1000) first.
1099+
useTokenFiatRatesMock.mockReturnValue([1, 99.9]);
1100+
1101+
const { result } = renderHook(() => useAccountTokens());
1102+
1103+
expect(result.current[0].symbol).toBe('USDC');
1104+
expect(result.current[1].symbol).toBe('VOL');
1105+
});
1106+
});
1107+
1108+
describe('zero balance with missing rate', () => {
1109+
it('renders $0 for an EVM zero-balance token even when useTokenFiatRates returns undefined', () => {
1110+
const zeroBalanceAssets = {
1111+
'0x1': [
1112+
{
1113+
chainId: '0x1',
1114+
address: '0xtoken1',
1115+
accountType: 'eip155:1/erc20:0xtoken1',
1116+
balance: '0',
1117+
fiat: { balance: '0' },
1118+
rawBalance: '0x0',
1119+
symbol: 'TOKEN1',
1120+
},
1121+
],
1122+
};
1123+
1124+
mockSelectAssetsBySelectedAccountGroup.mockReturnValue(
1125+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1126+
zeroBalanceAssets as any,
1127+
);
1128+
mockUseSelector.mockImplementation((selector) => {
1129+
if (selector === selectAssetsBySelectedAccountGroup) {
1130+
return zeroBalanceAssets;
1131+
}
1132+
return undefined;
1133+
});
1134+
useTokenFiatRatesMock.mockReturnValue([undefined]);
1135+
mockFormatFiat.mockImplementation((v) =>
1136+
v === undefined ? undefined : `formatted:${String(v)}`,
1137+
);
1138+
1139+
const { result } = renderHook(() =>
1140+
useAccountTokens({ includeNoBalance: true }),
1141+
);
1142+
1143+
const token = result.current.find((a) => a.symbol === 'TOKEN1');
1144+
expect(token?.balanceInSelectedCurrency).toBe('formatted:0');
1145+
});
1146+
1147+
it('still hides fiat for a non-zero EVM balance when rate is missing', () => {
1148+
const nonZeroAssets = {
1149+
'0x1': [
1150+
{
1151+
chainId: '0x1',
1152+
address: '0xtoken1',
1153+
accountType: 'eip155:1/erc20:0xtoken1',
1154+
balance: '10',
1155+
fiat: { balance: '10' },
1156+
rawBalance: '0x1234',
1157+
symbol: 'TOKEN1',
1158+
},
1159+
],
1160+
};
1161+
1162+
mockSelectAssetsBySelectedAccountGroup.mockReturnValue(
1163+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1164+
nonZeroAssets as any,
1165+
);
1166+
mockUseSelector.mockImplementation((selector) => {
1167+
if (selector === selectAssetsBySelectedAccountGroup) {
1168+
return nonZeroAssets;
1169+
}
1170+
return undefined;
1171+
});
1172+
useTokenFiatRatesMock.mockReturnValue([undefined]);
1173+
mockFormatFiat.mockImplementation((v) =>
1174+
v === undefined ? undefined : `formatted:${String(v)}`,
1175+
);
1176+
1177+
const { result } = renderHook(() => useAccountTokens());
1178+
1179+
const token = result.current.find((a) => a.symbol === 'TOKEN1');
1180+
expect(token?.balanceInSelectedCurrency).toBeUndefined();
1181+
});
1182+
});
10591183
});

app/components/Views/confirmations/hooks/send/useAccountTokens.ts

Lines changed: 106 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Asset } from '@metamask/assets-controllers';
12
import { useSelector } from 'react-redux';
23
import { useCallback, useMemo } from 'react';
34
import { BigNumber } from 'bignumber.js';
@@ -32,30 +33,6 @@ export interface EnrichTokenRequest {
3233

3334
const EMPTY_REQUESTS: EnrichTokenRequest[] = [];
3435

35-
function useAccountGroupAssets(accountAddress?: string | null) {
36-
const internalAccountsById = useSelector(selectInternalAccountsById);
37-
const accountToGroupMap = useSelector(selectAccountToGroupMap);
38-
39-
const accountGroupId = useMemo(() => {
40-
if (!accountAddress) return undefined;
41-
const internalAccountId = Object.keys(internalAccountsById).find(
42-
(id) =>
43-
internalAccountsById[id].address.toLowerCase() ===
44-
accountAddress.toLowerCase(),
45-
);
46-
if (!internalAccountId) return undefined;
47-
return accountToGroupMap[internalAccountId]?.id;
48-
}, [accountAddress, internalAccountsById, accountToGroupMap]);
49-
50-
const selectOverrideAssets = useCallback(
51-
(state: RootState) => selectAssetsByAccountGroupId(state, accountGroupId),
52-
[accountGroupId],
53-
);
54-
55-
const overrideAssets = useSelector(selectOverrideAssets);
56-
return accountGroupId ? overrideAssets : undefined;
57-
}
58-
5936
export function useAccountTokens({
6037
includeNoBalance = false,
6138
tokenFilter,
@@ -111,18 +88,11 @@ export function useAccountTokens({
11188
});
11289
}, [assets, includeNoBalance, tokenFilter]);
11390

114-
// useTokenFiatRates crashes on non-hex addresses (Solana etc.), so we
115-
// build requests for EVM assets only. Non-EVM assets are handled below in
116-
// the render memo by falling back to `asset.fiat.balance`.
11791
const fiatRateRequests = useMemo<TokenFiatRateRequest[]>(
11892
() =>
11993
assetsWithBalance
120-
.filter((asset) => asset.chainId && !isNonEvmChainId(asset.chainId))
121-
.map((asset) => ({
122-
address: asset.address as Hex,
123-
chainId: asset.chainId as Hex,
124-
currency: fiatCurrency.toLowerCase(),
125-
})),
94+
.filter(isEvmRateEligible)
95+
.map((asset) => buildFiatRateRequest(asset, fiatCurrency)),
12696
[assetsWithBalance, fiatCurrency],
12797
);
12898

@@ -146,38 +116,30 @@ export function useAccountTokens({
146116
Boolean(chainId) &&
147117
isNetworkTestnet(chainId as string);
148118

149-
// EVM assets use the useTokenFiatRates-derived rate (picks up stablecoin
150-
// bypass, and lets us convert to USD in pay flow). Non-EVM assets fall
151-
// back to the assets-controller's preferred-currency `fiat.balance`,
152-
// which is the pre-existing behavior for Solana/Bitcoin/etc.
153-
// EVM assets and rates are in lockstep — both arrays derived from the
154-
// same predicate over the same list, so a single index walk pairs them.
119+
const sortableFiatByAsset = new WeakMap<AssetType, BigNumber>();
155120
let evmIndex = 0;
156121
const processedAssets = assetsWithBalance.map((asset) => {
157-
const isEvm = asset.chainId && !isNonEvmChainId(asset.chainId);
158-
const rate = isEvm ? fiatRates[evmIndex++] : undefined;
159-
160-
let fiatAmount: BigNumber.Value | undefined;
161-
if (isFiatHidden(asset.chainId)) {
162-
fiatAmount = undefined;
163-
} else if (isEvm) {
164-
fiatAmount =
165-
rate !== undefined && asset.balance
166-
? new BigNumber(asset.balance).multipliedBy(rate)
167-
: undefined;
168-
} else {
169-
fiatAmount = asset.fiat?.balance;
170-
}
122+
const rate = isEvmRateEligible(asset) ? fiatRates[evmIndex++] : undefined;
123+
const fiatAmount = deriveAssetFiat(
124+
asset,
125+
rate,
126+
isFiatHidden(asset.chainId),
127+
);
171128

172129
const balanceInSelectedCurrency =
173130
fiatAmount !== undefined ? formatFiat(fiatAmount) : undefined;
174131

175-
return {
132+
const processed = {
176133
...asset,
177134
networkBadgeSource: getNetworkBadgeSource(asset.chainId as Hex),
178135
balanceInSelectedCurrency,
179136
standard: TokenStandard.ERC20 as const,
180137
} as AssetType;
138+
139+
if (fiatAmount !== undefined) {
140+
sortableFiatByAsset.set(processed, fiatAmount);
141+
}
142+
return processed;
181143
});
182144

183145
if (enrichTokenRequests.length > 0) {
@@ -218,10 +180,16 @@ export function useAccountTokens({
218180
}
219181
}
220182

221-
const sortableFiatBalance = (asset: AssetType) =>
222-
isFiatHidden(asset.chainId)
223-
? new BigNumber(0)
224-
: new BigNumber(asset.fiat?.balance || 0);
183+
// Sort by the same fiat we display. Falls back to the assets-controller
184+
// preferred-currency balance for tokens that have no derived fiat (e.g.
185+
// enrichment placeholders added below with `zeroFiat`), so unknown
186+
// tokens sink to the bottom.
187+
const sortableFiatBalance = (asset: AssetType) => {
188+
if (isFiatHidden(asset.chainId)) return new BigNumber(0);
189+
const derived = sortableFiatByAsset.get(asset);
190+
if (derived !== undefined) return derived;
191+
return new BigNumber(asset.fiat?.balance || 0);
192+
};
225193

226194
return processedAssets.sort(
227195
(a, b) => sortableFiatBalance(b).comparedTo(sortableFiatBalance(a)) || 0,
@@ -236,3 +204,83 @@ export function useAccountTokens({
236204
fiatRates,
237205
]) as unknown as AssetType[];
238206
}
207+
208+
function useAccountGroupAssets(accountAddress?: string | null) {
209+
const internalAccountsById = useSelector(selectInternalAccountsById);
210+
const accountToGroupMap = useSelector(selectAccountToGroupMap);
211+
212+
const accountGroupId = useMemo(() => {
213+
if (!accountAddress) return undefined;
214+
const internalAccountId = Object.keys(internalAccountsById).find(
215+
(id) =>
216+
internalAccountsById[id].address.toLowerCase() ===
217+
accountAddress.toLowerCase(),
218+
);
219+
if (!internalAccountId) return undefined;
220+
return accountToGroupMap[internalAccountId]?.id;
221+
}, [accountAddress, internalAccountsById, accountToGroupMap]);
222+
223+
const selectOverrideAssets = useCallback(
224+
(state: RootState) => selectAssetsByAccountGroupId(state, accountGroupId),
225+
[accountGroupId],
226+
);
227+
228+
const overrideAssets = useSelector(selectOverrideAssets);
229+
return accountGroupId ? overrideAssets : undefined;
230+
}
231+
232+
/**
233+
* Whether the asset is EVM-scoped with a hex address, i.e. eligible for
234+
* `useTokenFiatRates` (which crashes on non-hex addresses like Solana).
235+
* Non-EVM assets fall back to `asset.fiat.balance` in the render loop.
236+
*
237+
* This predicate is the single source of truth for the paired walks over
238+
* `assetsWithBalance` — request build and rate consumption — so they cannot
239+
* drift out of lockstep.
240+
*/
241+
function isEvmRateEligible(asset: Asset): boolean {
242+
return (
243+
Boolean(asset.chainId) &&
244+
!isNonEvmChainId(asset.chainId) &&
245+
'address' in asset &&
246+
Boolean(asset.address)
247+
);
248+
}
249+
250+
function buildFiatRateRequest(
251+
asset: Asset,
252+
currency: string,
253+
): TokenFiatRateRequest {
254+
return {
255+
address: (asset as { address: Hex }).address,
256+
chainId: asset.chainId as Hex,
257+
currency: currency.toLowerCase(),
258+
};
259+
}
260+
261+
/**
262+
* Fiat amount to display for a single asset, or `undefined` to hide.
263+
*
264+
* - Testnet-hidden → undefined.
265+
* - EVM with rate → `balance * rate` (picks up stablecoin bypass).
266+
* - EVM zero balance without rate → `0` (currency-invariant, avoids
267+
* hiding `$0` rows when market data hasn't loaded).
268+
* - EVM non-zero without rate → undefined (can't safely render).
269+
* - Non-EVM → the assets-controller's preferred-currency `fiat.balance`.
270+
*/
271+
function deriveAssetFiat(
272+
asset: Asset,
273+
rate: number | undefined,
274+
isFiatHidden: boolean,
275+
): BigNumber | undefined {
276+
if (isFiatHidden) return undefined;
277+
278+
if (isEvmRateEligible(asset)) {
279+
const balance = asset.balance ? new BigNumber(asset.balance) : undefined;
280+
if (rate !== undefined && balance) return balance.multipliedBy(rate);
281+
if (balance?.isZero()) return new BigNumber(0);
282+
return undefined;
283+
}
284+
285+
return asset.fiat?.balance ? new BigNumber(asset.fiat.balance) : undefined;
286+
}

0 commit comments

Comments
 (0)