Skip to content

Commit cee0d6b

Browse files
Merge branch 'main' into cferreira/fix-selectors-location
2 parents 2060c3f + 4b451ed commit cee0d6b

29 files changed

+7103
-5724
lines changed

app/components/UI/Bridge/hooks/useLatestBalance/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,21 @@ export const useLatestBalance = (token: {
142142
displayBalance,
143143
atomicBalance: parseUnits(displayBalance, token.decimals),
144144
});
145+
} else {
146+
setBalance({
147+
displayBalance: '0',
148+
atomicBalance: parseUnits('0'),
149+
});
145150
}
146151
}
147152
}, [token.address, token.decimals, chainId, selectedAddress, nonEvmTokens]);
148153

149154
useEffect(() => {
155+
// Reset balance state when token address changes to prevent stale balance display
156+
if (previousToken?.address !== token.address) {
157+
setBalance(undefined);
158+
}
159+
150160
// In case chainId is undefined, exit early to avoid
151161
// calling handleFetchEvmAtomicBalance which will trigger an invalid address error
152162
// when selectedAddress is a non-EVM chain.
@@ -161,7 +171,13 @@ export const useLatestBalance = (token: {
161171
if (isCaipChainId(chainId) && isNonEvmChainId(chainId)) {
162172
handleNonEvmAtomicBalance();
163173
}
164-
}, [handleFetchEvmAtomicBalance, handleNonEvmAtomicBalance, chainId]);
174+
}, [
175+
handleFetchEvmAtomicBalance,
176+
handleNonEvmAtomicBalance,
177+
chainId,
178+
previousToken?.address,
179+
token.address,
180+
]);
165181

166182
const cachedBalance = useMemo(() => {
167183
const displayBalance = token.balance;

app/components/UI/Bridge/hooks/useLatestBalance/useLatestBalance.test.tsx

Lines changed: 237 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { useLatestBalance } from '.';
99
import { getProviderByChainId } from '../../../../../util/notifications/methods/common';
1010
import { BigNumber, constants } from 'ethers';
1111
import { waitFor } from '@testing-library/react-native';
12-
import { Hex } from '@metamask/utils';
12+
import { Hex, type CaipAssetId } from '@metamask/utils';
1313
import { SolScope } from '@metamask/keyring-api';
1414
import { cloneDeep } from 'lodash';
1515

@@ -366,12 +366,13 @@ describe('useLatestBalance', () => {
366366
{ state: initialState },
367367
);
368368

369-
// Should not call EVM provider methods when chainId is CAIP (Solana)
370-
expect(getProviderByChainId).not.toHaveBeenCalled();
371-
expect(mockProvider.getBalance).not.toHaveBeenCalled();
372-
expect(result.current).toEqual({
373-
displayBalance: '50.0',
374-
atomicBalance: BigNumber.from('50000000000000000000'),
369+
// When EVM address is used with non-EVM chainId, it tries to find it in nonEvmTokens
370+
// Since it's not found, it sets balance to 0
371+
await waitFor(() => {
372+
expect(result.current).toEqual({
373+
displayBalance: '0',
374+
atomicBalance: BigNumber.from('0'),
375+
});
375376
});
376377
});
377378

@@ -661,4 +662,233 @@ describe('useLatestBalance', () => {
661662
});
662663
});
663664
});
665+
666+
describe('balance reset when token address changes', () => {
667+
it('resets balance to undefined when token address changes', async () => {
668+
let tokenAddress = '0x1234567890123456789012345678901234567890';
669+
670+
const { result, rerender } = renderHookWithProvider(
671+
() =>
672+
useLatestBalance({
673+
address: tokenAddress,
674+
decimals: 6,
675+
chainId: '0x1' as Hex,
676+
balance: '10.0',
677+
}),
678+
{ state: initialState },
679+
);
680+
681+
await waitFor(() => {
682+
expect(result.current?.displayBalance).toBe('1.0');
683+
});
684+
685+
tokenAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd';
686+
rerender({ state: initialState });
687+
688+
expect(result.current?.displayBalance).toBe('10.0');
689+
});
690+
691+
it('returns cached balance immediately after token address changes', async () => {
692+
let tokenAddress = '0x1111111111111111111111111111111111111111';
693+
let tokenBalance = '100.5';
694+
695+
const { result, rerender } = renderHookWithProvider(
696+
() =>
697+
useLatestBalance({
698+
address: tokenAddress,
699+
decimals: 6,
700+
chainId: '0x1' as Hex,
701+
balance: tokenBalance,
702+
}),
703+
{ state: initialState },
704+
);
705+
706+
expect(result.current?.displayBalance).toBe('100.5');
707+
708+
tokenAddress = '0x2222222222222222222222222222222222222222';
709+
tokenBalance = '250.75';
710+
rerender({ state: initialState });
711+
712+
expect(result.current?.displayBalance).toBe('250.75');
713+
expect(result.current?.atomicBalance).toEqual(
714+
BigNumber.from('250750000'),
715+
);
716+
});
717+
718+
it('fetches new balance after token address changes', async () => {
719+
let tokenAddress = constants.AddressZero;
720+
721+
const { result, rerender } = renderHookWithProvider(
722+
() =>
723+
useLatestBalance({
724+
address: tokenAddress,
725+
decimals: 18,
726+
chainId: '0x1' as Hex,
727+
}),
728+
{ state: initialState },
729+
);
730+
731+
await waitFor(() => {
732+
expect(result.current?.displayBalance).toBe('1.0');
733+
});
734+
735+
jest.clearAllMocks();
736+
737+
tokenAddress = '0x9999999999999999999999999999999999999999';
738+
rerender({ state: initialState });
739+
740+
await waitFor(() => {
741+
expect(getProviderByChainId).toHaveBeenCalled();
742+
});
743+
});
744+
745+
it('resets balance state before fetching new balance for different token', async () => {
746+
let tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
747+
let tokenBalance = '50.0';
748+
749+
const { result, rerender } = renderHookWithProvider(
750+
() =>
751+
useLatestBalance({
752+
address: tokenAddress,
753+
decimals: 6,
754+
chainId: '0x1' as Hex,
755+
balance: tokenBalance,
756+
}),
757+
{ state: initialState },
758+
);
759+
760+
await waitFor(() => {
761+
expect(result.current?.displayBalance).toBe('1.0');
762+
});
763+
764+
const previousBalance = result.current;
765+
766+
tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
767+
tokenBalance = '75.0';
768+
rerender({ state: initialState });
769+
770+
expect(result.current?.displayBalance).toBe('75.0');
771+
expect(result.current).not.toBe(previousBalance);
772+
});
773+
});
774+
775+
describe('non-EVM token not found in balance controller', () => {
776+
it('sets balance to 0 when Solana token is not found in nonEvmTokens', async () => {
777+
const state = cloneDeep(initialState);
778+
state.engine.backgroundState.AccountsController.internalAccounts.selectedAccount =
779+
solanaAccountId;
780+
781+
const { result } = renderHookWithProvider(
782+
() =>
783+
useLatestBalance({
784+
chainId: SolScope.Mainnet,
785+
address: 'TokenNotInBalanceController123',
786+
decimals: 9,
787+
}),
788+
{ state },
789+
);
790+
791+
await waitFor(() => {
792+
expect(result.current).toEqual({
793+
displayBalance: '0',
794+
atomicBalance: BigNumber.from('0'),
795+
});
796+
});
797+
});
798+
799+
it('sets balance to 0 when Solana token is not found in nonEvmTokens list', async () => {
800+
const state = cloneDeep(initialState);
801+
state.engine.backgroundState.AccountsController.internalAccounts.selectedAccount =
802+
solanaAccountId;
803+
804+
const nonExistentTokenAddress =
805+
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:NonExistentToken123' as CaipAssetId;
806+
807+
const { result } = renderHookWithProvider(
808+
() =>
809+
useLatestBalance({
810+
chainId: SolScope.Mainnet,
811+
address: nonExistentTokenAddress,
812+
decimals: 9,
813+
}),
814+
{ state },
815+
);
816+
817+
await waitFor(() => {
818+
expect(result.current).toEqual({
819+
displayBalance: '0',
820+
atomicBalance: BigNumber.from('0'),
821+
});
822+
});
823+
});
824+
825+
it('sets balance to 0 when non-EVM token has undefined balance in controller', async () => {
826+
const state = cloneDeep(initialState);
827+
state.engine.backgroundState.AccountsController.internalAccounts.selectedAccount =
828+
solanaAccountId;
829+
830+
const { result } = renderHookWithProvider(
831+
() =>
832+
useLatestBalance({
833+
chainId: SolScope.Mainnet,
834+
address: 'UnknownSolanaToken456',
835+
decimals: 6,
836+
}),
837+
{ state },
838+
);
839+
840+
await waitFor(() => {
841+
expect(result.current).toEqual({
842+
displayBalance: '0',
843+
atomicBalance: BigNumber.from('0'),
844+
});
845+
});
846+
});
847+
848+
it('returns valid balance when Solana token is found in nonEvmTokens', async () => {
849+
const state = cloneDeep(initialState);
850+
state.engine.backgroundState.AccountsController.internalAccounts.selectedAccount =
851+
solanaAccountId;
852+
853+
const { result } = renderHookWithProvider(
854+
() =>
855+
useLatestBalance({
856+
chainId: SolScope.Mainnet,
857+
address: solanaToken2Address,
858+
decimals: 6,
859+
}),
860+
{ state },
861+
);
862+
863+
await waitFor(() => {
864+
expect(result.current).toEqual({
865+
displayBalance: '20000.456',
866+
atomicBalance: BigNumber.from('20000456000'),
867+
});
868+
});
869+
});
870+
871+
it('handles empty balance string from controller by setting to 0', async () => {
872+
const state = cloneDeep(initialState);
873+
state.engine.backgroundState.AccountsController.internalAccounts.selectedAccount =
874+
solanaAccountId;
875+
876+
const { result } = renderHookWithProvider(
877+
() =>
878+
useLatestBalance({
879+
chainId: SolScope.Mainnet,
880+
address: 'TokenWithEmptyBalance789',
881+
decimals: 9,
882+
}),
883+
{ state },
884+
);
885+
886+
await waitFor(() => {
887+
expect(result.current).toEqual({
888+
displayBalance: '0',
889+
atomicBalance: BigNumber.from('0'),
890+
});
891+
});
892+
});
893+
});
664894
});

app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,14 +338,14 @@ const PerpsPositionCard: React.FC<PerpsPositionCardProps> = ({
338338

339339
if (hasTakeProfit && takeProfitPrice) {
340340
const tpPrice = formatPerpsFiat(parseFloat(takeProfitPrice), {
341-
ranges: PRICE_RANGES_MINIMAL_VIEW,
341+
ranges: PRICE_RANGES_UNIVERSAL,
342342
});
343343
parts.push(`${strings('perps.order.tp')} ${tpPrice}`);
344344
}
345345

346346
if (hasStopLoss && stopLossPrice) {
347347
const slPrice = formatPerpsFiat(parseFloat(stopLossPrice), {
348-
ranges: PRICE_RANGES_MINIMAL_VIEW,
348+
ranges: PRICE_RANGES_UNIVERSAL,
349349
});
350350
parts.push(`${strings('perps.order.sl')} ${slPrice}`);
351351
}

app/components/UI/Perps/constants/perpsConfig.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,9 @@ export const DECIMAL_PRECISION_CONFIG = {
334334
// Maximum decimal places for price input (matches Hyperliquid limit)
335335
// Used in TP/SL forms, limit price inputs, and price validation
336336
MAX_PRICE_DECIMALS: 6,
337+
// Maximum significant figures allowed by HyperLiquid API
338+
// Orders with more than 5 significant figures will be rejected
339+
MAX_SIGNIFICANT_FIGURES: 5,
337340
// Defensive fallback for size decimals when market data fails to load
338341
// Real szDecimals should always come from market data API (varies by asset)
339342
// Using 6 as safe maximum to prevent crashes (covers most assets)

app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ jest.mock('../utils/formatUtils', () => ({
3030
formatPerpsFiat: (price: string) => price, // Simple pass-through for testing
3131
PRICE_RANGES_UNIVERSAL: {},
3232
PRICE_RANGES_MINIMAL_VIEW: {},
33+
// Include significant figures utilities (re-exported via tpslValidation)
34+
countSignificantFigures: jest.requireActual('../utils/formatUtils')
35+
.countSignificantFigures,
36+
hasExceededSignificantFigures: jest.requireActual('../utils/formatUtils')
37+
.hasExceededSignificantFigures,
38+
roundToSignificantFigures: jest.requireActual('../utils/formatUtils')
39+
.roundToSignificantFigures,
3340
}));
3441

3542
// Mock i18n strings
@@ -219,10 +226,11 @@ describe('usePerpsTPSLForm', () => {
219226
});
220227

221228
act(() => {
222-
result.current.handlers.handleTakeProfitPriceChange('55000.50abc');
229+
// Use 5000.50 (5 sig figs) instead of 55000.50 (6 sig figs) to stay within limit
230+
result.current.handlers.handleTakeProfitPriceChange('5000.50abc');
223231
});
224232

225-
expect(result.current.formState.takeProfitPrice).toBe('55000.50');
233+
expect(result.current.formState.takeProfitPrice).toBe('5000.50');
226234
});
227235

228236
it('prevent multiple decimal points in price input', () => {

0 commit comments

Comments
 (0)