@@ -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+
128144const 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