Skip to content

Commit eb416a9

Browse files
CP - 13365 - fix for imported PK loading balance issues (#3581)
1 parent a28be83 commit eb416a9

File tree

5 files changed

+433
-65
lines changed

5 files changed

+433
-65
lines changed

packages/core-mobile/app/new/features/portfolio/hooks/useAllBalances.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { QueryObserverResult } from '@tanstack/react-query'
22
import { useSelector } from 'react-redux'
33
import { AdjustedNormalizedBalancesForAccounts } from 'services/balance/types'
4-
import { selectAccounts } from 'store/account'
4+
import { selectAccountsArray } from 'store/account'
55
import { useAccountsBalances } from './useAccountsBalances'
66

77
/**
@@ -20,7 +20,11 @@ export function useAllBalances(options?: {
2020
QueryObserverResult<AdjustedNormalizedBalancesForAccounts, Error>
2121
>
2222
} {
23-
const allAccounts = useSelector(selectAccounts)
23+
// Use the memoized selector so the array reference is stable between
24+
// renders. Object.values(selectAccounts) created a new array every render,
25+
// which made the useEffect in useAccountsBalances fire on every render,
26+
// flooding queryClient.setQueryData and causing a render storm / freeze.
27+
const allAccounts = useSelector(selectAccountsArray)
2428

25-
return useAccountsBalances(Object.values(allAccounts), options)
29+
return useAccountsBalances(allAccounts, options)
2630
}

packages/core-mobile/app/new/features/portfolio/hooks/useIsLoadingBalancesForWallet.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
import { useSelector } from 'react-redux'
2-
import { selectAccountsByWalletId } from 'store/account'
2+
import { selectAccountsByWalletId, selectImportedAccounts } from 'store/account'
33
import { RootState } from 'store/types'
44
import { Wallet } from 'store/wallet/types'
5+
import { IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID } from 'features/wallets/consts'
56
import { useAllBalances } from './useAllBalances'
67

78
/**
89
* Returns true if balances are currently loading for the given wallet.
910
* Checks if any account within the wallet is missing balance data.
1011
*/
1112
export const useIsLoadingBalancesForWallet = (wallet?: Wallet): boolean => {
12-
const accounts = useSelector((state: RootState) =>
13+
const accountsForWallet = useSelector((state: RootState) =>
1314
selectAccountsByWalletId(state, wallet?.id ?? '')
1415
)
16+
const importedAccounts = useSelector(selectImportedAccounts)
17+
18+
// The virtual "Imported" wallet groups all private-key accounts under one
19+
// card, but those accounts have real wallet UUIDs — not the virtual ID.
20+
const accounts =
21+
wallet?.id === IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID
22+
? importedAccounts
23+
: accountsForWallet
24+
1525
const { data } = useAllBalances()
1626

1727
if (!wallet || accounts.length === 0) return true

packages/core-mobile/app/new/features/portfolio/hooks/useWalletBalances.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { QueryObserverResult } from '@tanstack/react-query'
22
import { useMemo } from 'react'
33
import { useSelector } from 'react-redux'
44
import { AdjustedNormalizedBalancesForAccounts } from 'services/balance/types'
5-
import { selectAccountsByWalletId } from 'store/account'
5+
import { selectAccountsByWalletId, selectImportedAccounts } from 'store/account'
66
import { RootState } from 'store/types'
77
import { Wallet } from 'store/wallet/types'
8+
import { IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID } from 'new/features/wallets/consts'
89
import { useAllBalances } from './useAllBalances'
910

1011
/**
@@ -25,9 +26,19 @@ export const useWalletBalances = (
2526
QueryObserverResult<AdjustedNormalizedBalancesForAccounts, Error>
2627
>
2728
} => {
28-
const accounts = useSelector((state: RootState) =>
29+
const isVirtualImportedWallet =
30+
wallet?.id === IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID
31+
32+
// For the virtual "Imported" wallet, imported accounts have their own
33+
// real walletId — not the virtual ID — so selectAccountsByWalletId
34+
// would return an empty array. Fall back to selectImportedAccounts.
35+
const accountsByWalletId = useSelector((state: RootState) =>
2936
selectAccountsByWalletId(state, wallet?.id ?? '')
3037
)
38+
const importedAccounts = useSelector(selectImportedAccounts)
39+
const accounts = isVirtualImportedWallet
40+
? importedAccounts
41+
: accountsByWalletId
3142

3243
const {
3344
data: allAccountsBalances,

packages/core-mobile/app/services/balance/BalanceService.test.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ const testAccount2: Account = {
125125
addressPVM: 'P-avax1bbhxdv3wqxd42rxdalvp2knxs244r06wrxmvlg'
126126
}
127127

128+
// Imported PK account that shares the same EVM address as testAccount
129+
// (simulates exporting PK from mnemonic and re-importing it)
130+
const importedAccount: Account = {
131+
name: 'Imported Account',
132+
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
133+
walletId: 'imported-wallet-id',
134+
index: 0,
135+
type: CoreAccountType.IMPORTED as CoreAccountType.IMPORTED,
136+
addressC: '0x066b2322a30d7C5838035112F3b816b46D639bBC', // same as testAccount
137+
addressCoreEth: '0x066b2322a30d7C5838035112F3b816b46D639bBC',
138+
addressBTC: 'bc1qdifferentbtcaddressforimportedpkaccount',
139+
addressSVM: 'DifferentSolAddressForImportedPkAccount1234567',
140+
addressAVM: 'X-avax1differentavmaddressforimported',
141+
addressPVM: 'P-avax1differentpvmaddressforimported'
142+
}
143+
128144
const cChainNetwork = {
129145
chainId: 43114,
130146
chainName: 'Avalanche C-Chain',
@@ -688,6 +704,266 @@ describe('BalanceService', () => {
688704
})
689705
})
690706

707+
describe('duplicate address handling (imported PK accounts)', () => {
708+
describe('getBalancesForAccounts', () => {
709+
it('should assign balance to both accounts when they share the same EVM address', async () => {
710+
// API returns one balance for the shared EVM address
711+
const mockStreamResponse = [
712+
{
713+
caip2Id: 'eip155:43114',
714+
networkType: 'evm',
715+
id: testAccount.addressC // shared address
716+
}
717+
]
718+
719+
mockGetBalancesStream.mockReturnValue(
720+
createAsyncIterator(mockStreamResponse)
721+
)
722+
723+
const mockNormalizedBalance = {
724+
accountId: 'PLACEHOLDER',
725+
chainId: 43114,
726+
tokens: [{ symbol: 'AVAX', balance: 1000n }],
727+
dataAccurate: true,
728+
error: null
729+
}
730+
731+
// mapBalanceResponseToLegacy is called once per matched account
732+
mockMapBalanceResponseToLegacy
733+
.mockReturnValueOnce({
734+
...mockNormalizedBalance,
735+
accountId: testAccount.id
736+
})
737+
.mockReturnValueOnce({
738+
...mockNormalizedBalance,
739+
accountId: importedAccount.id
740+
})
741+
742+
const onBalanceLoaded = jest.fn()
743+
744+
const result = await balanceService.getBalancesForAccounts({
745+
networks: [cChainNetwork as any],
746+
accounts: [testAccount, importedAccount],
747+
currency: 'usd',
748+
onBalanceLoaded,
749+
xpAddressesByAccountId: new Map([
750+
[testAccount.id, ['avax1test1', 'avax1test2']],
751+
[importedAccount.id, []]
752+
]),
753+
xpubByAccountId: new Map()
754+
})
755+
756+
// Both accounts should have received the balance
757+
expect(result[testAccount.id]).toHaveLength(1)
758+
expect(result[importedAccount.id]).toHaveLength(1)
759+
expect(result[testAccount.id]?.[0]?.accountId).toBe(testAccount.id)
760+
expect(result[importedAccount.id]?.[0]?.accountId).toBe(
761+
importedAccount.id
762+
)
763+
// onBalanceLoaded should fire for both
764+
expect(onBalanceLoaded).toHaveBeenCalledTimes(2)
765+
})
766+
767+
it('should match address case-insensitively (API returns lowercase, account stores checksummed)', async () => {
768+
// API returns the address in LOWERCASE
769+
const lowercaseAddress =
770+
testAccount.addressC?.toLowerCase() ?? 'missing'
771+
772+
const mockStreamResponse = [
773+
{
774+
caip2Id: 'eip155:43114',
775+
networkType: 'evm',
776+
id: lowercaseAddress // lowercase from API
777+
}
778+
]
779+
780+
mockGetBalancesStream.mockReturnValue(
781+
createAsyncIterator(mockStreamResponse)
782+
)
783+
784+
mockMapBalanceResponseToLegacy
785+
.mockReturnValueOnce({
786+
accountId: testAccount.id,
787+
chainId: 43114,
788+
tokens: [{ symbol: 'AVAX', balance: 500n }],
789+
dataAccurate: true,
790+
error: null
791+
})
792+
.mockReturnValueOnce({
793+
accountId: importedAccount.id,
794+
chainId: 43114,
795+
tokens: [{ symbol: 'AVAX', balance: 500n }],
796+
dataAccurate: true,
797+
error: null
798+
})
799+
800+
const result = await balanceService.getBalancesForAccounts({
801+
networks: [cChainNetwork as any],
802+
accounts: [testAccount, importedAccount],
803+
currency: 'usd',
804+
xpAddressesByAccountId: new Map([
805+
[testAccount.id, ['avax1test1', 'avax1test2']],
806+
[importedAccount.id, []]
807+
]),
808+
xpubByAccountId: new Map()
809+
})
810+
811+
// Both should match despite case difference
812+
expect(result[testAccount.id]).toHaveLength(1)
813+
expect(result[importedAccount.id]).toHaveLength(1)
814+
})
815+
816+
it('should not duplicate when account id matches directly in accountById', async () => {
817+
// If the API returns the account.id directly as the balance id,
818+
// it should resolve via accountById (direct lookup) — not the address map.
819+
const mockStreamResponse = [
820+
{
821+
caip2Id: 'eip155:43114',
822+
networkType: 'evm',
823+
id: testAccount.id // matches account id directly
824+
}
825+
]
826+
827+
mockGetBalancesStream.mockReturnValue(
828+
createAsyncIterator(mockStreamResponse)
829+
)
830+
831+
mockMapBalanceResponseToLegacy.mockReturnValue({
832+
accountId: testAccount.id,
833+
chainId: 43114,
834+
tokens: [],
835+
dataAccurate: true,
836+
error: null
837+
})
838+
839+
const result = await balanceService.getBalancesForAccounts({
840+
networks: [cChainNetwork as any],
841+
accounts: [testAccount, importedAccount],
842+
currency: 'usd',
843+
xpAddressesByAccountId: new Map([
844+
[testAccount.id, []],
845+
[importedAccount.id, []]
846+
]),
847+
xpubByAccountId: new Map()
848+
})
849+
850+
// Only testAccount should match (by id), not importedAccount
851+
expect(result[testAccount.id]).toHaveLength(1)
852+
expect(result[importedAccount.id]).toHaveLength(0)
853+
})
854+
})
855+
856+
describe('getVMBalancesForAccounts', () => {
857+
it('should assign VM balance to both accounts sharing the same EVM address', async () => {
858+
const mockModule = {
859+
getBalances: jest.fn().mockResolvedValue({
860+
[testAccount.addressC!]: {
861+
avax: {
862+
name: 'Avalanche',
863+
symbol: 'AVAX',
864+
decimals: 18,
865+
balance: '1000000000000000000',
866+
type: TokenType.NATIVE
867+
}
868+
}
869+
})
870+
}
871+
mockLoadModuleByNetwork.mockResolvedValue(mockModule)
872+
873+
const onBalanceLoaded = jest.fn()
874+
875+
const result = await balanceService.getVMBalancesForAccounts({
876+
networks: [cChainNetwork as any],
877+
accounts: [testAccount, importedAccount],
878+
currency: 'usd',
879+
customTokens: {},
880+
onBalanceLoaded,
881+
xpAddressesByAccountId: new Map([
882+
[testAccount.id, ['avax1test1', 'avax1test2']],
883+
[importedAccount.id, []]
884+
])
885+
})
886+
887+
// Both accounts should have received the VM balance
888+
expect(result[testAccount.id]).toHaveLength(1)
889+
expect(result[importedAccount.id]).toHaveLength(1)
890+
expect(result[testAccount.id]?.[0]?.accountId).toBe(testAccount.id)
891+
expect(result[importedAccount.id]?.[0]?.accountId).toBe(
892+
importedAccount.id
893+
)
894+
})
895+
896+
it('should handle VM error for both accounts sharing an address', async () => {
897+
const mockModule = {
898+
getBalances: jest.fn().mockResolvedValue({
899+
[testAccount.addressC!]: {
900+
error: new Error('VM fetch failed')
901+
}
902+
})
903+
}
904+
mockLoadModuleByNetwork.mockResolvedValue(mockModule)
905+
906+
const result = await balanceService.getVMBalancesForAccounts({
907+
networks: [cChainNetwork as any],
908+
accounts: [testAccount, importedAccount],
909+
currency: 'usd',
910+
customTokens: {},
911+
xpAddressesByAccountId: new Map([
912+
[testAccount.id, ['avax1test1', 'avax1test2']],
913+
[importedAccount.id, []]
914+
])
915+
})
916+
917+
// Both accounts should have received an error entry
918+
expect(result[testAccount.id]).toHaveLength(1)
919+
expect(result[importedAccount.id]).toHaveLength(1)
920+
expect(result[testAccount.id]?.[0]?.dataAccurate).toBe(false)
921+
expect(result[importedAccount.id]?.[0]?.dataAccurate).toBe(false)
922+
})
923+
924+
it('should not add duplicate account entries for the same address', async () => {
925+
// Create a second imported account with the exact same address
926+
const importedAccount2: Account = {
927+
...importedAccount,
928+
id: 'duplicate-imported-id',
929+
name: 'Imported Account 2'
930+
}
931+
932+
const mockModule = {
933+
getBalances: jest.fn().mockResolvedValue({
934+
[testAccount.addressC!]: {
935+
avax: {
936+
name: 'Avalanche',
937+
symbol: 'AVAX',
938+
decimals: 18,
939+
balance: '1000000000000000000',
940+
type: TokenType.NATIVE
941+
}
942+
}
943+
})
944+
}
945+
mockLoadModuleByNetwork.mockResolvedValue(mockModule)
946+
947+
const result = await balanceService.getVMBalancesForAccounts({
948+
networks: [cChainNetwork as any],
949+
accounts: [testAccount, importedAccount, importedAccount2],
950+
currency: 'usd',
951+
customTokens: {},
952+
xpAddressesByAccountId: new Map([
953+
[testAccount.id, ['avax1test1', 'avax1test2']],
954+
[importedAccount.id, []],
955+
[importedAccount2.id, []]
956+
])
957+
})
958+
959+
// All three accounts should get exactly 1 balance entry
960+
expect(result[testAccount.id]).toHaveLength(1)
961+
expect(result[importedAccount.id]).toHaveLength(1)
962+
expect(result[importedAccount2.id]).toHaveLength(1)
963+
})
964+
})
965+
})
966+
691967
describe('edge cases', () => {
692968
it('should handle empty networks array', async () => {
693969
const result = await balanceService.getBalancesForAccount({

0 commit comments

Comments
 (0)