From 066925f706c2c36c8b113a1f9cb3208c787f516c Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Fri, 21 Nov 2025 17:51:49 +0100 Subject: [PATCH 01/11] feat: fix tests --- apps/legacy/jest.config.js | 1 + apps/next/jest.config.js | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/legacy/jest.config.js b/apps/legacy/jest.config.js index d48d5a975..c85c1b01e 100644 --- a/apps/legacy/jest.config.js +++ b/apps/legacy/jest.config.js @@ -5,6 +5,7 @@ module.exports = { preset: 'ts-jest', resolver: '/../../src/tests/resolver.js', testEnvironment: 'jest-environment-jsdom', + setupFiles: ['/../../src/tests/mockClientApis.ts'], setupFilesAfterEnv: ['/../../src/tests/setupTests.ts'], moduleNameMapper: { '^@/(.*)': '/src/$1', diff --git a/apps/next/jest.config.js b/apps/next/jest.config.js index 74602fabb..85be33409 100644 --- a/apps/next/jest.config.js +++ b/apps/next/jest.config.js @@ -5,6 +5,7 @@ module.exports = { preset: 'ts-jest', resolver: '/../../src/tests/resolver.js', testEnvironment: 'jest-environment-jsdom', + setupFiles: ['/../../src/tests/mockClientApis.ts'], setupFilesAfterEnv: ['/../../src/tests/setupTests.ts'], moduleNameMapper: { '^@/(.*)': '/src/$1', From ef6983b78ea0f035f8c14ae44c4409e3906e8ea1 Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Fri, 21 Nov 2025 18:00:09 +0100 Subject: [PATCH 02/11] feat: remove unnecessary mocking --- apps/legacy/jest.config.js | 1 - apps/next/jest.config.js | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/legacy/jest.config.js b/apps/legacy/jest.config.js index c85c1b01e..d48d5a975 100644 --- a/apps/legacy/jest.config.js +++ b/apps/legacy/jest.config.js @@ -5,7 +5,6 @@ module.exports = { preset: 'ts-jest', resolver: '/../../src/tests/resolver.js', testEnvironment: 'jest-environment-jsdom', - setupFiles: ['/../../src/tests/mockClientApis.ts'], setupFilesAfterEnv: ['/../../src/tests/setupTests.ts'], moduleNameMapper: { '^@/(.*)': '/src/$1', diff --git a/apps/next/jest.config.js b/apps/next/jest.config.js index 85be33409..74602fabb 100644 --- a/apps/next/jest.config.js +++ b/apps/next/jest.config.js @@ -5,7 +5,6 @@ module.exports = { preset: 'ts-jest', resolver: '/../../src/tests/resolver.js', testEnvironment: 'jest-environment-jsdom', - setupFiles: ['/../../src/tests/mockClientApis.ts'], setupFilesAfterEnv: ['/../../src/tests/setupTests.ts'], moduleNameMapper: { '^@/(.*)': '/src/$1', From 522d5e1cb5b02440a863bbcb29194cfb8918c0c9 Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Mon, 24 Nov 2025 13:39:59 +0100 Subject: [PATCH 03/11] feat: generate balance api client with prod schema --- .../src/api-clients/balance-api/sdk.gen.ts | 2 +- .../src/api-clients/balance-api/types.gen.ts | 231 ++++++++++++++---- 2 files changed, 186 insertions(+), 47 deletions(-) diff --git a/packages/service-worker/src/api-clients/balance-api/sdk.gen.ts b/packages/service-worker/src/api-clients/balance-api/sdk.gen.ts index 2d1f8f9b9..2983c6432 100644 --- a/packages/service-worker/src/api-clients/balance-api/sdk.gen.ts +++ b/packages/service-worker/src/api-clients/balance-api/sdk.gen.ts @@ -29,7 +29,7 @@ export type Options< export const postV1BalanceGetBalances = ( options: Options, ) => { - return (options.client ?? client).post< + return (options.client ?? client).sse.post< PostV1BalanceGetBalancesResponses, unknown, ThrowOnError diff --git a/packages/service-worker/src/api-clients/balance-api/types.gen.ts b/packages/service-worker/src/api-clients/balance-api/types.gen.ts index 3e6af821e..17df48eb4 100644 --- a/packages/service-worker/src/api-clients/balance-api/types.gen.ts +++ b/packages/service-worker/src/api-clients/balance-api/types.gen.ts @@ -8,20 +8,72 @@ export type ClientOptions = { * The request body for the get-balances endpoint */ export type GetBalancesRequestBody = { - data: Array<{ - /** - * The caip2 namespace - */ - namespace: string; - /** - * The addresses we want to get and sum up the balances for - */ - addresses: Array; - /** - * The second part for the caip2 chain ID - */ - references: Array; - }>; + data: Array< + | { + /** + * The caip2 namespace + */ + namespace: string; + /** + * The addresses we want to get and sum up the balances for + */ + addresses: Array; + /** + * The second part for the caip2 chain ID + */ + references: Array; + } + | { + /** + * The caip2 namespace for Avalanche chains + */ + namespace: 'avax'; + /** + * The second part for the caip2 chain ID + */ + references: Array< + | '11111111111111111111111111111111LpoYY' + | 'fuji-11111111111111111111111111111111LpoYY' + | '2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM' + | '2JVSBoinj9C2J33VntvzYtVJNZdN2NKiwwKjcumHUWEb5DbBrm' + >; + addressDetails: Array<{ + /** + * The wallet's ID. Will default to the hash of the addresses if not provided. + */ + walletId?: string; + /** + * The addresses we want to get and sum up the balances for + */ + addresses: Array; + }>; + } + | { + /** + * The caip2 namespace for Avalanche chains + */ + namespace: 'avax'; + /** + * The second part for the caip2 chain ID + */ + references: Array< + | '11111111111111111111111111111111LpoYY' + | 'fuji-11111111111111111111111111111111LpoYY' + | '2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM' + | '2JVSBoinj9C2J33VntvzYtVJNZdN2NKiwwKjcumHUWEb5DbBrm' + >; + extendedPublicKeyDetails: Array<{ + /** + * The wallet's ID. Will default to the hash of the extended public key if not provided. + */ + walletId?: string; + /** + * The extended public key for X / P chain + */ + extendedPublicKey: string & string; + }>; + } + >; currency?: Currency; }; @@ -36,7 +88,7 @@ export type Currency = 'usd' | 'eur' | 'aud' | 'cad' | 'chf'; export type GetStakeRewardsRequestBody = | { /** - * The extended public key for P-chain + * The extended public key for X / P chain */ extendedPublicKey: string & string; isTestnet: boolean; @@ -50,36 +102,21 @@ export type GetStakeRewardsRequestBody = * Get balances response */ export type GetBalancesResponse = - | EvmGetBalancesResponse - | BtcGetBalancesResponse - | { - caip2Id: string; + | ({ + networkType: 'evm'; + } & EvmGetBalancesResponse) + | ({ + networkType: 'btc'; + } & BtcGetBalancesResponse) + | ({ networkType: 'svm'; - address: string; - balances: { - nativeTokenBalance: NativeTokenBalance; - /** - * Total balance in given currency - */ - totalBalanceInCurrency?: number; - splTokenBalances: Array<{ - internalId?: string; - name: string; - symbol: string; - type: 'spl'; - decimals: number; - logoUri?: string; - balance: string; - balanceInCurrency?: number; - price?: number; - priceChange24h?: number; - priceChangePercentage24h?: number; - address: string; - scanResult?: 'Benign' | 'Malicious' | 'Warning' | 'Spam'; - }>; - }; - error: string | null; - } + } & SvmGetBalancesResponse) + | ({ + networkType: 'avm'; + } & AvmGetBalancesResponse) + | ({ + networkType: 'pvm'; + } & PvmGetBalancesResponse) | { error: string; }; @@ -90,7 +127,7 @@ export type GetBalancesResponse = export type EvmGetBalancesResponse = { caip2Id: string; networkType: 'evm'; - address: string; + id: string; balances: { nativeTokenBalance: NativeTokenBalance; /** @@ -148,7 +185,7 @@ export type Erc20TokenBalance = { export type BtcGetBalancesResponse = { caip2Id: string; networkType: 'btc'; - address: string; + id: string; balances: { nativeTokenBalance: { internalId?: string; @@ -173,6 +210,108 @@ export type BtcGetBalancesResponse = { error: string | null; }; +/** + * The balance response for SVM chains + */ +export type SvmGetBalancesResponse = { + caip2Id: string; + networkType: 'svm'; + id: string; + balances: { + nativeTokenBalance: NativeTokenBalance; + /** + * Total balance in given currency + */ + totalBalanceInCurrency?: number; + splTokenBalances: Array<{ + internalId?: string; + name: string; + symbol: string; + type: 'spl'; + decimals: number; + logoUri?: string; + balance: string; + balanceInCurrency?: number; + price?: number; + priceChange24h?: number; + priceChangePercentage24h?: number; + address: string; + scanResult?: 'Benign' | 'Malicious' | 'Warning' | 'Spam'; + }>; + }; + error: string | null; +}; + +/** + * The balance response for X-chain + */ +export type AvmGetBalancesResponse = { + caip2Id: string; + networkType: 'avm'; + id: string; + balances: { + nativeTokenBalance: NativeTokenBalance; + /** + * Total balance in given currency + */ + totalBalanceInCurrency?: number; + categories: { + unlocked: Array; + locked: Array; + atomicMemoryUnlocked: Array; + atomicMemoryLocked: Array; + }; + }; + error: string | null; +}; + +/** + * Avalanche Balance Item + * + * The balance for a given Avalanche asset + */ +export type AvalancheBalanceItem = { + internalId?: string; + name: string; + symbol: string; + type: 'native' | 'unknown'; + decimals: number; + logoUri?: string; + balance: string; + balanceInCurrency?: number; + price?: number; + priceChange24h?: number; + priceChangePercentage24h?: number; + assetId: string; +}; + +/** + * The balance response for P-chain + */ +export type PvmGetBalancesResponse = { + caip2Id: string; + networkType: 'pvm'; + id: string; + balances: { + nativeTokenBalance: NativeTokenBalance; + /** + * Total balance in given currency + */ + totalBalanceInCurrency?: number; + categories: { + unlockedStaked: Array; + unlockedUnstaked: Array; + lockedStaked: Array; + lockedPlatform: Array; + lockedStakeable: Array; + pendingStaked: Array; + atomicMemoryLocked: Array; + atomicMemoryUnlocked: Array; + }; + }; + error: string | null; +}; + /** * Get stake rewards response */ From fcf17b58564f497041247adda6f0430c05fe608e Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Fri, 28 Nov 2025 10:17:17 +0100 Subject: [PATCH 04/11] feat: update balance api client --- .../src/api-clients/balance-api/types.gen.ts | 177 +++++++++++++----- 1 file changed, 133 insertions(+), 44 deletions(-) diff --git a/packages/service-worker/src/api-clients/balance-api/types.gen.ts b/packages/service-worker/src/api-clients/balance-api/types.gen.ts index 17df48eb4..6dc9eaba5 100644 --- a/packages/service-worker/src/api-clients/balance-api/types.gen.ts +++ b/packages/service-worker/src/api-clients/balance-api/types.gen.ts @@ -11,39 +11,71 @@ export type GetBalancesRequestBody = { data: Array< | { /** - * The caip2 namespace + * The caip2 namespace for EVM chains */ - namespace: string; + namespace: 'eip155'; /** - * The addresses we want to get and sum up the balances for + * The list of addresses we want to get aggregated balances for */ addresses: Array; /** - * The second part for the caip2 chain ID + * The reference part of the caip2 ID (Supports EVM chains only) */ references: Array; } + | { + /** + * The caip2 namespace for BTC chains + */ + namespace: 'bip122'; + /** + * The list of addresses we want to get aggregated balances for + */ + addresses: Array; + /** + * The reference part of the caip2 ID (Supports BTC chains only) + */ + references: Array< + | '000000000019d6689c085ae165831e93' + | '000000000933ea01ad0ee984209779ba' + >; + } + | { + /** + * The caip2 namespace for SVM chains + */ + namespace: 'solana'; + /** + * The list of addresses we want to get aggregated balances for + */ + addresses: Array; + /** + * The reference part of the caip2 ID (Supports SVM chains only) + */ + references: Array< + | '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' + | 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1' + >; + } | { /** * The caip2 namespace for Avalanche chains */ namespace: 'avax'; /** - * The second part for the caip2 chain ID + * The reference part of the caip2 ID (Supports C chains only) */ references: Array< - | '11111111111111111111111111111111LpoYY' - | 'fuji-11111111111111111111111111111111LpoYY' - | '2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM' - | '2JVSBoinj9C2J33VntvzYtVJNZdN2NKiwwKjcumHUWEb5DbBrm' + | '8aDU0Kqh-5d23op-B-r-4YbQFRbsgF9a' + | 'YRLfeDBJpfEqUWe2FYR1OpXsnDDZeKWd' >; addressDetails: Array<{ /** - * The wallet's ID. Will default to the hash of the addresses if not provided. + * The wallet's unique identifier. */ - walletId?: string; + walletId: string; /** - * The addresses we want to get and sum up the balances for + * The list of addresses we want to get aggregated balances for */ addresses: Array; }>; @@ -54,27 +86,45 @@ export type GetBalancesRequestBody = { */ namespace: 'avax'; /** - * The second part for the caip2 chain ID + * The reference part of the caip2 ID (Supports X and P chains only) */ references: Array< - | '11111111111111111111111111111111LpoYY' - | 'fuji-11111111111111111111111111111111LpoYY' - | '2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM' - | '2JVSBoinj9C2J33VntvzYtVJNZdN2NKiwwKjcumHUWEb5DbBrm' + | 'Rr9hnPVPxuUvrdCul-vjEsU1zmqKqRDo' + | 'Sj7NVE3jXTbJvwFAiu7OEUo_8g8ctXMG' + | 'imji8papUf2EhV3le337w1vgFauqkJg-' + | '8AJTpRj3SAqv1e80Mtl9em08LhvKEbkl' >; - extendedPublicKeyDetails: Array<{ + addressDetails?: Array<{ + /** + * The wallet's unique identifier. + */ + walletId: string; /** - * The wallet's ID. Will default to the hash of the extended public key if not provided. + * The list of addresses we want to get aggregated balances for */ - walletId?: string; + addresses: Array; + }>; + extendedPublicKeyDetails?: Array<{ + /** + * The wallet's unique identifier. + */ + walletId: string; /** - * The extended public key for X / P chain + * The extended public key for X/P chains we want to get aggregated balances for */ extendedPublicKey: string & string; }>; + /** + * Whether to filter out dust UTXOs from the balance calculation. Default is true. Only supported on P-chain. + */ + filterOutDustUtxos?: boolean; } >; currency?: Currency; + /** + * Whether to show untrusted tokens in the balance response. Defaults to false. + */ + showUntrustedTokens?: boolean; }; /** @@ -117,6 +167,19 @@ export type GetBalancesResponse = | ({ networkType: 'pvm'; } & PvmGetBalancesResponse) + | ({ + networkType: 'coreth'; + } & CorethGetBalancesResponse) + | { + caip2Id: string; + /** + * The type of the network + */ + networkType?: 'evm' | 'btc' | 'svm' | 'avm' | 'pvm' | 'coreth'; + id: string; + balances: null; + error: string; + } | { error: string; }; @@ -136,7 +199,7 @@ export type EvmGetBalancesResponse = { totalBalanceInCurrency?: number; erc20TokenBalances: Array; }; - error: string | null; + error: null; }; /** @@ -207,7 +270,7 @@ export type BtcGetBalancesResponse = { */ totalBalanceInCurrency?: number; }; - error: string | null; + error: null; }; /** @@ -239,7 +302,7 @@ export type SvmGetBalancesResponse = { scanResult?: 'Benign' | 'Malicious' | 'Warning' | 'Spam'; }>; }; - error: string | null; + error: null; }; /** @@ -258,11 +321,15 @@ export type AvmGetBalancesResponse = { categories: { unlocked: Array; locked: Array; - atomicMemoryUnlocked: Array; - atomicMemoryLocked: Array; + atomicMemoryUnlocked: { + [key: string]: Array; + }; + atomicMemoryLocked: { + [key: string]: Array; + }; }; }; - error: string | null; + error: null; }; /** @@ -271,18 +338,12 @@ export type AvmGetBalancesResponse = { * The balance for a given Avalanche asset */ export type AvalancheBalanceItem = { - internalId?: string; + assetId: string; name: string; symbol: string; - type: 'native' | 'unknown'; decimals: number; - logoUri?: string; balance: string; - balanceInCurrency?: number; - price?: number; - priceChange24h?: number; - priceChangePercentage24h?: number; - assetId: string; + type: 'native' | 'unknown'; }; /** @@ -299,17 +360,45 @@ export type PvmGetBalancesResponse = { */ totalBalanceInCurrency?: number; categories: { - unlockedStaked: Array; - unlockedUnstaked: Array; - lockedStaked: Array; - lockedPlatform: Array; - lockedStakeable: Array; - pendingStaked: Array; - atomicMemoryLocked: Array; - atomicMemoryUnlocked: Array; + unlockedStaked: string; + unlockedUnstaked: string; + lockedStaked: string; + lockedPlatform: string; + lockedStakeable: string; + atomicMemoryLocked: { + [key: string]: string; + }; + atomicMemoryUnlocked: { + [key: string]: string; + }; + }; + }; + error: null; +}; + +/** + * The balance response for C-chain + */ +export type CorethGetBalancesResponse = { + caip2Id: string; + networkType: 'coreth'; + id: string; + balances: { + nativeTokenBalance: NativeTokenBalance; + /** + * Total balance in given currency + */ + totalBalanceInCurrency?: number; + categories: { + atomicMemoryUnlocked: { + [key: string]: Array; + }; + atomicMemoryLocked: { + [key: string]: Array; + }; }; }; - error: string | null; + error: null; }; /** From 630942eb76dbee43fdf6d55b58612380ba832c93 Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Tue, 2 Dec 2025 20:05:48 +0100 Subject: [PATCH 05/11] feat: atomic funds calculation and basic UI --- .../WalletView/components/WalletDetails.tsx | 8 +- .../src/utils/balance/balanceToDecimal.ts | 25 + packages/common/src/utils/balance/index.ts | 1 + .../src/api-clients/balance-api/types.gen.ts | 280 +++++----- .../src/api-clients/constants.ts | 28 + .../service-worker/src/api-clients/helpers.ts | 61 +++ .../service-worker/src/api-clients/mappers.ts | 111 ++++ .../service-worker/src/api-clients/types.ts | 49 ++ .../src/api-clients/utils.test.ts | 156 ++++++ .../service-worker/src/api-clients/utils.ts | 511 ++++++++++++++++++ .../extensionConnection/registry.ts | 5 + .../balances/BalanceAggregatorService.test.ts | 8 +- .../balances/BalanceAggregatorService.ts | 301 +++++++++-- .../services/balances/handlers/getBalances.ts | 4 +- .../handlers/getTotalAtomicFundsForWallet.ts | 118 ++++ .../balances/handlers/startBalancesPolling.ts | 6 +- .../handlers/updateBalancesForNetwork.ts | 11 +- .../gasless/handlers/setDefaultStateValues.ts | 4 +- packages/types/src/balance.ts | 41 ++ packages/types/src/feature-flags.ts | 1 + packages/types/src/ui-connection.ts | 4 +- .../BalancesProvider/BalancesProvider.tsx | 40 +- .../NetworkFeeProvider/NetworkFeeProvider.tsx | 2 +- .../contexts/WalletTotalBalanceProvider.tsx | 65 ++- packages/ui/src/hooks/index.ts | 1 + .../src/hooks/useWalletTotalAtomicBalance.ts | 19 + src/tests/mockClientApis.ts | 4 + 27 files changed, 1688 insertions(+), 176 deletions(-) create mode 100644 packages/common/src/utils/balance/balanceToDecimal.ts create mode 100644 packages/service-worker/src/api-clients/constants.ts create mode 100644 packages/service-worker/src/api-clients/helpers.ts create mode 100644 packages/service-worker/src/api-clients/mappers.ts create mode 100644 packages/service-worker/src/api-clients/types.ts create mode 100644 packages/service-worker/src/api-clients/utils.test.ts create mode 100644 packages/service-worker/src/api-clients/utils.ts create mode 100644 packages/service-worker/src/services/balances/handlers/getTotalAtomicFundsForWallet.ts create mode 100644 packages/ui/src/hooks/useWalletTotalAtomicBalance.ts diff --git a/apps/next/src/pages/Portfolio/components/WalletView/components/WalletDetails.tsx b/apps/next/src/pages/Portfolio/components/WalletView/components/WalletDetails.tsx index 3eae5b8fe..d4e066e52 100644 --- a/apps/next/src/pages/Portfolio/components/WalletView/components/WalletDetails.tsx +++ b/apps/next/src/pages/Portfolio/components/WalletView/components/WalletDetails.tsx @@ -5,7 +5,11 @@ import { WalletAccountsCard } from './WalletAccountsCard'; import { getAccountAvatars } from '../utils/accountAvatars'; import { useMemo } from 'react'; import { usePersonalAvatar } from '@/components/PersonalAvatar'; -import { useAccountsContext, useWalletTotalBalance } from '@core/ui'; +import { + useAccountsContext, + useWalletTotalAtomicBalance, + useWalletTotalBalance, +} from '@core/ui'; import { useNetworksWithBalance } from '../hooks/useNetworksWithBalance'; import { WalletDetails as WalletDetailsType } from '@core/types'; import { getNetworkCount } from '../utils/networkCount'; @@ -24,6 +28,7 @@ export const WalletDetails = ({ wallet }: Props) => { balanceChange, percentageChange, } = useWalletTotalBalance(wallet.id); + const { balanceDisplayValue } = useWalletTotalAtomicBalance(wallet.id); const { selected: { name: userAvatarName }, } = usePersonalAvatar(); @@ -45,6 +50,7 @@ export const WalletDetails = ({ wallet }: Props) => { return ( + balanceDisplayValue: {balanceDisplayValue} { + return Number(parseFloat(String(result)).toFixed(BALANCE_DECIMALS)); +}; + +export const balanceToDecimal = ( + balance: string | number, + decimals: string | number, +): number => { + const balanceString = String(balance); + const decimalsNumber = Number(decimals); + const balanceLength = balanceString.length; + + if (balanceLength <= decimalsNumber) { + const paddedBalanceString = balanceString.padStart(decimalsNumber, '0'); + + return parseResult(`0.${paddedBalanceString.slice(0, decimalsNumber)}`); + } else { + const integerPart = balanceString.slice(0, balanceLength - decimalsNumber); + const fractionalPart = balanceString.slice(balanceLength - decimalsNumber); + + return parseResult(`${integerPart}.${fractionalPart}`); + } +}; diff --git a/packages/common/src/utils/balance/index.ts b/packages/common/src/utils/balance/index.ts index 7e122de6f..c97693f99 100644 --- a/packages/common/src/utils/balance/index.ts +++ b/packages/common/src/utils/balance/index.ts @@ -4,3 +4,4 @@ export * from './groupTokensByType'; export * from './isTokenWithBalanceAVM'; export * from './isTokenWithBalancePVM'; export * from './getMaxUtxos'; +export * from './balanceToDecimal'; diff --git a/packages/service-worker/src/api-clients/balance-api/types.gen.ts b/packages/service-worker/src/api-clients/balance-api/types.gen.ts index 6dc9eaba5..b4dbf79c3 100644 --- a/packages/service-worker/src/api-clients/balance-api/types.gen.ts +++ b/packages/service-worker/src/api-clients/balance-api/types.gen.ts @@ -9,116 +9,11 @@ export type ClientOptions = { */ export type GetBalancesRequestBody = { data: Array< - | { - /** - * The caip2 namespace for EVM chains - */ - namespace: 'eip155'; - /** - * The list of addresses we want to get aggregated balances for - */ - addresses: Array; - /** - * The reference part of the caip2 ID (Supports EVM chains only) - */ - references: Array; - } - | { - /** - * The caip2 namespace for BTC chains - */ - namespace: 'bip122'; - /** - * The list of addresses we want to get aggregated balances for - */ - addresses: Array; - /** - * The reference part of the caip2 ID (Supports BTC chains only) - */ - references: Array< - | '000000000019d6689c085ae165831e93' - | '000000000933ea01ad0ee984209779ba' - >; - } - | { - /** - * The caip2 namespace for SVM chains - */ - namespace: 'solana'; - /** - * The list of addresses we want to get aggregated balances for - */ - addresses: Array; - /** - * The reference part of the caip2 ID (Supports SVM chains only) - */ - references: Array< - | '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' - | 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1' - >; - } - | { - /** - * The caip2 namespace for Avalanche chains - */ - namespace: 'avax'; - /** - * The reference part of the caip2 ID (Supports C chains only) - */ - references: Array< - | '8aDU0Kqh-5d23op-B-r-4YbQFRbsgF9a' - | 'YRLfeDBJpfEqUWe2FYR1OpXsnDDZeKWd' - >; - addressDetails: Array<{ - /** - * The wallet's unique identifier. - */ - walletId: string; - /** - * The list of addresses we want to get aggregated balances for - */ - addresses: Array; - }>; - } - | { - /** - * The caip2 namespace for Avalanche chains - */ - namespace: 'avax'; - /** - * The reference part of the caip2 ID (Supports X and P chains only) - */ - references: Array< - | 'Rr9hnPVPxuUvrdCul-vjEsU1zmqKqRDo' - | 'Sj7NVE3jXTbJvwFAiu7OEUo_8g8ctXMG' - | 'imji8papUf2EhV3le337w1vgFauqkJg-' - | '8AJTpRj3SAqv1e80Mtl9em08LhvKEbkl' - >; - addressDetails?: Array<{ - /** - * The wallet's unique identifier. - */ - walletId: string; - /** - * The list of addresses we want to get aggregated balances for - */ - addresses: Array; - }>; - extendedPublicKeyDetails?: Array<{ - /** - * The wallet's unique identifier. - */ - walletId: string; - /** - * The extended public key for X/P chains we want to get aggregated balances for - */ - extendedPublicKey: string & string; - }>; - /** - * Whether to filter out dust UTXOs from the balance calculation. Default is true. Only supported on P-chain. - */ - filterOutDustUtxos?: boolean; - } + | EvmGetBalancesRequestItem + | BtcGetBalancesRequestItem + | SvmGetBalancesRequestItem + | AvalancheCorethGetBalancesRequestItem + | AvalancheXpGetBalancesRequestItem >; currency?: Currency; /** @@ -127,10 +22,148 @@ export type GetBalancesRequestBody = { showUntrustedTokens?: boolean; }; +/** + * The request item for EVM chains + */ +export type EvmGetBalancesRequestItem = { + /** + * The caip2 namespace for EVM chains + */ + namespace: 'eip155'; + /** + * The list of addresses we want to get aggregated balances for + */ + addresses: Array; + /** + * The reference part of the caip2 ID (Supports EVM chains only) + */ + references: Array; +}; + +/** + * The request item for BTC chains + */ +export type BtcGetBalancesRequestItem = { + /** + * The caip2 namespace for BTC chains + */ + namespace: 'bip122'; + /** + * The list of addresses we want to get aggregated balances for + */ + addresses: Array; + /** + * The reference part of the caip2 ID (Supports BTC chains only) + */ + references: Array< + '000000000019d6689c085ae165831e93' | '000000000933ea01ad0ee984209779ba' + >; +}; + +/** + * The request item for Solana chains + */ +export type SvmGetBalancesRequestItem = { + /** + * The caip2 namespace for SVM chains + */ + namespace: 'solana'; + /** + * The list of addresses we want to get aggregated balances for + */ + addresses: Array; + /** + * The reference part of the caip2 ID (Supports SVM chains only) + */ + references: Array< + '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' | 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1' + >; +}; + +/** + * The request item for C-chain (coreth) + */ +export type AvalancheCorethGetBalancesRequestItem = { + /** + * The caip2 namespace for Avalanche chains + */ + namespace: 'avax'; + /** + * The reference part of the caip2 ID (Supports C chains only) + */ + references: Array< + '8aDU0Kqh-5d23op-B-r-4YbQFRbsgF9a' | 'YRLfeDBJpfEqUWe2FYR1OpXsnDDZeKWd' + >; + addressDetails: Array<{ + /** + * The wallet's unique identifier. + */ + walletId: string; + /** + * The list of addresses we want to get aggregated balances for + */ + addresses: Array; + }>; +}; + +/** + * The request item for X/P chains + */ +export type AvalancheXpGetBalancesRequestItem = { + /** + * The caip2 namespace for Avalanche chains + */ + namespace: 'avax'; + /** + * The reference part of the caip2 ID (Supports X and P chains only) + */ + references: Array< + | 'Rr9hnPVPxuUvrdCul-vjEsU1zmqKqRDo' + | 'Sj7NVE3jXTbJvwFAiu7OEUo_8g8ctXMG' + | 'imji8papUf2EhV3le337w1vgFauqkJg-' + | '8AJTpRj3SAqv1e80Mtl9em08LhvKEbkl' + >; + addressDetails?: Array<{ + /** + * The wallet's unique identifier. + */ + walletId: string; + /** + * The list of addresses we want to get aggregated balances for + */ + addresses: Array; + }>; + extendedPublicKeyDetails?: Array<{ + /** + * The wallet's unique identifier. + */ + walletId: string; + /** + * The extended public key for X/P chains we want to get aggregated balances for + */ + extendedPublicKey: string & string; + }>; + /** + * Whether to filter out dust UTXOs from the balance calculation. Default is true. Only supported on P-chain. + */ + filterOutDustUtxos?: boolean; +}; + /** * The currency we are using for balance calculation */ -export type Currency = 'usd' | 'eur' | 'aud' | 'cad' | 'chf'; +export type Currency = + | 'usd' + | 'eur' + | 'aud' + | 'cad' + | 'chf' + | 'clp' + | 'czk' + | 'dkk' + | 'gbp' + | 'hkd' + | 'huf'; /** * The request body for the get rewards endpoint @@ -170,16 +203,7 @@ export type GetBalancesResponse = | ({ networkType: 'coreth'; } & CorethGetBalancesResponse) - | { - caip2Id: string; - /** - * The type of the network - */ - networkType?: 'evm' | 'btc' | 'svm' | 'avm' | 'pvm' | 'coreth'; - id: string; - balances: null; - error: string; - } + | GetBalancesResponseError | { error: string; }; @@ -401,6 +425,20 @@ export type CorethGetBalancesResponse = { error: null; }; +/** + * The error response if there was an error which got handled + */ +export type GetBalancesResponseError = { + caip2Id: string; + /** + * The type of the network + */ + networkType?: 'evm' | 'btc' | 'svm' | 'avm' | 'pvm' | 'coreth'; + id: string; + balances: null; + error: string; +}; + /** * Get stake rewards response */ diff --git a/packages/service-worker/src/api-clients/constants.ts b/packages/service-worker/src/api-clients/constants.ts new file mode 100644 index 000000000..6b4ab7701 --- /dev/null +++ b/packages/service-worker/src/api-clients/constants.ts @@ -0,0 +1,28 @@ +import { + AvalancheCaip2ChainId, + BitcoinCaip2ChainId, + ChainId, +} from '@avalabs/core-chains-sdk'; +import { SolanaCaipId } from '@core/common'; +import { AccountTypes } from '~/api-clients/types'; + +export const CORE_ETH_CAIP2ID = 'avax:8aDU0Kqh-5d23op-B-r-4YbQFRbsgF9a'; + +export const AVALANCHE_CHAIN_IDS = Object.freeze({ + MAINNET_P: '11111111111111111111111111111111LpoYY', + TESTNET_P: '11111111111111111111111111111111LpoYY', + MAINNET_X: '2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM', + TESTNET_X: '2JVSBoinj9C2J33VntvzYtVJNZdN2NKiwwKjcumHUWEb5DbBrm', + MAINNET_C: '2q9e4r6Mu3U68nU1fYjgbR6JvwrRx36CohpAX5UQxse55x1Q5', + TESTNET_C: 'yH8D7ThNJkxmtkuv2jgBa4P1Rn3Qpr4pPr7QYNfcdoS6k6HWp', +}); + +export const Caip2IdAccountTypeMap: Record = { + [SolanaCaipId[ChainId.SOLANA_MAINNET_ID]]: 'addressSVM', + [BitcoinCaip2ChainId.MAINNET]: 'addressBTC', + [AvalancheCaip2ChainId.P]: 'addressPVM', + [AvalancheCaip2ChainId.X]: 'addressAVM', + [AvalancheCaip2ChainId.C]: 'addressCoreEth', + 'eip155:43114': 'addressC', + 'eip155:1': 'addressC', +}; diff --git a/packages/service-worker/src/api-clients/helpers.ts b/packages/service-worker/src/api-clients/helpers.ts new file mode 100644 index 000000000..db0074993 --- /dev/null +++ b/packages/service-worker/src/api-clients/helpers.ts @@ -0,0 +1,61 @@ +import { Account, AccountType } from '@core/types'; +import { + GetBalancesResponse, + GetBalancesResponseError, +} from '~/api-clients/balance-api'; +import { BalanceResponse } from '~/api-clients/types'; +import { isErrorResponse } from '~/api-clients/utils'; +import { Caip2IdAccountTypeMap } from '~/api-clients/constants'; + +export const convertStreamToArray = async ( + stream: AsyncGenerator, +): Promise<{ + balances: BalanceResponse[]; + errors: GetBalancesResponseError[]; +}> => { + if (!stream) { + return { balances: [], errors: [] }; + } + let next = await stream?.next(); + + const balances: BalanceResponse[] = []; + const errors: GetBalancesResponseError[] = []; + while (!next.done) { + if (isErrorResponse(next.value)) { + if ('caip2Id' in next.value) { + errors.push(next.value); + } + next = await stream.next(); + continue; + } + balances.push(next.value); + next = await stream.next(); + } + + return { balances, errors }; +}; + +export const normalizeXPAddress = (address: string) => { + const withoutPrefix = address.split('-').at(1); + if (!withoutPrefix) { + throw new Error('Invalid X/P address'); + } + return withoutPrefix; +}; + +export const reconstructAccountFromError = ( + error: GetBalancesResponseError, +): Account => { + return { + index: 0, + walletId: 'N/A', + type: AccountType.PRIMARY, + name: 'N/A', + id: error.id, + // these will be overwritten if the error happened with the respective chain + addressBTC: 'N/A', + addressC: 'N/A', + // the id in the error is the account address + [Caip2IdAccountTypeMap[error.caip2Id] ?? '']: error.id, + }; +}; diff --git a/packages/service-worker/src/api-clients/mappers.ts b/packages/service-worker/src/api-clients/mappers.ts new file mode 100644 index 000000000..ff7c58ead --- /dev/null +++ b/packages/service-worker/src/api-clients/mappers.ts @@ -0,0 +1,111 @@ +import { + Erc20TokenBalance, + NativeTokenBalance, +} from '~/api-clients/balance-api'; +import { + NetworkTokenWithBalance, + TokenWithBalanceERC20, + TokenWithBalanceSPL, +} from '@avalabs/vm-module-types/dist/balance'; +import { TokenType } from '@avalabs/vm-module-types'; +import { SplTokenBalance } from '~/api-clients/types'; +import { Erc20TokenBalance as GlacierSdkErc20TokenBalance } from '@avalabs/glacier-sdk'; +import tokenReputation = GlacierSdkErc20TokenBalance.tokenReputation; +import { balanceToDecimal } from '@core/common'; + +interface TokenBalance { + internalId?: string; + name: string; + symbol: string; + decimals: number; + logoUri?: string; + balance: string; + balanceInCurrency?: number; + price?: number; + priceChange24h?: number; + priceChangePercentage24h?: number; +} + +interface BaseTokenBalance { + balance: bigint; + balanceCurrencyDisplayValue?: string; + balanceDisplayValue: string; + balanceInCurrency: number; + priceChanges: { + percentage?: number; + value: number; + }; + priceInCurrency?: number; +} + +const getBaseTokenBalance = (tokenBalance: TokenBalance): BaseTokenBalance => { + return { + balance: BigInt(tokenBalance.balance), + balanceCurrencyDisplayValue: tokenBalance.balanceInCurrency + ?.toFixed(3) + .slice(0, -1), + balanceDisplayValue: balanceToDecimal( + tokenBalance.balance, + tokenBalance.decimals, + ) + .toFixed(5) + .slice(0, -1), + balanceInCurrency: Number( + tokenBalance.balanceInCurrency?.toFixed(3).slice(0, -1) ?? '0', + ), + priceChanges: { + percentage: tokenBalance.priceChangePercentage24h, + value: + ((tokenBalance.balanceInCurrency ?? 0) * + (tokenBalance.priceChangePercentage24h ?? 0)) / + 100, + }, + priceInCurrency: tokenBalance.price, + }; +}; + +export const mapNativeTokenBalance = ( + nativeTokenBalance: NativeTokenBalance, +): NetworkTokenWithBalance => { + return { + change24: nativeTokenBalance.priceChangePercentage24h, + coingeckoId: '', + decimals: nativeTokenBalance.decimals, + logoUri: nativeTokenBalance.logoUri, + name: nativeTokenBalance.name, + symbol: nativeTokenBalance.symbol, + type: TokenType.NATIVE, + ...getBaseTokenBalance(nativeTokenBalance), + }; +}; + +export const mapErc20TokenBalance = + (chainId: number) => + (tokenBalance: Erc20TokenBalance): TokenWithBalanceERC20 => { + return { + address: tokenBalance.address, + chainId, + decimals: tokenBalance.decimals, + logoUri: tokenBalance.logoUri, + name: tokenBalance.name, + reputation: (tokenBalance.scanResult as tokenReputation) ?? null, + symbol: tokenBalance.symbol, + type: TokenType.ERC20, + ...getBaseTokenBalance(tokenBalance), + }; + }; + +export const mapSplTokenBalance = ( + tokenBalance: SplTokenBalance, +): TokenWithBalanceSPL => { + return { + address: tokenBalance.address, + decimals: tokenBalance.decimals, + logoUri: tokenBalance.logoUri, + name: tokenBalance.name, + reputation: null, + symbol: tokenBalance.symbol, + type: TokenType.SPL, + ...getBaseTokenBalance(tokenBalance), + }; +}; diff --git a/packages/service-worker/src/api-clients/types.ts b/packages/service-worker/src/api-clients/types.ts new file mode 100644 index 000000000..6993dcc4f --- /dev/null +++ b/packages/service-worker/src/api-clients/types.ts @@ -0,0 +1,49 @@ +import { + AvalancheCorethGetBalancesRequestItem, + AvalancheXpGetBalancesRequestItem, + AvmGetBalancesResponse, + BtcGetBalancesRequestItem, + BtcGetBalancesResponse, + CorethGetBalancesResponse, + EvmGetBalancesRequestItem, + EvmGetBalancesResponse, + PvmGetBalancesResponse, + SvmGetBalancesRequestItem, + SvmGetBalancesResponse, +} from '~/api-clients/balance-api'; +import { AccountStorageItem } from '@core/types'; + +export type BalanceResponse = + | CorethGetBalancesResponse + | EvmGetBalancesResponse + | AvmGetBalancesResponse + | BtcGetBalancesResponse + | PvmGetBalancesResponse + | SvmGetBalancesResponse; + +export type GetBalanceRequestItem = + | EvmGetBalancesRequestItem + | BtcGetBalancesRequestItem + | SvmGetBalancesRequestItem + | AvalancheCorethGetBalancesRequestItem + | AvalancheXpGetBalancesRequestItem; + +export type NotAvalancheRequestItem = + | EvmGetBalancesRequestItem + | BtcGetBalancesRequestItem + | SvmGetBalancesRequestItem; + +export type NameSpace = 'eip155' | 'bip122' | 'solana' | 'avax'; + +export type SplTokenBalance = + SvmGetBalancesResponse['balances']['splTokenBalances']['0']; + +export type AccountTypes = keyof Pick< + AccountStorageItem, + | 'addressC' + | 'addressBTC' + | 'addressAVM' + | 'addressPVM' + | 'addressCoreEth' + | 'addressSVM' +>; diff --git a/packages/service-worker/src/api-clients/utils.test.ts b/packages/service-worker/src/api-clients/utils.test.ts new file mode 100644 index 000000000..6d14b575d --- /dev/null +++ b/packages/service-worker/src/api-clients/utils.test.ts @@ -0,0 +1,156 @@ +import { Account } from '@core/types'; +import { createGetBalancePayload } from './utils'; + +const payload = { + chainIds: [ + 43114, 4503599627370471, 4503599627370475, 1, 4503599627369476, + 4503599627370469, + ], + accounts: [ + { + id: 'wallet-1-account-1', + index: 0, + name: 'Account 1', + type: 'primary', + walletId: 'wallet1', + addressC: '0xa1', + addressBTC: 'bc111', + addressAVM: 'X-avax111', + addressPVM: 'P-avax111', + addressCoreEth: 'C-avax1aa', + addressSVM: 'AAA', + }, + { + id: 'wallet-1-account-2', + index: 1, + name: 'Account 2', + type: 'primary', + walletId: 'wallet1', + addressC: '0xa2', + addressBTC: 'bc112', + addressAVM: 'X-avax112', + addressPVM: 'P-avax112', + addressCoreEth: 'C-avax1ab', + addressSVM: 'AAB', + }, + { + id: 'wallet-2-account-1', + index: 0, + name: 'Account 1', + type: 'primary', + walletId: 'wallet2', + addressC: '0xb1', + addressBTC: 'bc121', + addressAVM: 'X-avax121', + addressPVM: 'P-avax121', + addressCoreEth: 'C-avax1ba', + addressSVM: 'BBA', + }, + { + id: 'wallet-2-account-2', + index: 1, + name: 'Account 2', + type: 'primary', + walletId: 'wallet2', + addressC: '0xb2', + addressBTC: 'bc122', + addressAVM: 'X-avax122', + addressPVM: 'P-avax122', + addressCoreEth: 'C-avax1bb', + addressSVM: 'BBB', + }, + { + id: 'wallet-2-account-3', + index: 2, + name: 'Account 3', + type: 'primary', + walletId: 'wallet2', + addressC: '0xb3', + addressBTC: 'bc123', + addressAVM: 'X-avax123', + addressPVM: 'P-avax123', + addressCoreEth: 'C-avax1bc', + addressSVM: 'BBC', + }, + { + id: 'wallet-2-account-4', + index: 3, + name: 'Account 4', + type: 'primary', + walletId: 'wallet2', + addressC: '0xb4', + addressBTC: 'bc124', + addressAVM: 'X-avax124', + addressPVM: 'P-avax124', + addressCoreEth: 'C-avax1bd', + addressSVM: 'BBD', + }, + ], +}; + +describe('utils', () => { + describe('createGetBalancePayload', () => { + it('Should return the expected payload', () => { + const expected = { + data: [ + { + namespace: 'eip155', + references: ['43114', '1'], + addresses: ['0xa1', '0xa2', '0xb1', '0xb2', '0xb3', '0xb4'], + }, + { + namespace: 'avax', + references: [ + 'Rr9hnPVPxuUvrdCul-vjEsU1zmqKqRDo', + 'imji8papUf2EhV3le337w1vgFauqkJg-', + ], + addressDetails: [ + { + addresses: ['avax111'], + walletId: 'avax111', + }, + { + addresses: ['avax112'], + walletId: 'avax112', + }, + { + addresses: ['avax121'], + walletId: 'avax121', + }, + { + addresses: ['avax122'], + walletId: 'avax122', + }, + { + addresses: ['avax123'], + walletId: 'avax123', + }, + { + addresses: ['avax124'], + walletId: 'avax124', + }, + ], + }, + { + namespace: 'bip122', + references: ['000000000019d6689c085ae165831e93'], + addresses: ['bc111', 'bc112', 'bc121', 'bc122', 'bc123', 'bc124'], + }, + { + namespace: 'solana', + references: ['5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + addresses: ['AAA', 'AAB', 'BBA', 'BBB', 'BBC', 'BBD'], + }, + ], + currency: 'usd', + }; + + const actual = createGetBalancePayload( + payload.accounts as Account[], + payload.chainIds, + ); + + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/packages/service-worker/src/api-clients/utils.ts b/packages/service-worker/src/api-clients/utils.ts new file mode 100644 index 000000000..739163c53 --- /dev/null +++ b/packages/service-worker/src/api-clients/utils.ts @@ -0,0 +1,511 @@ +import { uniqBy, merge } from 'lodash'; +import { AvalancheCaip2ChainId } from '@avalabs/core-chains-sdk'; +import { Account, AtomicBalances, Balances } from '@core/types'; +import { + AvalancheXpGetBalancesRequestItem, + AvmGetBalancesResponse, + BtcGetBalancesRequestItem, + BtcGetBalancesResponse, + Currency, + EvmGetBalancesResponse, + GetBalancesRequestBody, + GetBalancesResponse, + PvmGetBalancesResponse, + SvmGetBalancesRequestItem, + SvmGetBalancesResponse, + CorethGetBalancesResponse, +} from '~/api-clients/balance-api'; +import { + caipToChainId, + chainIdToCaip, + getNameSpaceFromScope, +} from '@core/common'; +import { + BalanceResponse, + GetBalanceRequestItem, + NameSpace, + NotAvalancheRequestItem, +} from '~/api-clients/types'; +import { normalizeXPAddress } from '~/api-clients/helpers'; +import { + mapErc20TokenBalance, + mapSplTokenBalance, + mapNativeTokenBalance, +} from '~/api-clients/mappers'; +import { + AVALANCHE_CHAIN_IDS, + Caip2IdAccountTypeMap, + CORE_ETH_CAIP2ID, +} from '~/api-clients/constants'; + +export const isErrorResponse = ( + response: GetBalancesResponse, +): response is { error: string } => !!response.error; + +export const isEvmGetBalancesResponse = ( + response: GetBalancesResponse, +): response is EvmGetBalancesResponse => { + return (response as EvmGetBalancesResponse).networkType === 'evm'; +}; + +export const isAvmGetBalancesResponse = ( + response: GetBalancesResponse, +): response is AvmGetBalancesResponse => { + return (response as AvmGetBalancesResponse).networkType === 'avm'; +}; + +export const isBtcGetBalancesResponse = ( + response: GetBalancesResponse, +): response is BtcGetBalancesResponse => { + return (response as BtcGetBalancesResponse).networkType === 'btc'; +}; + +export const isPvmGetBalancesResponse = ( + response: GetBalancesResponse, +): response is PvmGetBalancesResponse => { + return (response as PvmGetBalancesResponse).networkType === 'pvm'; +}; + +export const isSvmGetBalancesResponse = ( + response: GetBalancesResponse, +): response is SvmGetBalancesResponse => { + return (response as SvmGetBalancesResponse).networkType === 'svm'; +}; + +export const isCorethGetBalancesResponse = ( + response: GetBalancesResponse, +): response is CorethGetBalancesResponse => { + return (response as CorethGetBalancesResponse).networkType === 'coreth'; +}; + +type PartialGetBalancePayload = Record< + string, // the name space + GetBalanceRequestItem +>; + +interface GetChainSpecificPayloadObjectParams { + nameSpace: NameSpace; + address: string; + reference: string; +} + +const getChainSpecificPayloadObject = ({ + nameSpace, + address, + reference, +}: GetChainSpecificPayloadObjectParams): GetBalanceRequestItem => { + switch (nameSpace) { + case 'avax': + return { + namespace: nameSpace, + references: [reference], + addressDetails: [ + { + addresses: [normalizeXPAddress(address)], + walletId: normalizeXPAddress(address), + }, + ], + } as AvalancheXpGetBalancesRequestItem; + case 'solana': + return { + namespace: nameSpace, + references: [reference], + addresses: [address], + } as SvmGetBalancesRequestItem; + case 'bip122': + return { + namespace: nameSpace, + references: [reference], + addresses: [address], + } as BtcGetBalancesRequestItem; + default: + return { + namespace: nameSpace, + references: [reference], + addresses: [address], + }; + } +}; + +export const createGetBalancePayload = ( + accounts: Account[], + chainIds: number[], + currency: Currency = 'usd', +): GetBalancesRequestBody => { + // TODO: coreth caip2 ID from extension + const caip2Ids = chainIds.map(chainIdToCaip); + const partialGetBalancePayload = accounts.reduce( + (accumulator, account) => { + return caip2Ids.reduce((acc, caip2Id) => { + // when we don't have an address for the given account for the given chain, there is nothing to query + const nameSpace = getNameSpaceFromScope(caip2Id) as + | NameSpace + | null + | undefined; + if ( + !nameSpace || + !Caip2IdAccountTypeMap[caip2Id] || + !account[Caip2IdAccountTypeMap[caip2Id]] + ) { + return acc; + } + + const address = account[Caip2IdAccountTypeMap[caip2Id]]!; + const [, reference] = caip2Id.split(':'); + // if the caip2Id is "malformed" we skip it + if (!reference) { + return acc; + } + + const chainSpecificRequestItem = getChainSpecificPayloadObject({ + nameSpace, + address, + reference, + }); + + if (!acc[nameSpace]) { + return { + ...acc, + [nameSpace]: { + ...chainSpecificRequestItem, + }, + }; + } + + if (nameSpace === 'avax') { + return { + ...acc, + [nameSpace]: { + references: Array.from( + new Set([...acc[nameSpace].references, reference]), + ), + addressDetails: uniqBy( + [ + ...((acc[nameSpace] as AvalancheXpGetBalancesRequestItem) + .addressDetails ?? []), + ...(( + chainSpecificRequestItem as AvalancheXpGetBalancesRequestItem + ).addressDetails ?? []), + ], + 'walletId', + ), + namespace: nameSpace, + }, + } as PartialGetBalancePayload; + } + + return { + ...acc, + [nameSpace]: { + namespace: nameSpace, + references: Array.from( + new Set([...acc[nameSpace].references, reference]), + ), + addresses: Array.from( + new Set([ + ...(acc[nameSpace] as NotAvalancheRequestItem).addresses, + address, + ]), + ), + }, + } as PartialGetBalancePayload; + }, accumulator); + }, + {}, + ); + + const coreEthGetBalancePayload = accounts.reduce( + (accumulator, account) => { + const caip2Id = AvalancheCaip2ChainId.C; + const nameSpace = getNameSpaceFromScope(caip2Id) as + | NameSpace + | null + | undefined; + if ( + !nameSpace || + !Caip2IdAccountTypeMap[caip2Id] || + !account[Caip2IdAccountTypeMap[caip2Id]] + ) { + return accumulator; + } + + const address = account[Caip2IdAccountTypeMap[caip2Id]]!; + const [, reference] = caip2Id.split(':'); + // if the caip2Id is "malformed" we skip it + if (!reference) { + return accumulator; + } + + const chainSpecificRequestItem = getChainSpecificPayloadObject({ + nameSpace, + address, + reference, + }); + + if (!accumulator[nameSpace]) { + return { + ...accumulator, + [nameSpace]: { + ...chainSpecificRequestItem, + }, + }; + } + + if (nameSpace === 'avax') { + return { + ...accumulator, + [nameSpace]: { + references: Array.from( + new Set([...accumulator[nameSpace].references, reference]), + ), + addressDetails: uniqBy( + [ + ...(( + accumulator[nameSpace] as AvalancheXpGetBalancesRequestItem + ).addressDetails ?? []), + ...(( + chainSpecificRequestItem as AvalancheXpGetBalancesRequestItem + ).addressDetails ?? []), + ], + 'walletId', + ), + namespace: nameSpace, + }, + } as PartialGetBalancePayload; + } + + return accumulator; + }, + {}, + ); + return { + data: [ + ...Object.entries(coreEthGetBalancePayload), + ...Object.entries(partialGetBalancePayload), + ].map(([nameSpace, { namespace, ...rest }]) => ({ + namespace: nameSpace, + ...rest, + })), + currency, + } as GetBalancesRequestBody; +}; + +export const convertBalanceResponsesToCacheBalanceObject = ( + balanceResponses: BalanceResponse[], +): Balances => { + return balanceResponses.reduce((accumulator, balanceResponse) => { + let chainId: number = 0; + try { + chainId = caipToChainId(balanceResponse.caip2Id); + } catch (error) { + // if we are throwing error because of CoreEth, we are ignoring it + if ( + !(error instanceof Error) || + !error.message.includes(CORE_ETH_CAIP2ID) + ) { + throw error; + } + } + + if (isEvmGetBalancesResponse(balanceResponse) && chainId !== 0) { + const tokenBalanceMapper = mapErc20TokenBalance(chainId); + + return { + ...accumulator, + [chainId]: { + ...(accumulator[chainId] ?? {}), + [balanceResponse.id]: { + ...(accumulator[chainId]?.[balanceResponse.id] ?? {}), + [balanceResponse.balances.nativeTokenBalance.symbol]: + mapNativeTokenBalance( + balanceResponse.balances.nativeTokenBalance, + ), + ...balanceResponse.balances.erc20TokenBalances.reduce( + (acc, tokenBalance) => ({ + ...acc, + [tokenBalance.address]: tokenBalanceMapper(tokenBalance), + }), + {}, + ), + }, + }, + }; + } + + if (isPvmGetBalancesResponse(balanceResponse) && chainId !== 0) { + // the id in the response is the walletId we passed in with the addressDetails. For X/P that should be the account address + const accountKey = `P-${balanceResponse.id}`; + return { + ...accumulator, + [chainId]: { + ...(accumulator[chainId] ?? {}), + [accountKey]: { + ...(accumulator[chainId]?.[accountKey] ?? {}), + [balanceResponse.balances.nativeTokenBalance.symbol]: + mapNativeTokenBalance( + balanceResponse.balances.nativeTokenBalance, + ), + }, + }, + }; + } + + if (isAvmGetBalancesResponse(balanceResponse) && chainId !== 0) { + // the id in the response is the walletId we passed in with the addressDetails. For X/P that should be the account address + const accountKey = `X-${balanceResponse.id}`; + return { + ...accumulator, + [chainId]: { + ...(accumulator[chainId] ?? {}), + [accountKey]: { + ...(accumulator[chainId]?.[accountKey] ?? {}), + [balanceResponse.balances.nativeTokenBalance.symbol]: + mapNativeTokenBalance( + balanceResponse.balances.nativeTokenBalance, + ), + }, + }, + }; + } + + if (isBtcGetBalancesResponse(balanceResponse) && chainId !== 0) { + return { + ...accumulator, + [chainId]: { + ...(accumulator[chainId] ?? {}), + [balanceResponse.id]: { + ...(accumulator[chainId]?.[balanceResponse.id] ?? {}), + [balanceResponse.balances.nativeTokenBalance.symbol]: + mapNativeTokenBalance( + balanceResponse.balances.nativeTokenBalance, + ), + }, + }, + }; + } + + if (isSvmGetBalancesResponse(balanceResponse) && chainId !== 0) { + return { + ...accumulator, + [chainId]: { + ...(accumulator[chainId] ?? {}), + [balanceResponse.id]: { + ...(accumulator[chainId]?.[balanceResponse.id] ?? {}), + [balanceResponse.balances.nativeTokenBalance.symbol]: + mapNativeTokenBalance( + balanceResponse.balances.nativeTokenBalance, + ), + ...balanceResponse.balances.splTokenBalances.reduce( + (acc, tokenBalance) => { + return { + ...acc, + [tokenBalance.address]: mapSplTokenBalance(tokenBalance), + }; + }, + {}, + ), + }, + }, + }; + } + + if (isCorethGetBalancesResponse(balanceResponse) && chainId === 0) { + const mainNetC = AVALANCHE_CHAIN_IDS.MAINNET_C; + return { + ...accumulator, + [mainNetC]: { + ...(accumulator[mainNetC] ?? {}), + [balanceResponse.id]: { + ...(accumulator[mainNetC]?.[balanceResponse.id] ?? {}), + [balanceResponse.balances.nativeTokenBalance.symbol]: + mapNativeTokenBalance( + balanceResponse.balances.nativeTokenBalance, + ), + categories: { + ...(accumulator[mainNetC]?.[balanceResponse.id]?.categories ?? + {}), + ...balanceResponse.balances.categories, + }, + }, + }, + } as Balances; + } + + return accumulator; + }, {}); +}; + +export const convertBalanceResponseToAtomicCacheBalanceObject = ( + balanceResponses: BalanceResponse[], +): AtomicBalances => { + return balanceResponses.reduce( + (accumulator, balanceResponse) => { + let chainId: number = 0; + try { + chainId = caipToChainId(balanceResponse.caip2Id); + } catch (error) { + // if we are throwing error because of CoreEth, we are ignoring it + if ( + !(error instanceof Error) || + !error.message.includes(CORE_ETH_CAIP2ID) + ) { + throw error; + } + } + + if (isPvmGetBalancesResponse(balanceResponse) && chainId !== 0) { + // the id in the response is the walletId we passed in with the addressDetails. For X/P that should be the account address + const accountKey = `P-${balanceResponse.id}`; + const mergedAccountObject = merge( + accumulator[chainId]?.[accountKey] ?? {}, + balanceResponse.balances.categories, + ); + return { + ...accumulator, + [chainId]: { + ...(accumulator[chainId] ?? {}), + [accountKey]: { + ...mergedAccountObject, + nativeTokenBalance: balanceResponse.balances.nativeTokenBalance, + }, + }, + }; + } + + if (isAvmGetBalancesResponse(balanceResponse) && chainId !== 0) { + // the id in the response is the walletId we passed in with the addressDetails. For X/P that should be the account address + const accountKey = `X-${balanceResponse.id}`; + const mergedAccountObject = merge( + accumulator[chainId]?.[accountKey] ?? {}, + balanceResponse.balances.categories, + ); + return { + ...accumulator, + [chainId]: { + ...(accumulator[chainId] ?? {}), + [accountKey]: mergedAccountObject, + }, + }; + } + + if (isCorethGetBalancesResponse(balanceResponse) && chainId === 0) { + const mainNetC = AVALANCHE_CHAIN_IDS.MAINNET_C; + const accountKey = `C-${balanceResponse.id}`; + const mergedAccountObject = merge( + accumulator[mainNetC]?.[accountKey] ?? {}, + balanceResponse.balances.categories, + ); + return { + ...accumulator, + [mainNetC]: { + ...(accumulator[mainNetC] ?? {}), + [accountKey]: mergedAccountObject, + }, + }; + } + + return accumulator; + }, + {}, + ); +}; diff --git a/packages/service-worker/src/connections/extensionConnection/registry.ts b/packages/service-worker/src/connections/extensionConnection/registry.ts index 8fcfa0824..be0071fd7 100644 --- a/packages/service-worker/src/connections/extensionConnection/registry.ts +++ b/packages/service-worker/src/connections/extensionConnection/registry.ts @@ -153,6 +153,7 @@ import { SetShowTrendingTokensHandler } from '~/services/settings/handlers/setSh import { EnableNetworkHandler } from '~/services/network/handlers/enableNetwork'; import { DisableNetworkHandler } from '~/services/network/handlers/disableNetwork'; import { GetTrendingTokensHandler } from '~/services/trendingTokens/handlers/getTrendingTokens'; +import { GetTotalAtomicFundsForWalletHandler } from '~/services/balances/handlers/getTotalAtomicFundsForWallet'; /** * TODO: GENERATE THIS FILE AS PART OF THE BUILD PROCESS @@ -479,6 +480,10 @@ import { GetTrendingTokensHandler } from '~/services/trendingTokens/handlers/get { token: 'ExtensionRequestHandler', useToken: ImportLedgerHandlerNew }, { token: 'ExtensionRequestHandler', useToken: CheckIfWalletExists }, { token: 'ExtensionRequestHandler', useToken: GetTrendingTokensHandler }, + { + token: 'ExtensionRequestHandler', + useToken: GetTotalAtomicFundsForWalletHandler, + }, ]) export class ExtensionRequestHandlerRegistry {} diff --git a/packages/service-worker/src/services/balances/BalanceAggregatorService.test.ts b/packages/service-worker/src/services/balances/BalanceAggregatorService.test.ts index d69478b60..75118edce 100644 --- a/packages/service-worker/src/services/balances/BalanceAggregatorService.test.ts +++ b/packages/service-worker/src/services/balances/BalanceAggregatorService.test.ts @@ -33,6 +33,10 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { getBalancesForNetwork: jest.fn(), } as any; + const featureFlagServiceMock = { + featureFlags: {}, + } as any; + const settingsServiceMock = { getSettings: () => ({ currency: 'USD' }), } as unknown as SettingsService; @@ -205,6 +209,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { lockService, storageService, settingsServiceMock, + featureFlagServiceMock, ); }); @@ -247,7 +252,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { ); expect(balancesServiceMock.getBalancesForNetwork).toHaveBeenCalledTimes( - 1, + 2, ); expect(service.balances).toEqual({ @@ -432,6 +437,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { lockService, storageService, settingsServiceMock, + featureFlagServiceMock, ); }); diff --git a/packages/service-worker/src/services/balances/BalanceAggregatorService.ts b/packages/service-worker/src/services/balances/BalanceAggregatorService.ts index f25e8bd22..017ae2edd 100644 --- a/packages/service-worker/src/services/balances/BalanceAggregatorService.ts +++ b/packages/service-worker/src/services/balances/BalanceAggregatorService.ts @@ -1,36 +1,63 @@ -import { OnLock, OnUnlock } from '~/runtime/lifecycleCallbacks'; -import { singleton } from 'tsyringe'; +import { isEqual, partition, pick, merge } from 'lodash'; +import { container, singleton } from 'tsyringe'; +import { EventEmitter } from 'events'; +import * as Sentry from '@sentry/browser'; +import { resolve } from '@avalabs/core-utils-sdk'; +import { NftTokenWithBalance, TokenType } from '@avalabs/vm-module-types'; + import { Account, + AtomicBalances, Balances, - BalancesInfo, - BalanceServiceEvents, BALANCES_CACHE_KEY, + BalanceServiceEvents, + BalancesInfo, CachedBalancesInfo, + priceChangeRefreshRate, PriceChangesData, TOKENS_PRICE_DATA, TokensPriceChangeData, TokensPriceShortData, - priceChangeRefreshRate, } from '@core/types'; +import { + caipToChainId, + groupTokensByType, + isFulfilled, + watchlistTokens, + Monitoring, +} from '@core/common'; + +import { OnLock, OnUnlock } from '~/runtime/lifecycleCallbacks'; +import { balanceApiClient } from '~/api-clients'; +import { + GetBalancesResponseError, + postV1BalanceGetBalances, +} from '~/api-clients/balance-api'; +import { + convertStreamToArray, + reconstructAccountFromError, +} from '~/api-clients/helpers'; +import { + convertBalanceResponsesToCacheBalanceObject, + convertBalanceResponseToAtomicCacheBalanceObject, + createGetBalancePayload, +} from '~/api-clients/utils'; +import { AccountsService } from '~/services/accounts/AccountsService'; + import { BalancesService } from './BalancesService'; import { NetworkService } from '../network/NetworkService'; -import { EventEmitter } from 'events'; -import * as Sentry from '@sentry/browser'; -import { isEqual, omit, pick } from 'lodash'; - import { LockService } from '../lock/LockService'; import { StorageService } from '../storage/StorageService'; -import { resolve } from '@avalabs/core-utils-sdk'; import { SettingsService } from '../settings/SettingsService'; -import { isFulfilled, watchlistTokens } from '@core/common'; -import { NftTokenWithBalance, TokenType } from '@avalabs/vm-module-types'; -import { groupTokensByType } from '@core/common'; +import { FeatureFlagService } from '~/services/featureFlags/FeatureFlagService'; + +const NFT_TYPES = [TokenType.ERC721, TokenType.ERC1155]; @singleton() export class BalanceAggregatorService implements OnLock, OnUnlock { #eventEmitter = new EventEmitter(); #balances: Balances = {}; + #atomicBalances: AtomicBalances = {}; #nfts: Balances = {}; #isBalancesCached = true; @@ -38,6 +65,10 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { return this.#balances; } + get atomicBalances() { + return this.#atomicBalances; + } + get nfts() { return this.#nfts; } @@ -52,18 +83,14 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { private lockService: LockService, private storageService: StorageService, private settingsService: SettingsService, + private featureFlagService: FeatureFlagService, ) {} - async getBalancesForNetworks( + async #fetchBalances( chainIds: number[], accounts: Account[], tokenTypes: TokenType[], - cacheResponse = true, ): Promise<{ tokens: Balances; nfts: Balances }> { - const sentryTracker = Sentry.startTransaction({ - name: 'BalanceAggregatorService: getBatchedUpdatedBalancesForNetworks', - }); - const networks = Object.values( await this.networkService.activeNetworks.promisify(), ).filter((network) => chainIds.includes(network.chainId)); @@ -91,22 +118,7 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { .filter(isFulfilled) .map(({ value }) => value); - const networksWithChanges = updatedNetworks - .filter(({ chainId, networkBalances }) => { - // We may have balances of other accounts cached for this chain ID, - // so to check for updates we need to only compare against a subsection - // of the cached balances. - const fetchedAddresses = Object.keys(networkBalances); - const cachedBalances = pick( - this.balances[chainId] ?? {}, - fetchedAddresses, - ); - - return !isEqual(networkBalances, cachedBalances); - }) - .map(({ chainId }) => chainId); - - const freshBalances = updatedNetworks.reduce<{ + return updatedNetworks.reduce<{ nfts: Balances; tokens: Balances; }>( @@ -121,19 +133,178 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { }, { tokens: {}, nfts: {} }, ); + } + + async #getNftBalances( + chainIds: number[], + accounts: Account[], + tokenTypes: TokenType[], + ): Promise> { + if (!tokenTypes.some((tokenType) => NFT_TYPES.includes(tokenType))) { + return {}; + } + + const balances = await this.#fetchBalances(chainIds, accounts, tokenTypes); + + return balances.nfts; + } + + async #fallbackOnBalanceServiceErrors( + errors: GetBalancesResponseError[], + tokenTypes: TokenType[], + ): Promise { + if (errors.length === 0) { + return {}; + } + + // TODO: if we need to differentiate between chains we can filter based on the networkType + const accounts = errors.map(reconstructAccountFromError); + const chainIds = errors.map(({ caip2Id }) => caipToChainId(caip2Id)); + + try { + const { tokens } = await this.#fetchBalances( + Array.from(new Set(chainIds)), + accounts, + tokenTypes, + ); + return tokens; + } catch (_error) { + return {}; + } + } + + async #getTokenBalances( + chainIds: number[], + accounts: Account[], + tokenTypes: TokenType[], + ): Promise<{ tokens: Balances; atomic: AtomicBalances }> { + if (tokenTypes.some((tokenType) => NFT_TYPES.includes(tokenType))) { + return { tokens: {}, atomic: {} }; + } + + if (this.featureFlagService.featureFlags['balance-service-integration']) { + try { + const getBalancesRequestBody = createGetBalancePayload( + accounts, + chainIds, + ); + + const balanceResult = await postV1BalanceGetBalances({ + client: balanceApiClient, + body: getBalancesRequestBody, + }); + + const { balances, errors } = await convertStreamToArray( + balanceResult.stream, + ); + + const fallbackBalanceResponse = + await this.#fallbackOnBalanceServiceErrors(errors, tokenTypes); + + const cacheBalanceObject = + convertBalanceResponsesToCacheBalanceObject(balances); + const atomicCachedBalanceObject = + convertBalanceResponseToAtomicCacheBalanceObject(balances); + + return { + tokens: merge(cacheBalanceObject, fallbackBalanceResponse), + atomic: atomicCachedBalanceObject, + }; + } catch (err) { + Monitoring.sentryCaptureException( + err as Error, + Monitoring.SentryExceptionTypes.BALANCES, + ); + } + } + + // if there was an error with querying the balance service, or the feature flag is off, we're getting balances through vm modules + const balances = await this.#fetchBalances(chainIds, accounts, tokenTypes); + + return { tokens: balances.tokens, atomic: {} }; + } + + async getBalancesForNetworks( + chainIds: number[], + accounts: Account[], + tokenTypes: TokenType[], + cacheResponse = true, + ): Promise<{ + tokens: Balances; + nfts: Balances; + atomic: AtomicBalances; + }> { + const sentryTracker = Sentry.startTransaction({ + name: 'BalanceAggregatorService: getBatchedUpdatedBalancesForNetworks', + }); + + const [nftTokenTypes, notNftTokenTypes] = partition( + tokenTypes, + (tokenType) => NFT_TYPES.includes(tokenType), + ); + + const [nfts, { tokens, atomic }] = ( + await Promise.allSettled([ + this.#getNftBalances(chainIds, accounts, nftTokenTypes), + this.#getTokenBalances(chainIds, accounts, notNftTokenTypes), + ]) + ).map((promiseResolve) => + promiseResolve.status === 'rejected' ? null : promiseResolve.value, + ) as [ + Balances, + { tokens: Balances; atomic: AtomicBalances }, + ]; + + const freshBalances = { + nfts: (nfts ?? {}) as Balances, + tokens: (tokens ?? {}) as Balances, + atomic: (atomic ?? {}) as AtomicBalances, + }; // NFTs don't have balance = 0, if they are sent they should be removed // from the list, hence deep merge doesn't work - const hasFetchedNfts = - tokenTypes.includes(TokenType.ERC721) || - tokenTypes.includes(TokenType.ERC1155); + const hasFetchedNfts = tokenTypes.some((tokenType) => + NFT_TYPES.includes(tokenType), + ); const aggregatedNfts = hasFetchedNfts ? { ...this.nfts, ...freshBalances.nfts, } : this.nfts; - const hasBalanceChanges = networksWithChanges.length > 0; + /* + * { + * 1: { + * 0xa81e63fC485Dd1263D35d55D88422215884C6430: { + * ETH: { + * // the token properties + * } + * } + * } + * } + * */ + const hasBalanceChanges = Object.entries(freshBalances.tokens).some( + ([chainId, networkBalances]) => { + return Object.keys(networkBalances).some((fetchedAddress) => { + const pathToCheck = [chainId, fetchedAddress]; + return !isEqual( + pick(this.balances, pathToCheck), + pick(freshBalances.tokens, pathToCheck), + ); + }); + }, + ); + const hasAtomicBalanceChanges = Object.entries(freshBalances.atomic).some( + ([chainId, balances]) => { + return Object.keys(balances).some((fetchedAddress) => { + const pathToCheck = [chainId, fetchedAddress]; + return !isEqual( + pick(this.atomicBalances, pathToCheck), + pick(freshBalances.atomic, pathToCheck), + ); + }); + }, + ); const hasNftChanges = !isEqual(aggregatedNfts, this.nfts); const hasChanges = hasBalanceChanges || hasNftChanges; @@ -146,7 +317,7 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { for (const [chainId, chainBalances] of freshData) { for (const [address, addressBalance] of Object.entries(chainBalances)) { aggregatedBalances[chainId] = { - ...omit(aggregatedBalances[chainId], address), // Keep cached balances for other accounts + ...aggregatedBalances[chainId], // Keep cached balances for other accounts ...chainBalances, [address]: addressBalance, }; @@ -154,14 +325,35 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { } } - if (cacheResponse && hasChanges && !this.lockService.locked) { + const aggregatedAtomicBalances = { ...this.atomicBalances }; + if (hasAtomicBalanceChanges) { + const freshData = Object.entries(freshBalances.atomic); + + for (const [chainId, chainBalances] of freshData) { + for (const [address, addressBalance] of Object.entries(chainBalances)) { + aggregatedAtomicBalances[chainId] = { + ...aggregatedAtomicBalances[chainId], // Keep cached balances for other accounts + ...chainBalances, + [address]: { ...addressBalance }, + }; + } + } + } + + if ( + cacheResponse && + (hasChanges || hasAtomicBalanceChanges) && + !this.lockService.locked + ) { this.#balances = aggregatedBalances; this.#nfts = aggregatedNfts; + this.#atomicBalances = aggregatedAtomicBalances; this.#eventEmitter.emit(BalanceServiceEvents.UPDATED, { balances: { tokens: aggregatedBalances, nfts: aggregatedNfts, + atomic: aggregatedAtomicBalances, }, isBalancesCached: false, } as BalancesInfo); @@ -175,6 +367,7 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { return { tokens: aggregatedBalances, nfts: aggregatedNfts, + atomic: aggregatedAtomicBalances, }; } @@ -260,11 +453,13 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { async #updateCachedBalancesInfo() { return this.storageService.save(BALANCES_CACHE_KEY, { balances: this.#balances, + atomicBalances: this.#atomicBalances, }); } onLock() { this.#balances = {}; + this.#atomicBalances = {}; this.#isBalancesCached = true; } @@ -281,15 +476,39 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { } this.#balances = cachedBalance.balances; + this.#atomicBalances = cachedBalance.atomicBalances ?? {}; this.#isBalancesCached = true; this.#eventEmitter.emit(BalanceServiceEvents.UPDATED, { balances: { tokens: this.#balances, nfts: this.#nfts, + atomic: this.#atomicBalances, }, isBalancesCached: true, } as BalancesInfo); + + // trying to get the balances of all the accounts upon unlock if we have the balance service integration turned on + if (this.featureFlagService.featureFlags['balance-service-integration']) { + try { + const accountsService = container.resolve(AccountsService); + const networkService = container.resolve(NetworkService); + + networkService.enabledNetworksUpdated.addOnce(async () => { + const [accounts, enabledNetworks] = await Promise.all([ + accountsService.getAccounts(), + networkService.getEnabledNetworks(), + ]); + this.getBalancesForNetworks( + enabledNetworks, + Object.values(accounts.primary).flat(), + Object.values(TokenType), + ); + }); + } catch (_error) { + /* if there was an error just continue */ + } + } } addListener( diff --git a/packages/service-worker/src/services/balances/handlers/getBalances.ts b/packages/service-worker/src/services/balances/handlers/getBalances.ts index 3c0e8734a..f7d20f99a 100644 --- a/packages/service-worker/src/services/balances/handlers/getBalances.ts +++ b/packages/service-worker/src/services/balances/handlers/getBalances.ts @@ -18,7 +18,8 @@ export class GetBalancesHandler implements HandlerType { constructor(private networkBalancesService: BalanceAggregatorService) {} handle: HandlerType['handle'] = async ({ request }) => { - const { balances, nfts, isBalancesCached } = this.networkBalancesService; + const { balances, nfts, isBalancesCached, atomicBalances } = + this.networkBalancesService; return { ...request, @@ -26,6 +27,7 @@ export class GetBalancesHandler implements HandlerType { balances: { tokens: balances, nfts: nfts, + atomic: atomicBalances, }, isBalancesCached, }, diff --git a/packages/service-worker/src/services/balances/handlers/getTotalAtomicFundsForWallet.ts b/packages/service-worker/src/services/balances/handlers/getTotalAtomicFundsForWallet.ts new file mode 100644 index 000000000..81013672a --- /dev/null +++ b/packages/service-worker/src/services/balances/handlers/getTotalAtomicFundsForWallet.ts @@ -0,0 +1,118 @@ +import { sum } from 'lodash'; +import { injectable } from 'tsyringe'; +import { ChainId } from '@avalabs/core-chains-sdk'; +import { + ExtensionRequest, + ExtensionRequestHandler, + PvmCategories, + CoreEthCategories, + AvmCategories, +} from '@core/types'; +import { balanceToDecimal } from '@core/common'; + +import { BalanceAggregatorService } from '~/services/balances/BalanceAggregatorService'; +import { AccountsService } from '~/services/accounts/AccountsService'; +import { AvalancheBalanceItem } from '~/api-clients/balance-api'; +import { AVALANCHE_CHAIN_IDS } from '~/api-clients/constants'; + +type HandlerType = ExtensionRequestHandler< + ExtensionRequest.GET_ATOMIC_FUNDS_FOR_WALLET, + { sum: number }, + { walletId: string } +>; + +type PvmCategoryWithNativeTokenBalance = PvmCategories & { + nativeTokenBalance: { decimals: number }; +}; + +type Categories = PvmCategories | CoreEthCategories | AvmCategories; + +const isCoreEthOrAvmAtomicBalance = ( + chainId: string | number, + atomicBalance: Categories, +): atomicBalance is CoreEthCategories | AvmCategories => + (AVALANCHE_CHAIN_IDS.MAINNET_C === chainId || + ChainId.AVALANCHE_X === Number(chainId)) && + 'atomicMemoryUnlocked' in atomicBalance; + +const isPvmAtomicBalance = ( + chainId: string | number, + atomicBalance: Categories, +): atomicBalance is PvmCategoryWithNativeTokenBalance => { + return ( + !isNaN(Number(chainId)) && + Number(chainId) === ChainId.AVALANCHE_P && + 'atomicMemoryUnlocked' in atomicBalance + ); +}; + +@injectable() +export class GetTotalAtomicFundsForWalletHandler implements HandlerType { + method = ExtensionRequest.GET_ATOMIC_FUNDS_FOR_WALLET as const; + + constructor( + private balanceAggregatorService: BalanceAggregatorService, + private accountsService: AccountsService, + ) {} + + handle: HandlerType['handle'] = async ({ request }) => { + const { walletId } = request.params; + + const primaryAccounts = Object.values( + (await this.accountsService.getAccounts()).primary, + ).flat(); + const accountsInWallet = primaryAccounts + .filter((account) => account.walletId === walletId) + .reduce>( + (accumulator, account) => ({ + ...accumulator, + [account.addressCoreEth ?? '']: true, + [account.addressAVM ?? '']: true, + [account.addressPVM ?? '']: true, + }), + {}, + ); + + const { atomicBalances } = this.balanceAggregatorService; + const atomicFundsSum = sum( + Object.entries(atomicBalances).flatMap(([chainId, chainBalance]) => { + return Object.entries(chainBalance).flatMap( + ([accountAddress, atomicBalance]) => { + if (!accountsInWallet[accountAddress]) { + return 0; + } + if (isCoreEthOrAvmAtomicBalance(chainId, atomicBalance)) { + return sum( + Object.values(atomicBalance.atomicMemoryUnlocked).flatMap( + (atomicBalanceItems: AvalancheBalanceItem[]) => + atomicBalanceItems.flatMap((item) => + balanceToDecimal(item.balance, item.decimals), + ), + ), + ); + } + if (isPvmAtomicBalance(chainId, atomicBalance)) { + return sum( + Object.values(atomicBalance.atomicMemoryUnlocked).flatMap( + (balance) => + balanceToDecimal( + balance, + atomicBalance.nativeTokenBalance.decimals, + ), + ), + ); + } + return 0; + }, + ); + }), + ); + + return { + ...request, + result: { + sum: atomicFundsSum, + }, + }; + }; +} diff --git a/packages/service-worker/src/services/balances/handlers/startBalancesPolling.ts b/packages/service-worker/src/services/balances/handlers/startBalancesPolling.ts index b892ba28f..0079c585d 100644 --- a/packages/service-worker/src/services/balances/handlers/startBalancesPolling.ts +++ b/packages/service-worker/src/services/balances/handlers/startBalancesPolling.ts @@ -1,5 +1,6 @@ import { Account, + AtomicBalances, Balances, ExtensionRequest, ExtensionRequestHandler, @@ -16,6 +17,7 @@ type HandlerType = ExtensionRequestHandler< balances: { tokens: Balances; nfts: Balances; + atomic: AtomicBalances; }; isBalancesCached: boolean; }, @@ -44,7 +46,8 @@ export class StartBalancesPollingHandler implements HandlerType { ); } - const { balances, nfts, isBalancesCached } = this.aggregatorService; + const { balances, nfts, isBalancesCached, atomicBalances } = + this.aggregatorService; return { ...request, @@ -52,6 +55,7 @@ export class StartBalancesPollingHandler implements HandlerType { balances: { tokens: balances, nfts, + atomic: atomicBalances, }, isBalancesCached, }, diff --git a/packages/service-worker/src/services/balances/handlers/updateBalancesForNetwork.ts b/packages/service-worker/src/services/balances/handlers/updateBalancesForNetwork.ts index 10982380c..820216690 100644 --- a/packages/service-worker/src/services/balances/handlers/updateBalancesForNetwork.ts +++ b/packages/service-worker/src/services/balances/handlers/updateBalancesForNetwork.ts @@ -1,5 +1,6 @@ import { Account, + AtomicBalances, Balances, ExtensionRequest, ExtensionRequestHandler, @@ -13,7 +14,11 @@ import { NftTokenWithBalance, TokenType } from '@avalabs/vm-module-types'; type HandlerType = ExtensionRequestHandler< ExtensionRequest.NETWORK_BALANCES_UPDATE, - { tokens: Balances; nfts: Balances }, + { + tokens: Balances; + nfts: Balances; + atomic: AtomicBalances; + }, [accounts?: Account[], networks?: number[]] | undefined >; @@ -24,11 +29,11 @@ export class UpdateBalancesForNetworkHandler implements HandlerType { constructor( private networkBalancesService: BalanceAggregatorService, private accountsService: AccountsService, - private networkSerice: NetworkService, + private networkService: NetworkService, ) {} async #getDefaultNetworksToFetch(activeChainId: number) { - const favoriteNetworks = await this.networkSerice.getFavoriteNetworks(); + const favoriteNetworks = await this.networkService.getFavoriteNetworks(); return [...(activeChainId ? [activeChainId] : []), ...favoriteNetworks]; } diff --git a/packages/service-worker/src/services/gasless/handlers/setDefaultStateValues.ts b/packages/service-worker/src/services/gasless/handlers/setDefaultStateValues.ts index df2c11bd3..47a78a0f7 100644 --- a/packages/service-worker/src/services/gasless/handlers/setDefaultStateValues.ts +++ b/packages/service-worker/src/services/gasless/handlers/setDefaultStateValues.ts @@ -3,13 +3,13 @@ import { injectable } from 'tsyringe'; import { GasStationService } from '../GasStationService'; type HandlerType = ExtensionRequestHandler< - ExtensionRequest.GASLESS_SET_DEFAUlT_STATE_VALUES, + ExtensionRequest.GASLESS_SET_DEFAULT_STATE_VALUES, undefined >; @injectable() export class SetDefaultStateValuesHandler implements HandlerType { - method = ExtensionRequest.GASLESS_SET_DEFAUlT_STATE_VALUES as const; + method = ExtensionRequest.GASLESS_SET_DEFAULT_STATE_VALUES as const; constructor(private gasStationService: GasStationService) {} diff --git a/packages/types/src/balance.ts b/packages/types/src/balance.ts index c799f3275..4a10880b2 100644 --- a/packages/types/src/balance.ts +++ b/packages/types/src/balance.ts @@ -8,6 +8,7 @@ import { TokenWithBalanceSPL, TokenWithBalanceSVM, } from '@avalabs/vm-module-types'; +import { AvalancheBalanceItem } from '@core/service-worker/src/api-clients/balance-api'; import { EnsureDefined } from './util-types'; @@ -50,10 +51,45 @@ export interface Balances { }; }; } + +export interface PvmCategories { + unlockedStaked: string; + unlockedUnstaked: string; + lockedStaked: string; + lockedPlatform: string; + lockedStakeable: string; + atomicMemoryLocked: { + [avalancheChainId: string]: string; + }; + atomicMemoryUnlocked: { + [avalancheChainId: string]: string; + }; +} + +export interface CoreEthCategories { + atomicMemoryUnlocked: { + [avalancheChainId: string]: AvalancheBalanceItem[]; + }; + atomicMemoryLocked: { + [avalancheChainId: string]: AvalancheBalanceItem[]; + }; +} + +export interface AvmCategories extends CoreEthCategories { + unlocked: AvalancheBalanceItem[]; + locked: AvalancheBalanceItem[]; +} + +export interface AtomicBalances { + [networkId: string | number]: { + [accountAddress: string]: PvmCategories | CoreEthCategories | AvmCategories; + }; +} export interface BalancesInfo { balances: { tokens: Balances; nfts: Balances; + atomic: AtomicBalances; }; isBalancesCached: boolean; } @@ -61,6 +97,7 @@ export interface BalancesInfo { export interface CachedBalancesInfo { totalBalance?: TotalBalance; balances?: Balances; + atomicBalances?: AtomicBalances; lastUpdated?: number; } @@ -110,6 +147,10 @@ export type TotalBalanceForWallet = { percentageChange?: number; }; +export type TotalAtomicBalanceForWallet = { + balanceDisplayValue: number; +}; + export type NonFungibleAssetType = 'evm_erc721' | 'evm_erc1155'; export type FungibleAssetType = diff --git a/packages/types/src/feature-flags.ts b/packages/types/src/feature-flags.ts index 6541de956..9c5a06ff4 100644 --- a/packages/types/src/feature-flags.ts +++ b/packages/types/src/feature-flags.ts @@ -58,6 +58,7 @@ export enum FeatureGates { SOLANA_LAUNCH_MODAL = 'solana-launch-modal', CORE_ASSISTANT = 'core-assistant', SWAP_USE_MARKR = 'swap-use-markr', + BALANCE_SERVICE_INTEGRATION = 'balance-service-integration', } export enum FeatureVars { diff --git a/packages/types/src/ui-connection.ts b/packages/types/src/ui-connection.ts index 0c182cd34..b3305575f 100644 --- a/packages/types/src/ui-connection.ts +++ b/packages/types/src/ui-connection.ts @@ -178,7 +178,7 @@ export enum ExtensionRequest { GASLESS_FUND_TX = 'gasless_fund_tx', GASLESS_GET_ELIGIBILITY = 'gasless_get_eligibility', GASLESS_SET_HEX_VALUES = 'gasless_set_hex_values', - GASLESS_SET_DEFAUlT_STATE_VALUES = 'gasless_set_default_state_values', + GASLESS_SET_DEFAULT_STATE_VALUES = 'gasless_set_default_state_values', GASLESS_CREATE_OFFSCREEN = 'gasless_create_offscreen', GASLESS_CLOSE_OFFSCREEN = 'gasless_close_offscreen', @@ -190,6 +190,8 @@ export enum ExtensionRequest { NOTIFICATION_GET_SUBSCRIPTIONS = 'notification_get_subscriptions', GET_TRENDING_TOKENS = 'get_trending_tokens', + + GET_ATOMIC_FUNDS_FOR_WALLET = 'get_atomic_funds_for_wallet', } /* eslint-disable no-prototype-builtins */ diff --git a/packages/ui/src/contexts/BalancesProvider/BalancesProvider.tsx b/packages/ui/src/contexts/BalancesProvider/BalancesProvider.tsx index 6d50113a5..891120c08 100644 --- a/packages/ui/src/contexts/BalancesProvider/BalancesProvider.tsx +++ b/packages/ui/src/contexts/BalancesProvider/BalancesProvider.tsx @@ -6,7 +6,12 @@ import { StopBalancesPollingHandler, UpdateBalancesForNetworkHandler, } from '@core/service-worker'; -import { Balances, ExtensionRequest, TotalPriceChange } from '@core/types'; +import { + AtomicBalances, + Balances, + ExtensionRequest, + TotalPriceChange, +} from '@core/types'; import { merge } from 'lodash'; import { createContext, @@ -40,6 +45,7 @@ export interface BalancesState { loading: boolean; nfts?: Balances; tokens?: Balances; + atomic?: AtomicBalances; cached?: boolean; error?: string; } @@ -65,6 +71,7 @@ type BalanceAction = balances?: { nfts: Balances; tokens: Balances; + atomic: AtomicBalances; }; isBalancesCached?: boolean; }; @@ -101,6 +108,11 @@ const BalancesContext = createContext<{ priceChange: TotalPriceChange; } | undefined; + getAtomicBalanceForWallet: (walletId: string) => + | { + sum: number | null; + } + | undefined; }>({ balances: { loading: true }, getTokenPrice() { @@ -115,6 +127,9 @@ const BalancesContext = createContext<{ getTotalBalance() { return undefined; }, + getAtomicBalanceForWallet() { + return undefined; + }, }); function balancesReducer( @@ -329,6 +344,28 @@ export function BalancesProvider({ children }: PropsWithChildren) { ], ); + const getAtomicBalanceForWallet = useCallback( + (walletId: string) => { + console.log('getAtomicBalanceForWallet', { + walletId, + atomic: balances.atomic, + }); + // TODO: Implementation + // const networks = chainIds.map(getNetwork).filter(isNotNullish); + // + // if (balances.tokens && network?.chainId) { + // return calculateTotalBalance( + // getAccount(addressC), + // networks, + // balances.tokens, + // ); + // } + + return undefined; + }, + [balances.atomic], + ); + const getTokenPrice = useCallback( (addressOrSymbol: string, lookupNetwork?: NetworkWithCaipId) => { if (!activeAccount) { @@ -375,6 +412,7 @@ export function BalancesProvider({ children }: PropsWithChildren) { ? getTotalBalance(activeAccount.addressC) : undefined, getTotalBalance, + getAtomicBalanceForWallet, }} > {children} diff --git a/packages/ui/src/contexts/NetworkFeeProvider/NetworkFeeProvider.tsx b/packages/ui/src/contexts/NetworkFeeProvider/NetworkFeeProvider.tsx index b5d122228..584f690d9 100644 --- a/packages/ui/src/contexts/NetworkFeeProvider/NetworkFeeProvider.tsx +++ b/packages/ui/src/contexts/NetworkFeeProvider/NetworkFeeProvider.tsx @@ -191,7 +191,7 @@ export function NetworkFeeContextProvider({ children }: PropsWithChildren) { setIsGaslessEligible(false); setIsGaslessOn(false); return request({ - method: ExtensionRequest.GASLESS_SET_DEFAUlT_STATE_VALUES, + method: ExtensionRequest.GASLESS_SET_DEFAULT_STATE_VALUES, }); }, [request]); diff --git a/packages/ui/src/contexts/WalletTotalBalanceProvider.tsx b/packages/ui/src/contexts/WalletTotalBalanceProvider.tsx index 24900774d..f762cfe1a 100644 --- a/packages/ui/src/contexts/WalletTotalBalanceProvider.tsx +++ b/packages/ui/src/contexts/WalletTotalBalanceProvider.tsx @@ -12,12 +12,14 @@ import { IMPORTED_ACCOUNTS_WALLET_ID, TotalBalanceForWallet, ExtensionRequest, + TotalAtomicBalanceForWallet, } from '@core/types'; import { GetTotalBalanceForWalletHandler } from '@core/service-worker'; import { useAccountsContext } from './AccountsProvider'; import { useWalletContext } from './WalletProvider'; import { useConnectionContext } from './ConnectionProvider'; +import { GetTotalAtomicFundsForWalletHandler } from '~/services/balances/handlers/getTotalAtomicFundsForWallet'; interface WalletTotalBalanceContextProps { children?: React.ReactNode; @@ -28,10 +30,16 @@ export type WalletTotalBalanceState = Partial & { hasErrorOccurred: boolean; }; +export type WalletAtomicBalanceState = Partial & { + isLoading: boolean; + hasErrorOccurred: boolean; +}; + const WalletTotalBalanceContext = createContext< | { fetchBalanceForWallet(walletId: string): Promise; walletBalances: Record; + walletAtomicBalances: Record; } | undefined >(undefined); @@ -54,6 +62,50 @@ export const WalletTotalBalanceProvider = ({ Record >({}); + const [walletAtomicBalances, setWalletAtomicBalances] = useState< + Record + >({}); + + const fetchAtomicBalanceForWallet = useCallback( + async (walletId: string) => { + setWalletBalances((prevState) => ({ + ...prevState, + [walletId]: { + ...prevState[walletId], + hasErrorOccurred: false, + isLoading: true, + }, + })); + request({ + method: ExtensionRequest.GET_ATOMIC_FUNDS_FOR_WALLET, + params: { + walletId, + }, + }) + .then((atomicBalance) => { + setWalletAtomicBalances((prevState) => ({ + ...prevState, + [walletId]: { + balanceDisplayValue: atomicBalance.sum, + hasErrorOccurred: false, + isLoading: false, + }, + })); + }) + .catch((_err) => { + setWalletAtomicBalances((prevState) => ({ + ...prevState, + [walletId]: { + ...prevState[walletId], + hasErrorOccurred: true, + isLoading: false, + }, + })); + }); + }, + [request], + ); + const fetchBalanceForWallet = useCallback( async (walletId: string) => { setWalletBalances((prevState) => ({ @@ -100,7 +152,10 @@ export const WalletTotalBalanceProvider = ({ const fetchWalletBalancesSequentially = async (walletIds: string[]) => { for (const walletId of walletIds) { - await fetchBalanceForWallet(walletId); + await Promise.allSettled([ + fetchBalanceForWallet(walletId), + fetchAtomicBalanceForWallet(walletId), + ]); if (!isMounted) { return; } @@ -117,13 +172,19 @@ export const WalletTotalBalanceProvider = ({ return () => { isMounted = false; }; - }, [wallets, hasImportedAccounts, fetchBalanceForWallet]); + }, [ + wallets, + hasImportedAccounts, + fetchBalanceForWallet, + fetchAtomicBalanceForWallet, + ]); return ( {children} diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 92ecf126e..f4b6633b2 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -76,3 +76,4 @@ export * from './useSolanaPublicKeys'; export * from './useSeedlessActions'; export * from './useDuplicatedWalletChecker'; export * from './useDeriveMissingKeysForSeedless'; +export * from './useWalletTotalAtomicBalance'; diff --git a/packages/ui/src/hooks/useWalletTotalAtomicBalance.ts b/packages/ui/src/hooks/useWalletTotalAtomicBalance.ts new file mode 100644 index 000000000..504c05844 --- /dev/null +++ b/packages/ui/src/hooks/useWalletTotalAtomicBalance.ts @@ -0,0 +1,19 @@ +import { + useWalletTotalBalanceContext, + WalletAtomicBalanceState, +} from '../contexts/WalletTotalBalanceProvider'; + +const fallbackBalance: WalletAtomicBalanceState = { + isLoading: false, + hasErrorOccurred: false, +}; + +export const useWalletTotalAtomicBalance = (walletId?: string) => { + const { walletAtomicBalances } = useWalletTotalBalanceContext(); + + if (!walletId || !walletAtomicBalances[walletId]) { + return fallbackBalance; + } + + return walletAtomicBalances[walletId]; +}; diff --git a/src/tests/mockClientApis.ts b/src/tests/mockClientApis.ts index 556973580..2a2213e75 100644 --- a/src/tests/mockClientApis.ts +++ b/src/tests/mockClientApis.ts @@ -1,7 +1,11 @@ jest.mock('~/api-clients', () => ({ profileApiClient: jest.fn(), + balanceApiClient: jest.fn(), callGetAddresses: jest.fn(), })); jest.mock('~/api-clients/profile-api', () => ({ postV1GetAddresses: jest.fn(), })); +jest.mock('~/api-clients/balance-api', () => ({ + postV1BalanceGetBalances: jest.fn(), +})); From de8da1d07792a37675eeed182458c153f254d393 Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Tue, 2 Dec 2025 22:22:37 +0100 Subject: [PATCH 06/11] feat: create component for atomic balance --- .../PortfolioHome/PortfolioHome.tsx | 27 ++++-- .../components/AtomicFundsBalance.tsx | 67 +++++++++++++ .../WalletView/components/WalletDetails.tsx | 8 +- .../BalancesProvider/BalancesProvider.tsx | 93 ++++++++++++++----- .../contexts/WalletTotalBalanceProvider.tsx | 65 +------------ packages/ui/src/hooks/index.ts | 1 - .../src/hooks/useWalletTotalAtomicBalance.ts | 19 ---- 7 files changed, 159 insertions(+), 121 deletions(-) create mode 100644 apps/next/src/pages/Portfolio/components/PortfolioHome/components/AtomicFundsBalance.tsx delete mode 100644 packages/ui/src/hooks/useWalletTotalAtomicBalance.ts diff --git a/apps/next/src/pages/Portfolio/components/PortfolioHome/PortfolioHome.tsx b/apps/next/src/pages/Portfolio/components/PortfolioHome/PortfolioHome.tsx index 2ebdb2c53..d1e61738c 100644 --- a/apps/next/src/pages/Portfolio/components/PortfolioHome/PortfolioHome.tsx +++ b/apps/next/src/pages/Portfolio/components/PortfolioHome/PortfolioHome.tsx @@ -1,3 +1,6 @@ +import { FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory, useLocation } from 'react-router-dom'; import { alpha, CircularProgress, @@ -12,16 +15,15 @@ import { useBalancesContext, useNetworkContext, } from '@core/ui'; -import { FC, useState } from 'react'; + +import { TESTNET_MODE_BACKGROUND_COLOR } from '@/config/constants'; import { NoScrollStack } from '@/components/NoScrollStack'; +import { TestnetModeOverlay } from '@/components/TestnetModeOverlay'; import AccountInfo from './components/AccountInfo'; import { EmptyState } from './components/EmptyState'; import { PortfolioDetails } from './components/PortolioDetails'; -import { useTranslation } from 'react-i18next'; -import { TESTNET_MODE_BACKGROUND_COLOR } from '@/config/constants'; -import { TestnetModeOverlay } from '@/components/TestnetModeOverlay'; -import { useHistory, useLocation } from 'react-router-dom'; +import { AtomicFundsBalance } from './components/AtomicFundsBalance'; export type TabName = 'assets' | 'collectibles' | 'defi' | 'activity'; @@ -41,7 +43,11 @@ export const PortfolioHome: FC = () => { activeTabFromParams ?? 'assets', ); const { networks, isDeveloperMode } = useNetworkContext(); - const { totalBalance, balances } = useBalancesContext(); + const { totalBalance, balances, getAtomicBalance } = useBalancesContext(); + const walletId = + accounts.active?.type === 'primary' ? accounts.active.walletId : undefined; + const atomicBalance = getAtomicBalance(walletId); + const atomicBalanceExists = !!atomicBalance; const isLoading = !totalBalance; const isAccountEmpty = !hasAccountBalances( @@ -90,6 +96,15 @@ export const PortfolioHome: FC = () => { balance={totalBalance} isDeveloperMode={isDeveloperMode} /> + {!!walletId && + atomicBalanceExists && + (atomicBalance.isLoading ? ( + + ) : ( + + ))} {isLoading ? ( diff --git a/apps/next/src/pages/Portfolio/components/PortfolioHome/components/AtomicFundsBalance.tsx b/apps/next/src/pages/Portfolio/components/PortfolioHome/components/AtomicFundsBalance.tsx new file mode 100644 index 000000000..7cd914e00 --- /dev/null +++ b/apps/next/src/pages/Portfolio/components/PortfolioHome/components/AtomicFundsBalance.tsx @@ -0,0 +1,67 @@ +import { FC } from 'react'; +import { FiAlertCircle } from 'react-icons/fi'; +import { Box, Button, Stack, Typography, useTheme } from '@avalabs/k2-alpine'; +import { useTranslation } from 'react-i18next'; +import { Card } from '@/components/Card'; +import { CORE_WEB_BASE_URL } from '@/config'; + +type Props = { + atomicBalance: number; +}; + +// TODO: Multiple token support +export const AtomicFundsBalance: FC = ({ atomicBalance }) => { + const theme = useTheme(); + const { t } = useTranslation(); + console.log('asdasdasdasd', atomicBalance); + if (!atomicBalance) { + return <>; + } + + return ( + + + + + + + + + {t('Core has detected stuck funds')} + + + {t( + 'You have {{amount}} AVAX stuck in atomic memory from incomplete cross-chain transfers', + { + amount: atomicBalance, + }, + )} + + + + + + + ); +}; diff --git a/apps/next/src/pages/Portfolio/components/WalletView/components/WalletDetails.tsx b/apps/next/src/pages/Portfolio/components/WalletView/components/WalletDetails.tsx index d4e066e52..3eae5b8fe 100644 --- a/apps/next/src/pages/Portfolio/components/WalletView/components/WalletDetails.tsx +++ b/apps/next/src/pages/Portfolio/components/WalletView/components/WalletDetails.tsx @@ -5,11 +5,7 @@ import { WalletAccountsCard } from './WalletAccountsCard'; import { getAccountAvatars } from '../utils/accountAvatars'; import { useMemo } from 'react'; import { usePersonalAvatar } from '@/components/PersonalAvatar'; -import { - useAccountsContext, - useWalletTotalAtomicBalance, - useWalletTotalBalance, -} from '@core/ui'; +import { useAccountsContext, useWalletTotalBalance } from '@core/ui'; import { useNetworksWithBalance } from '../hooks/useNetworksWithBalance'; import { WalletDetails as WalletDetailsType } from '@core/types'; import { getNetworkCount } from '../utils/networkCount'; @@ -28,7 +24,6 @@ export const WalletDetails = ({ wallet }: Props) => { balanceChange, percentageChange, } = useWalletTotalBalance(wallet.id); - const { balanceDisplayValue } = useWalletTotalAtomicBalance(wallet.id); const { selected: { name: userAvatarName }, } = usePersonalAvatar(); @@ -50,7 +45,6 @@ export const WalletDetails = ({ wallet }: Props) => { return ( - balanceDisplayValue: {balanceDisplayValue} & { + isLoading: boolean; + hasErrorOccurred: boolean; +}; + const BalancesContext = createContext<{ balances: BalancesState; refreshNftMetadata( @@ -108,11 +115,9 @@ const BalancesContext = createContext<{ priceChange: TotalPriceChange; } | undefined; - getAtomicBalanceForWallet: (walletId: string) => - | { - sum: number | null; - } - | undefined; + getAtomicBalance: ( + walletId: string | undefined, + ) => WalletAtomicBalanceState | undefined; }>({ balances: { loading: true }, getTokenPrice() { @@ -127,7 +132,7 @@ const BalancesContext = createContext<{ getTotalBalance() { return undefined; }, - getAtomicBalanceForWallet() { + getAtomicBalance() { return undefined; }, }); @@ -182,6 +187,10 @@ export function BalancesProvider({ children }: PropsWithChildren) { cached: true, }); + const [walletAtomicBalances, setWalletAtomicBalances] = useState< + Record + >({}); + const [subscribers, setSubscribers] = useState({}); const registerSubscriber = useCallback((tokenTypes: TokenType[]) => { @@ -241,11 +250,55 @@ export function BalancesProvider({ children }: PropsWithChildren) { }); }, [request]); + const fetchAtomicBalanceForWallet = useCallback( + async (walletId: string) => { + setWalletAtomicBalances((prevState) => ({ + ...prevState, + [walletId]: { + ...prevState[walletId], + hasErrorOccurred: false, + isLoading: true, + }, + })); + request({ + method: ExtensionRequest.GET_ATOMIC_FUNDS_FOR_WALLET, + params: { + walletId, + }, + }) + .then((atomicBalance) => { + setWalletAtomicBalances((prevState) => ({ + ...prevState, + [walletId]: { + balanceDisplayValue: atomicBalance.sum, + hasErrorOccurred: false, + isLoading: false, + }, + })); + }) + .catch((_err) => { + setWalletAtomicBalances((prevState) => ({ + ...prevState, + [walletId]: { + ...prevState[walletId], + hasErrorOccurred: true, + isLoading: false, + }, + })); + }); + }, + [request], + ); + useEffect(() => { if (!activeAccount) { return; } + if ('walletId' in activeAccount) { + fetchAtomicBalanceForWallet(activeAccount.walletId); + } + const tokenTypes = Object.entries(subscribers) .filter(([, subscriberCount]) => subscriberCount > 0) .map(([tokenType]) => tokenType as TokenType); @@ -277,6 +330,7 @@ export function BalancesProvider({ children }: PropsWithChildren) { network?.chainId, enabledNetworkIds, subscribers, + fetchAtomicBalanceForWallet, ]); const updateBalanceOnNetworks = useCallback( @@ -344,26 +398,15 @@ export function BalancesProvider({ children }: PropsWithChildren) { ], ); - const getAtomicBalanceForWallet = useCallback( - (walletId: string) => { - console.log('getAtomicBalanceForWallet', { - walletId, - atomic: balances.atomic, - }); - // TODO: Implementation - // const networks = chainIds.map(getNetwork).filter(isNotNullish); - // - // if (balances.tokens && network?.chainId) { - // return calculateTotalBalance( - // getAccount(addressC), - // networks, - // balances.tokens, - // ); - // } + const getAtomicBalance = useCallback( + (walletId: string | undefined) => { + if (!walletId) { + return undefined; + } - return undefined; + return walletAtomicBalances[walletId]; }, - [balances.atomic], + [walletAtomicBalances], ); const getTokenPrice = useCallback( @@ -412,7 +455,7 @@ export function BalancesProvider({ children }: PropsWithChildren) { ? getTotalBalance(activeAccount.addressC) : undefined, getTotalBalance, - getAtomicBalanceForWallet, + getAtomicBalance, }} > {children} diff --git a/packages/ui/src/contexts/WalletTotalBalanceProvider.tsx b/packages/ui/src/contexts/WalletTotalBalanceProvider.tsx index f762cfe1a..24900774d 100644 --- a/packages/ui/src/contexts/WalletTotalBalanceProvider.tsx +++ b/packages/ui/src/contexts/WalletTotalBalanceProvider.tsx @@ -12,14 +12,12 @@ import { IMPORTED_ACCOUNTS_WALLET_ID, TotalBalanceForWallet, ExtensionRequest, - TotalAtomicBalanceForWallet, } from '@core/types'; import { GetTotalBalanceForWalletHandler } from '@core/service-worker'; import { useAccountsContext } from './AccountsProvider'; import { useWalletContext } from './WalletProvider'; import { useConnectionContext } from './ConnectionProvider'; -import { GetTotalAtomicFundsForWalletHandler } from '~/services/balances/handlers/getTotalAtomicFundsForWallet'; interface WalletTotalBalanceContextProps { children?: React.ReactNode; @@ -30,16 +28,10 @@ export type WalletTotalBalanceState = Partial & { hasErrorOccurred: boolean; }; -export type WalletAtomicBalanceState = Partial & { - isLoading: boolean; - hasErrorOccurred: boolean; -}; - const WalletTotalBalanceContext = createContext< | { fetchBalanceForWallet(walletId: string): Promise; walletBalances: Record; - walletAtomicBalances: Record; } | undefined >(undefined); @@ -62,50 +54,6 @@ export const WalletTotalBalanceProvider = ({ Record >({}); - const [walletAtomicBalances, setWalletAtomicBalances] = useState< - Record - >({}); - - const fetchAtomicBalanceForWallet = useCallback( - async (walletId: string) => { - setWalletBalances((prevState) => ({ - ...prevState, - [walletId]: { - ...prevState[walletId], - hasErrorOccurred: false, - isLoading: true, - }, - })); - request({ - method: ExtensionRequest.GET_ATOMIC_FUNDS_FOR_WALLET, - params: { - walletId, - }, - }) - .then((atomicBalance) => { - setWalletAtomicBalances((prevState) => ({ - ...prevState, - [walletId]: { - balanceDisplayValue: atomicBalance.sum, - hasErrorOccurred: false, - isLoading: false, - }, - })); - }) - .catch((_err) => { - setWalletAtomicBalances((prevState) => ({ - ...prevState, - [walletId]: { - ...prevState[walletId], - hasErrorOccurred: true, - isLoading: false, - }, - })); - }); - }, - [request], - ); - const fetchBalanceForWallet = useCallback( async (walletId: string) => { setWalletBalances((prevState) => ({ @@ -152,10 +100,7 @@ export const WalletTotalBalanceProvider = ({ const fetchWalletBalancesSequentially = async (walletIds: string[]) => { for (const walletId of walletIds) { - await Promise.allSettled([ - fetchBalanceForWallet(walletId), - fetchAtomicBalanceForWallet(walletId), - ]); + await fetchBalanceForWallet(walletId); if (!isMounted) { return; } @@ -172,19 +117,13 @@ export const WalletTotalBalanceProvider = ({ return () => { isMounted = false; }; - }, [ - wallets, - hasImportedAccounts, - fetchBalanceForWallet, - fetchAtomicBalanceForWallet, - ]); + }, [wallets, hasImportedAccounts, fetchBalanceForWallet]); return ( {children} diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index f4b6633b2..92ecf126e 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -76,4 +76,3 @@ export * from './useSolanaPublicKeys'; export * from './useSeedlessActions'; export * from './useDuplicatedWalletChecker'; export * from './useDeriveMissingKeysForSeedless'; -export * from './useWalletTotalAtomicBalance'; diff --git a/packages/ui/src/hooks/useWalletTotalAtomicBalance.ts b/packages/ui/src/hooks/useWalletTotalAtomicBalance.ts deleted file mode 100644 index 504c05844..000000000 --- a/packages/ui/src/hooks/useWalletTotalAtomicBalance.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - useWalletTotalBalanceContext, - WalletAtomicBalanceState, -} from '../contexts/WalletTotalBalanceProvider'; - -const fallbackBalance: WalletAtomicBalanceState = { - isLoading: false, - hasErrorOccurred: false, -}; - -export const useWalletTotalAtomicBalance = (walletId?: string) => { - const { walletAtomicBalances } = useWalletTotalBalanceContext(); - - if (!walletId || !walletAtomicBalances[walletId]) { - return fallbackBalance; - } - - return walletAtomicBalances[walletId]; -}; From e37ab75171b8f5ee95b64b21f78b1fa111862c27 Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Tue, 2 Dec 2025 22:25:34 +0100 Subject: [PATCH 07/11] feat: remove console.log --- .../components/PortfolioHome/components/AtomicFundsBalance.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/next/src/pages/Portfolio/components/PortfolioHome/components/AtomicFundsBalance.tsx b/apps/next/src/pages/Portfolio/components/PortfolioHome/components/AtomicFundsBalance.tsx index 7cd914e00..1d34a3773 100644 --- a/apps/next/src/pages/Portfolio/components/PortfolioHome/components/AtomicFundsBalance.tsx +++ b/apps/next/src/pages/Portfolio/components/PortfolioHome/components/AtomicFundsBalance.tsx @@ -13,7 +13,6 @@ type Props = { export const AtomicFundsBalance: FC = ({ atomicBalance }) => { const theme = useTheme(); const { t } = useTranslation(); - console.log('asdasdasdasd', atomicBalance); if (!atomicBalance) { return <>; } From 1887db2172f80a597bbe628701f6b78a458ec644 Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Tue, 2 Dec 2025 22:26:41 +0100 Subject: [PATCH 08/11] feat: generate translation --- apps/next/src/localization/locales/en/translation.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/next/src/localization/locales/en/translation.json b/apps/next/src/localization/locales/en/translation.json index 690ac3481..e01f413a7 100644 --- a/apps/next/src/localization/locales/en/translation.json +++ b/apps/next/src/localization/locales/en/translation.json @@ -146,6 +146,7 @@ "Choose Verification Method": "Choose Verification Method", "Choose a recovery method": "Choose a recovery method", "Choose a recovery method associated with your wallet.": "Choose a recovery method associated with your wallet.", + "Claim": "Claim", "Clear all": "Clear all", "Click for more details": "Click for more details", "Click on the “Get signature” button after signing the transaction with your Keystone device": "Click on the “Get signature” button after signing the transaction with your Keystone device", @@ -187,6 +188,7 @@ "Core Concierge": "Core Concierge", "Core always finds the best price from the top liquidity providers. A fee of {{coreFee}} is automatically factored into this quote.": "Core always finds the best price from the top liquidity providers. A fee of {{coreFee}} is automatically factored into this quote.", "Core functionality may be unstable with custom RPC URLs": "Core functionality may be unstable with custom RPC URLs", + "Core has detected stuck funds": "Core has detected stuck funds", "Core has entered an unexpected state. Please restart the browser if the issue persists.": "Core has entered an unexpected state. Please restart the browser if the issue persists.", "Core is committed to protecting your privacy. We will never sell or share your data. If you wish, you can disable this at any time in the settings menu.": "Core is committed to protecting your privacy. We will never sell or share your data. If you wish, you can disable this at any time in the settings menu.", "Core is no longer connected to your Keystone device. Reconnect to continue.": "Core is no longer connected to your Keystone device. Reconnect to continue.", @@ -838,6 +840,7 @@ "You cannot add a new recovery method for your wallet! Try again later!": "You cannot add a new recovery method for your wallet! Try again later!", "You do not have enough funds to cover the network fees.": "You do not have enough funds to cover the network fees.", "You have been logged out do to inactivity": "You have been logged out do to inactivity", + "You have {{amount}} AVAX stuck in atomic memory from incomplete cross-chain transfers": "You have {{amount}} AVAX stuck in atomic memory from incomplete cross-chain transfers", "You hid all your collectibles": "You hid all your collectibles", "You may need to enable popups to continue, you can find this setting near the address bar.": "You may need to enable popups to continue, you can find this setting near the address bar.", "You must allow access to scan the QR code.": "You must allow access to scan the QR code.", From af70b3a9de98fb47619f48bbe578c526cc1d4f7d Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Wed, 3 Dec 2025 09:09:38 +0100 Subject: [PATCH 09/11] feat: fix lint and type issues --- packages/common/src/feature-flags.ts | 2 ++ .../src/utils/balance/balanceToDecimal.test.ts | 11 +++++++++++ .../services/balances/BalanceAggregatorService.ts | 13 +++++++++++-- .../getTotalBalanceForWallet.test.ts | 11 +++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 packages/common/src/utils/balance/balanceToDecimal.test.ts diff --git a/packages/common/src/feature-flags.ts b/packages/common/src/feature-flags.ts index 9db1509e1..78ab06d4d 100644 --- a/packages/common/src/feature-flags.ts +++ b/packages/common/src/feature-flags.ts @@ -63,6 +63,7 @@ export const DISABLED_FLAG_VALUES: FeatureFlags = { [FeatureGates.CORE_ASSISTANT]: false, [FeatureGates.SWAP_USE_MARKR]: false, [FeatureVars.MARKR_SWAP_GAS_BUFFER]: '100', + [FeatureGates.BALANCE_SERVICE_INTEGRATION]: false, }; // Default flags are used when posthog is not available @@ -127,6 +128,7 @@ export const DEFAULT_FLAGS: FeatureFlags = { [FeatureGates.CORE_ASSISTANT]: true, [FeatureGates.SWAP_USE_MARKR]: true, [FeatureVars.MARKR_SWAP_GAS_BUFFER]: '120', + [FeatureGates.BALANCE_SERVICE_INTEGRATION]: false, }; export const FEATURE_FLAGS_OVERRIDES_KEY = '__feature-flag-overrides__'; diff --git a/packages/common/src/utils/balance/balanceToDecimal.test.ts b/packages/common/src/utils/balance/balanceToDecimal.test.ts new file mode 100644 index 000000000..be2067b22 --- /dev/null +++ b/packages/common/src/utils/balance/balanceToDecimal.test.ts @@ -0,0 +1,11 @@ +import { balanceToDecimal } from './balanceToDecimal'; + +describe('balanceToDecimal', () => { + it('calculates the balance correctly for a balance less than the decimals', () => { + expect(balanceToDecimal('1', 5)).toBe(0.00001); + }); + + it('calculates the balance correctly for a balance greater than the decimals', () => { + expect(balanceToDecimal('100000000000000000000', 18)).toBe(100); + }); +}); diff --git a/packages/service-worker/src/services/balances/BalanceAggregatorService.ts b/packages/service-worker/src/services/balances/BalanceAggregatorService.ts index 017ae2edd..e5d4464cb 100644 --- a/packages/service-worker/src/services/balances/BalanceAggregatorService.ts +++ b/packages/service-worker/src/services/balances/BalanceAggregatorService.ts @@ -13,6 +13,7 @@ import { BalanceServiceEvents, BalancesInfo, CachedBalancesInfo, + FeatureGates, priceChangeRefreshRate, PriceChangesData, TOKENS_PRICE_DATA, @@ -182,7 +183,11 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { return { tokens: {}, atomic: {} }; } - if (this.featureFlagService.featureFlags['balance-service-integration']) { + if ( + this.featureFlagService.featureFlags[ + FeatureGates.BALANCE_SERVICE_INTEGRATION + ] + ) { try { const getBalancesRequestBody = createGetBalancePayload( accounts, @@ -489,7 +494,11 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { } as BalancesInfo); // trying to get the balances of all the accounts upon unlock if we have the balance service integration turned on - if (this.featureFlagService.featureFlags['balance-service-integration']) { + if ( + this.featureFlagService.featureFlags[ + FeatureGates.BALANCE_SERVICE_INTEGRATION + ] + ) { try { const accountsService = container.resolve(AccountsService); const networkService = container.resolve(NetworkService); diff --git a/packages/service-worker/src/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts b/packages/service-worker/src/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts index af58350af..46f139c02 100644 --- a/packages/service-worker/src/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts +++ b/packages/service-worker/src/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts @@ -241,6 +241,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts balanceAggregatorService.getPriceChangesData.mockResolvedValue({}); balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [isMainnet ? ChainId.AVALANCHE_MAINNET_ID @@ -663,6 +664,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts // Percentage change: (10 / 90) * 100 = 11.11% balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEEDLESS.addressC]: { @@ -684,6 +686,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts // Percentage change: (-20 / 120) * 100 = -16.67% balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEEDLESS.addressC]: { @@ -714,6 +717,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts // First account return { nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEED_0.addressC]: { @@ -726,6 +730,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts // Second account return { nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEED_1.addressC]: { @@ -748,6 +753,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts it('returns undefined for balanceChange when priceChange is 0', async () => { balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEEDLESS.addressC]: { @@ -767,6 +773,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts it('returns undefined for balanceChange when no priceChanges data exists', async () => { balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEEDLESS.addressC]: { @@ -786,6 +793,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts it('returns undefined for percentageChange when totalBalance is 0', async () => { balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEEDLESS.addressC]: { @@ -807,6 +815,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts // Cannot calculate percentage change from 0 balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEEDLESS.addressC]: { @@ -831,6 +840,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts // Percentage: (15 / 135) * 100 = 11.11% balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEEDLESS.addressC]: { @@ -856,6 +866,7 @@ describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts // Percentage: (10 / 190) * 100 = 5.26% balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ nfts: {}, + atomic: {}, tokens: { [ChainId.AVALANCHE_MAINNET_ID]: { [ACCOUNT_SEEDLESS.addressC]: { From 277d5f1d1322fed59f0985c31042d3752ff18eed Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Wed, 3 Dec 2025 10:11:07 +0100 Subject: [PATCH 10/11] feat: fix tests in BalanceAggregatorService --- .../balances/BalanceAggregatorService.test.ts | 13 ++++++++----- .../balances/BalanceAggregatorService.ts | 19 ++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/service-worker/src/services/balances/BalanceAggregatorService.test.ts b/packages/service-worker/src/services/balances/BalanceAggregatorService.test.ts index 75118edce..beaeea6e5 100644 --- a/packages/service-worker/src/services/balances/BalanceAggregatorService.test.ts +++ b/packages/service-worker/src/services/balances/BalanceAggregatorService.test.ts @@ -248,11 +248,11 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { await service.getBalancesForNetworks( [network1.chainId], [account2], - [TokenType.NATIVE, TokenType.ERC20, TokenType.ERC721], + [TokenType.NATIVE, TokenType.ERC20], ); expect(balancesServiceMock.getBalancesForNetwork).toHaveBeenCalledTimes( - 2, + 1, ); expect(service.balances).toEqual({ @@ -267,7 +267,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { await service.getBalancesForNetworks( [network1.chainId], [account1], - [TokenType.NATIVE, TokenType.ERC20, TokenType.ERC721], + [TokenType.NATIVE, TokenType.ERC20], ); expect(balancesServiceMock.getBalancesForNetwork).toHaveBeenCalledTimes( @@ -291,7 +291,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { [account1], [TokenType.NATIVE, TokenType.ERC20, TokenType.ERC721], ); - expect(balancesServiceMock.getBalancesForNetwork).toBeCalledTimes(2); + expect(balancesServiceMock.getBalancesForNetwork).toBeCalledTimes(4); const expected = { tokens: { [network1.chainId]: { @@ -301,6 +301,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { }, [network2.chainId]: balanceForNetwork2, }, + atomic: {}, nfts: { [network1.chainId]: { [account1.addressC]: { @@ -323,6 +324,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { ); const expected = { + atomic: {}, tokens: { [network1.chainId]: balanceForTwoAccounts, [network2.chainId]: balanceForTwoAccounts, @@ -386,7 +388,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { [TokenType.NATIVE, TokenType.ERC20, TokenType.ERC721], ); - expect(balancesServiceMock.getBalancesForNetwork).toBeCalledTimes(2); + expect(balancesServiceMock.getBalancesForNetwork).toBeCalledTimes(4); const expectedTokens = { [network1.chainId]: { [account1.addressC]: { @@ -410,6 +412,7 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ balances: { + atomic: {}, tokens: expectedTokens, nfts: { [network1.chainId]: { diff --git a/packages/service-worker/src/services/balances/BalanceAggregatorService.ts b/packages/service-worker/src/services/balances/BalanceAggregatorService.ts index e5d4464cb..a3fdf87b2 100644 --- a/packages/service-worker/src/services/balances/BalanceAggregatorService.ts +++ b/packages/service-worker/src/services/balances/BalanceAggregatorService.ts @@ -1,4 +1,4 @@ -import { isEqual, partition, pick, merge } from 'lodash'; +import { isEqual, partition, get, merge } from 'lodash'; import { container, singleton } from 'tsyringe'; import { EventEmitter } from 'events'; import * as Sentry from '@sentry/browser'; @@ -293,8 +293,8 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { return Object.keys(networkBalances).some((fetchedAddress) => { const pathToCheck = [chainId, fetchedAddress]; return !isEqual( - pick(this.balances, pathToCheck), - pick(freshBalances.tokens, pathToCheck), + get(this.balances, pathToCheck), + get(freshBalances.tokens, pathToCheck), ); }); }, @@ -304,14 +304,15 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { return Object.keys(balances).some((fetchedAddress) => { const pathToCheck = [chainId, fetchedAddress]; return !isEqual( - pick(this.atomicBalances, pathToCheck), - pick(freshBalances.atomic, pathToCheck), + get(this.atomicBalances, pathToCheck), + get(freshBalances.atomic, pathToCheck), ); }); }, ); const hasNftChanges = !isEqual(aggregatedNfts, this.nfts); - const hasChanges = hasBalanceChanges || hasNftChanges; + const hasChanges = + hasBalanceChanges || hasNftChanges || hasAtomicBalanceChanges; const aggregatedBalances = { ...this.balances }; if (hasBalanceChanges) { @@ -345,11 +346,7 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { } } - if ( - cacheResponse && - (hasChanges || hasAtomicBalanceChanges) && - !this.lockService.locked - ) { + if (cacheResponse && hasChanges && !this.lockService.locked) { this.#balances = aggregatedBalances; this.#nfts = aggregatedNfts; this.#atomicBalances = aggregatedAtomicBalances; From 7bcd1e5d27ddb25810c8274f6a4f20a7443f6c48 Mon Sep 17 00:00:00 2001 From: Csaba Valyi Date: Wed, 3 Dec 2025 10:14:30 +0100 Subject: [PATCH 11/11] feat: fix utils tests --- .../src/api-clients/utils.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/service-worker/src/api-clients/utils.test.ts b/packages/service-worker/src/api-clients/utils.test.ts index 6d14b575d..009ebc892 100644 --- a/packages/service-worker/src/api-clients/utils.test.ts +++ b/packages/service-worker/src/api-clients/utils.test.ts @@ -93,6 +93,36 @@ describe('utils', () => { it('Should return the expected payload', () => { const expected = { data: [ + { + namespace: 'avax', + references: ['8aDU0Kqh-5d23op-B-r-4YbQFRbsgF9a'], + addressDetails: [ + { + addresses: ['avax1aa'], + walletId: 'avax1aa', + }, + { + addresses: ['avax1ab'], + walletId: 'avax1ab', + }, + { + addresses: ['avax1ba'], + walletId: 'avax1ba', + }, + { + addresses: ['avax1bb'], + walletId: 'avax1bb', + }, + { + addresses: ['avax1bc'], + walletId: 'avax1bc', + }, + { + addresses: ['avax1bd'], + walletId: 'avax1bd', + }, + ], + }, { namespace: 'eip155', references: ['43114', '1'],