From bedc114878206a3f822dea7192319e8b62df2bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Wed, 27 Nov 2024 15:05:38 +0100 Subject: [PATCH 1/9] fix: pchain balance breakdown --- .../PchainActiveNetworkWidgetContent.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pages/Home/components/Portfolio/NetworkWidget/PchainActiveNetworkWidgetContent.tsx b/src/pages/Home/components/Portfolio/NetworkWidget/PchainActiveNetworkWidgetContent.tsx index 56d4e74d..21cda220 100644 --- a/src/pages/Home/components/Portfolio/NetworkWidget/PchainActiveNetworkWidgetContent.tsx +++ b/src/pages/Home/components/Portfolio/NetworkWidget/PchainActiveNetworkWidgetContent.tsx @@ -2,6 +2,7 @@ import { Stack, Typography } from '@avalabs/core-k2-components'; import { useTranslation } from 'react-i18next'; import { BalanceColumn } from '@src/components/common/BalanceColumn'; import { TokenWithBalancePVM } from '@avalabs/vm-module-types'; +import { TokenUnit } from '@avalabs/core-utils-sdk'; interface PchainActiveNetworkWidgetContentProps { balances?: TokenWithBalancePVM; @@ -23,13 +24,13 @@ export function PchainActiveNetworkWidgetContent({ pendingStaked: t('Pending Staked'), }; - if (!balances) { + if (!balances?.balancePerType) { return null; } return ( <> - {Object.keys(typeDisplayNames).map((type) => { - const show = balances[type] && balances[type] > 0; + {Object.entries(balances.balancePerType).map(([type, balance]) => { + const show = balance > 0; if (!show) { return null; @@ -59,7 +60,12 @@ export function PchainActiveNetworkWidgetContent({ data-testid="token-row-token-balance" variant="caption" > - {`${balances[type]} AVAX`} + {new TokenUnit( + balance, + balances.decimals, + balances.symbol + ).toDisplay()}{' '} + AVAX From 25a8b9e769a8b4dc71bafec8cd18d6d252f03e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Wed, 27 Nov 2024 15:06:02 +0100 Subject: [PATCH 2/9] fix: balance cached not being updated --- src/background/services/balances/BalanceAggregatorService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/background/services/balances/BalanceAggregatorService.ts b/src/background/services/balances/BalanceAggregatorService.ts index c6951cc2..30768dbf 100644 --- a/src/background/services/balances/BalanceAggregatorService.ts +++ b/src/background/services/balances/BalanceAggregatorService.ts @@ -143,6 +143,7 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { for (const [chainId, chainBalances] of freshData) { for (const [address, addressBalance] of Object.entries(chainBalances)) { aggregatedBalances[chainId] = { + ...aggregatedBalances[chainId], ...chainBalances, [address]: addressBalance, }; From ca5bde65d15cb347df49536e4942cd6cfa892a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Wed, 27 Nov 2024 15:06:35 +0100 Subject: [PATCH 3/9] feat: total wallet balance + xp rollup --- .../connections/extensionConnection/models.ts | 1 + .../extensionConnection/registry.ts | 5 + .../services/accounts/AccountsService.ts | 4 + .../handlers/avalanche_getAddressesInRange.ts | 45 ++-- .../accounts/utils/getAddressesInRange.ts | 21 ++ .../getTotalBalanceForWallet.ts | 120 ++++++++++ .../getTotalBalanceForWallet/helpers.ts | 207 ++++++++++++++++++ .../getTotalBalanceForWallet/index.ts | 3 + .../getTotalBalanceForWallet/models.ts | 23 ++ .../services/glacier/GlacierService.ts | 20 +- src/contexts/BalancesProvider.tsx | 28 +-- src/localization/locales/en/translation.json | 1 + src/pages/Accounts/Accounts.tsx | 6 - .../components/AccountListImported.tsx | 5 +- .../Accounts/components/WalletContainer.tsx | 49 ++++- .../Accounts/components/WalletHeader.tsx | 13 +- .../Accounts/hooks/useWalletTotalBalance.tsx | 56 +++++ 17 files changed, 547 insertions(+), 60 deletions(-) create mode 100644 src/background/services/accounts/utils/getAddressesInRange.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/index.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts create mode 100644 src/pages/Accounts/hooks/useWalletTotalBalance.tsx diff --git a/src/background/connections/extensionConnection/models.ts b/src/background/connections/extensionConnection/models.ts index 8104c38e..94b73d7f 100644 --- a/src/background/connections/extensionConnection/models.ts +++ b/src/background/connections/extensionConnection/models.ts @@ -25,6 +25,7 @@ export enum ExtensionRequest { BALANCES_GET = 'balances_get', BALANCES_START_POLLING = 'balances_start_polling', BALANCES_STOP_POLLING = 'balances_stop_polling', + BALANCES_GET_TOTAL_FOR_WALLET = 'balance_get_total_for_wallet', NETWORK_BALANCES_UPDATE = 'network_balances_update', NFT_BALANCES_GET = 'nft_balances_get', NFT_REFRESH_METADATA = 'nft_refresh_metadata', diff --git a/src/background/connections/extensionConnection/registry.ts b/src/background/connections/extensionConnection/registry.ts index 49f607ce..f2e7e486 100644 --- a/src/background/connections/extensionConnection/registry.ts +++ b/src/background/connections/extensionConnection/registry.ts @@ -129,6 +129,7 @@ import { StopBalancesPollingHandler } from '@src/background/services/balances/ha import { BalancesUpdatedEvents } from '@src/background/services/balances/events/balancesUpdatedEvent'; import { UnifiedBridgeTrackTransfer } from '@src/background/services/unifiedBridge/handlers/unifiedBridgeTrackTransfer'; import { UpdateActionTxDataHandler } from '@src/background/services/actions/handlers/updateTxData'; +import { GetTotalBalanceForWalletHandler } from '@src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet'; /** * TODO: GENERATE THIS FILE AS PART OF THE BUILD PROCESS @@ -373,6 +374,10 @@ import { UpdateActionTxDataHandler } from '@src/background/services/actions/hand token: 'ExtensionRequestHandler', useToken: StopBalancesPollingHandler, }, + { + token: 'ExtensionRequestHandler', + useToken: GetTotalBalanceForWalletHandler, + }, ]) export class ExtensionRequestHandlerRegistry {} diff --git a/src/background/services/accounts/AccountsService.ts b/src/background/services/accounts/AccountsService.ts index 43bc8e0f..919e11d8 100644 --- a/src/background/services/accounts/AccountsService.ts +++ b/src/background/services/accounts/AccountsService.ts @@ -333,6 +333,10 @@ export class AccountsService implements OnLock, OnUnlock { ); } + getPrimaryAccountsByWalletId(walletId: string) { + return this.accounts.primary[walletId] ?? []; + } + #buildAccount( accountData, importType: ImportType, diff --git a/src/background/services/accounts/handlers/avalanche_getAddressesInRange.ts b/src/background/services/accounts/handlers/avalanche_getAddressesInRange.ts index 2e85c3ba..80a1c847 100644 --- a/src/background/services/accounts/handlers/avalanche_getAddressesInRange.ts +++ b/src/background/services/accounts/handlers/avalanche_getAddressesInRange.ts @@ -2,7 +2,6 @@ import { ethErrors } from 'eth-rpc-errors'; import { injectable } from 'tsyringe'; import { DAppRequestHandler } from '@src/background/connections/dAppConnection/DAppRequestHandler'; import { DAppProviderRequest } from '@src/background/connections/dAppConnection/models'; -import { Avalanche } from '@avalabs/core-wallets-sdk'; import { SecretsService } from '../../secrets/SecretsService'; import { NetworkService } from '../../network/NetworkService'; import { canSkipApproval } from '@src/utils/canSkipApproval'; @@ -21,6 +20,7 @@ type Params = [ internalLimit: number ]; import { AccountsService } from '../AccountsService'; +import { getAddressesInRange } from '../utils/getAddressesInRange'; const EXPOSED_DOMAINS = [ 'develop.avacloud-app.pages.dev', @@ -71,38 +71,23 @@ export class AvalancheGetAddressesInRangeHandler extends DAppRequestHandler< if (secrets?.xpubXP) { if (externalLimit > 0) { - for ( - let index = externalStart; - index < externalStart + externalLimit; - index++ - ) { - addresses.external.push( - Avalanche.getAddressFromXpub( - secrets.xpubXP, - index, - provXP, - 'X' - ).split('-')[1] as string // since addresses are the same for X/P we return them without the chain alias prefix (e.g.: fuji1jsduya7thx2ayrawf9dnw7v9jz7vc6xjycra2m) - ); - } + addresses.external = getAddressesInRange( + secrets.xpubXP, + provXP, + false, + externalStart, + externalLimit + ); } if (internalLimit > 0) { - for ( - let index = internalStart; - index < internalStart + internalLimit; - index++ - ) { - addresses.internal.push( - Avalanche.getAddressFromXpub( - secrets.xpubXP, - index, - provXP, - 'X', - true - ).split('-')[1] as string // only X has "internal" (change) addresses, but we remove the chain alias here as well to make it consistent with the external address list - ); - } + addresses.internal = getAddressesInRange( + secrets.xpubXP, + provXP, + true, + internalStart, + internalLimit + ); } } diff --git a/src/background/services/accounts/utils/getAddressesInRange.ts b/src/background/services/accounts/utils/getAddressesInRange.ts new file mode 100644 index 00000000..2ff423ef --- /dev/null +++ b/src/background/services/accounts/utils/getAddressesInRange.ts @@ -0,0 +1,21 @@ +import { Avalanche } from '@avalabs/core-wallets-sdk'; + +export function getAddressesInRange( + xpubXP: string, + providerXP: Avalanche.JsonRpcProvider, + internal = false, + start = 0, + limit = 64 +) { + const addresses: string[] = []; + + for (let i = start; i < start + limit; i++) { + addresses.push( + Avalanche.getAddressFromXpub(xpubXP, i, providerXP, 'P', internal).split( + '-' + )[1] as string + ); + } + + return addresses; +} diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts new file mode 100644 index 00000000..f51d2757 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts @@ -0,0 +1,120 @@ +import { injectable } from 'tsyringe'; +import { Network } from '@avalabs/glacier-sdk'; +import { TokenType } from '@avalabs/vm-module-types'; +import { uniq } from 'lodash'; + +import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; +import { ExtensionRequestHandler } from '@src/background/connections/models'; + +import { SecretsService } from '../../../secrets/SecretsService'; +import { AccountsService } from '../../../accounts/AccountsService'; +import { GlacierService } from '../../../glacier/GlacierService'; +import { NetworkService } from '../../../network/NetworkService'; +import { BalanceAggregatorService } from '../../BalanceAggregatorService'; +import { Account } from '../../../accounts/models'; + +import { GetWalletsWithActivityParams, TotalBalanceForWallet } from './models'; +import { + calculateTotalBalanceForAddresses, + getAccountsWithActivity, + getAllAddressesForAccounts, + getIncludedNetworks, +} from './helpers'; + +type HandlerType = ExtensionRequestHandler< + ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET, + TotalBalanceForWallet, + GetWalletsWithActivityParams +>; + +@injectable() +export class GetTotalBalanceForWalletHandler implements HandlerType { + method = ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET as const; + + constructor( + private secretService: SecretsService, + private glacierService: GlacierService, + private networkService: NetworkService, + private accountsService: AccountsService, + private balanceAggregatorService: BalanceAggregatorService + ) {} + + #getAddressesActivity = (addresses: string[]) => + this.glacierService.getChainIdsForAddresses({ + addresses, + network: this.networkService.isMainnet() ? Network.MAINNET : Network.FUJI, + }); + + handle: HandlerType['handle'] = async ({ request }) => { + const { walletId } = request.params; + const secrets = await this.secretService.getWalletAccountsSecretsById( + walletId + ); + + if (!secrets.xpubXP) { + return { + ...request, + error: 'not available for this wallet', // TODO + }; + } + + try { + const walletAccounts = + this.accountsService.getPrimaryAccountsByWalletId(walletId); + const derivedWaletAddresses = getAllAddressesForAccounts(walletAccounts); + + const underivedPChainAddresses = Object.keys( + await getAccountsWithActivity( + secrets.xpubXP, + await this.networkService.getAvalanceProviderXP(), + this.#getAddressesActivity + ) + ).filter( + (address) => !derivedWaletAddresses.includes(address.toLowerCase()) + ); + + const networksIncludedInRollup = getIncludedNetworks( + this.networkService.isMainnet(), + await this.networkService.activeNetworks.promisify(), + await this.networkService.getFavoriteNetworks() + ); + + await this.balanceAggregatorService.getBalancesForNetworks( + networksIncludedInRollup, + [ + ...walletAccounts, + ...underivedPChainAddresses.map( + (address) => + ({ + walletId, + addressPVM: address, + addressAVM: address, + } as Account) + ), + ], + [TokenType.NATIVE, TokenType.ERC20] + ); + + const allWalletAddresses = uniq([ + ...derivedWaletAddresses, + ...underivedPChainAddresses, + ]); + + const result = calculateTotalBalanceForAddresses( + this.balanceAggregatorService.balances, + allWalletAddresses, + networksIncludedInRollup + ); + + return { + ...request, + result, + }; + } catch (e: any) { + return { + ...request, + error: e.toString(), + }; + } + }; +} diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts new file mode 100644 index 00000000..94ccd8b2 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts @@ -0,0 +1,207 @@ +import { ChainAddressChainIdMap } from '@avalabs/glacier-sdk'; +import { + ADDRESS_GAP_LIMIT, + AddressActivityFetcher, + GLACIER_ADDRESS_FETCH_LIMIT, + ITERATION_LIMIT, + SearchSpace, + TotalBalanceForWallet, + XPAddressDictionary, +} from './models'; +import { ChainListWithCaipIds } from '@src/background/services/network/models'; +import { ChainId } from '@avalabs/core-chains-sdk'; +import { isString, toLower, uniq } from 'lodash'; +import { Account } from '@src/background/services/accounts/models'; +import getAllAddressesForAccount from '@src/utils/getAllAddressesForAccount'; +import { Avalanche } from '@avalabs/core-wallets-sdk'; +import { getAddressesInRange } from '@src/background/services/accounts/utils/getAddressesInRange'; +import { Balances } from '../../models'; +import { isNFT } from '../../nft/utils/isNFT'; + +function isDone(currentGap: number) { + return currentGap > ADDRESS_GAP_LIMIT; +} + +async function processGlacierAddresses( + addresses: string[], + fetchActivity: AddressActivityFetcher, + space: SearchSpace, + start: number, + gap: number +) { + if (isDone(gap)) { + return { gap, result: {} }; + } else { + const { addresses: glacierAddresses } = await fetchActivity(addresses); + + const seenByGlacier: Record = + glacierAddresses.reduce( + (acc, addressInfo) => ({ + ...acc, + [addressInfo.address]: addressInfo, + }), + {} + ); + const result: XPAddressDictionary = {}; + for (let i = 0; i < addresses.length && !isDone(gap); i++) { + const address = addresses[i]; + if (address && address in seenByGlacier) { + result[address] = { space, index: start + i }; + gap = 0; + } else { + gap += 1; + } + } + + return { gap, result }; + } +} + +function getIncludedNetworks( + isMainnet: boolean, + currentChainList: ChainListWithCaipIds, + favoriteChainIds: number[] +) { + const cChainId = isMainnet + ? ChainId.AVALANCHE_MAINNET_ID + : ChainId.AVALANCHE_TESTNET_ID; + const xChainId = isMainnet ? ChainId.AVALANCHE_X : ChainId.AVALANCHE_TEST_X; + const pChainId = isMainnet ? ChainId.AVALANCHE_P : ChainId.AVALANCHE_TEST_P; + const currentEnvNetworks = Object.keys(currentChainList).map(Number); + + return uniq( + [cChainId, pChainId, xChainId, ...favoriteChainIds].filter((chainId) => + currentEnvNetworks.includes(chainId) + ) + ); +} + +function getAllAddressesForAccounts(accounts: Account[]): string[] { + return accounts + .flatMap(getAllAddressesForAccount) + .filter(isString) + .map(toLower) + .map((addr) => addr.replace(/^[PXC]-/i, '')); +} + +async function getAccountsWithActivity( + xpubXP: string, + providerXP: Avalanche.JsonRpcProvider, + activityFetcher: AddressActivityFetcher +) { + let externalGap = 0; + let internalGap = 0; + let externalStart = 0; + let internalStart = 0; + let tooManyIterations = false; + let result: XPAddressDictionary = {}; + let iteration = 0; + + while (!isDone(externalGap) && !isDone(internalGap) && !tooManyIterations) { + const external = getAddressesInRange( + xpubXP, + providerXP, + false, + externalStart + ); + const internal = getAddressesInRange( + xpubXP, + providerXP, + true, + internalStart + ); + const [externalResult, internalResult] = await Promise.all([ + processGlacierAddresses( + external, + activityFetcher, + 'e', + externalStart, + externalGap + ), + processGlacierAddresses( + internal, + activityFetcher, + 'i', + internalStart, + internalGap + ), + ]); + + result = { ...result, ...externalResult.result }; + result = { ...result, ...internalResult.result }; + + externalGap = externalResult.gap; + externalStart += GLACIER_ADDRESS_FETCH_LIMIT; + + internalGap = internalResult.gap; + internalStart += GLACIER_ADDRESS_FETCH_LIMIT; + + iteration += 1; + tooManyIterations = iteration >= ITERATION_LIMIT; + } + + return result; +} + +function calculateTotalBalanceForAddresses( + balances: Balances, + addresses: string[], + chainIds: number[] +): TotalBalanceForWallet { + const allBalances = Object.entries(balances); + const stringChainIds = chainIds.map(String); + + const result = { + totalBalanceInCurrency: 0, + hasBalanceOnUnderivedAccounts: false, + hasBalanceOfUnknownFiatValue: false, + }; + + for (const [chainId, chainBalances] of allBalances) { + if (!stringChainIds.includes(chainId)) { + console.log('DEBUG skipping not-favorite chain', chainId); + continue; + } + + for (const [address, addressBalanceOnChain] of Object.entries( + chainBalances + )) { + if (!addresses.includes(address.toLowerCase())) { + console.log('DEBUG skipping address', address.toLowerCase()); + continue; + } + + for (const tokenBalance of Object.values(addressBalanceOnChain)) { + if (isNFT(tokenBalance)) { + console.log('DEBUG skipping NFT', tokenBalance); + continue; + } + + if (typeof tokenBalance.balanceInCurrency !== 'number') { + result.hasBalanceOfUnknownFiatValue = true; + continue; + } + + if ( + !result.hasBalanceOnUnderivedAccounts && + addresses.includes(address) + ) { + result.hasBalanceOnUnderivedAccounts = true; + } + + result.totalBalanceInCurrency += tokenBalance.balanceInCurrency; + } + } + } + + return result; +} + +export { + calculateTotalBalanceForAddresses, + getAccountsWithActivity, + getAllAddressesForAccounts, + getIncludedNetworks, + isDone, + processGlacierAddresses, +}; diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/index.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/index.ts new file mode 100644 index 00000000..d05050d4 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/index.ts @@ -0,0 +1,3 @@ +import { GetTotalBalanceForWalletHandler } from './getTotalBalanceForWallet'; + +export { GetTotalBalanceForWalletHandler }; diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts new file mode 100644 index 00000000..ebf36216 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts @@ -0,0 +1,23 @@ +import { ChainAddressChainIdMapListResponse } from '@avalabs/glacier-sdk'; + +export type GetWalletsWithActivityParams = { + walletId: string; +}; + +export type TotalBalanceForWallet = { + totalBalanceInCurrency?: number; + hasBalanceOnUnderivedAccounts: boolean; + hasBalanceOfUnknownFiatValue: boolean; +}; +export type SearchSpace = 'i' | 'e'; +export type XPAddress = string; +export type XPAddressData = { index: number; space: SearchSpace }; +export type XPAddressDictionary = Record; + +export type AddressActivityFetcher = ( + addresses: string[] +) => Promise; + +export const ADDRESS_GAP_LIMIT = 20; +export const GLACIER_ADDRESS_FETCH_LIMIT = 64; // Requested addresses are encoded as query params, and Glacier enforces URI length limits +export const ITERATION_LIMIT = 10; // Abitrary number to avoid an infinite loop. diff --git a/src/background/services/glacier/GlacierService.ts b/src/background/services/glacier/GlacierService.ts index 776164dc..7ee7625c 100644 --- a/src/background/services/glacier/GlacierService.ts +++ b/src/background/services/glacier/GlacierService.ts @@ -1,4 +1,9 @@ -import { Erc1155Token, Erc721Token, Glacier } from '@avalabs/glacier-sdk'; +import { + Erc1155Token, + Erc721Token, + Glacier, + Network, +} from '@avalabs/glacier-sdk'; import { singleton } from 'tsyringe'; import { wait } from '@avalabs/core-utils-sdk'; @@ -13,6 +18,19 @@ export class GlacierService { HEADERS, }); + async getChainIdsForAddresses({ + addresses, + network, + }: { + addresses: string[]; + network: Network; + }) { + return this.glacierSdkInstance.primaryNetwork.getChainIdsForAddresses({ + addresses: addresses.join(','), + network, + }); + } + async refreshNftMetadata(address: string, chainId: string, tokenId: string) { const requestTimestamp = Math.floor(Date.now() / 1000); const maxAttempts = 10; // Amount of fetches after which we give up. diff --git a/src/contexts/BalancesProvider.tsx b/src/contexts/BalancesProvider.tsx index a1aff4db..637ca3d1 100644 --- a/src/contexts/BalancesProvider.tsx +++ b/src/contexts/BalancesProvider.tsx @@ -167,8 +167,6 @@ export function BalancesProvider({ children }: { children: any }) { }); const [subscribers, setSubscribers] = useState({}); - const [isPolling, setIsPolling] = useState(false); - const polledChainIds = useMemo( () => favoriteNetworks.map(({ chainId }) => chainId), [favoriteNetworks] @@ -236,11 +234,11 @@ export function BalancesProvider({ children }: { children: any }) { return; } - if (isPolling) { - const tokenTypes = Object.entries(subscribers) - .filter(([, subscriberCount]) => subscriberCount > 0) - .map(([tokenType]) => tokenType as TokenType); + const tokenTypes = Object.entries(subscribers) + .filter(([, subscriberCount]) => subscriberCount > 0) + .map(([tokenType]) => tokenType as TokenType); + if (tokenTypes.length > 0) { request({ method: ExtensionRequest.BALANCES_START_POLLING, params: [activeAccount, polledChainIds, tokenTypes], @@ -250,6 +248,10 @@ export function BalancesProvider({ children }: { children: any }) { payload: balancesData, }); }); + } else { + request({ + method: ExtensionRequest.BALANCES_STOP_POLLING, + }); } return () => { @@ -257,19 +259,7 @@ export function BalancesProvider({ children }: { children: any }) { method: ExtensionRequest.BALANCES_STOP_POLLING, }); }; - }, [ - request, - isPolling, - activeAccount, - network?.chainId, - polledChainIds, - subscribers, - ]); - - useEffect(() => { - // Toggle balance polling based on the amount of dependent components. - setIsPolling(Object.values(subscribers).some((count) => count > 0)); - }, [subscribers]); + }, [request, activeAccount, network?.chainId, polledChainIds, subscribers]); const updateBalanceOnNetworks = useCallback( async (accounts: Account[], chainIds?: number[]) => { diff --git a/src/localization/locales/en/translation.json b/src/localization/locales/en/translation.json index 1a4401a4..c1d3219e 100644 --- a/src/localization/locales/en/translation.json +++ b/src/localization/locales/en/translation.json @@ -981,6 +981,7 @@ "View All Networks": "View All Networks", "View Balance": "View Balance", "View Details": "View Details", + "View P-Chain Details": "View P-Chain Details", "View QR code to scan with your authenticator app.": "View QR code to scan with your authenticator app.", "View Status": "View Status", "View in Explorer": "View in Explorer", diff --git a/src/pages/Accounts/Accounts.tsx b/src/pages/Accounts/Accounts.tsx index ee51e40b..c83a2994 100644 --- a/src/pages/Accounts/Accounts.tsx +++ b/src/pages/Accounts/Accounts.tsx @@ -12,7 +12,6 @@ import { } from '@avalabs/core-k2-components'; import { t } from 'i18next'; import { useHistory } from 'react-router-dom'; -import { TokenType } from '@avalabs/vm-module-types'; import { useAccountsContext } from '@src/contexts/AccountsProvider'; import { useLedgerContext } from '@src/contexts/LedgerProvider'; @@ -20,7 +19,6 @@ import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; import { LedgerApprovalDialog } from '@src/pages/SignTransaction/components/LedgerApprovalDialog'; import { AccountType } from '@src/background/services/accounts/models'; -import { useLiveBalance } from '@src/hooks/useLiveBalance'; import { useScopedToast } from '@src/hooks/useScopedToast'; import { NetworkSwitcher } from '@src/components/common/header/NetworkSwitcher'; import { Overlay } from '@src/components/common/Overlay'; @@ -36,8 +34,6 @@ import { AccountListImported } from './components/AccountListImported'; import { AccountsActionButton } from './components/AccountsActionButton'; import { OverflowingTypography } from './components/OverflowingTypography'; -const POLLED_BALANCES = [TokenType.NATIVE, TokenType.ERC20]; - export function Accounts() { const { selectAccount, @@ -64,8 +60,6 @@ export function Accounts() { [active?.addressC, getTotalBalance] ); - useLiveBalance(POLLED_BALANCES); - const addAccountAndFocus = async () => { setAddAccountLoading(true); diff --git a/src/pages/Accounts/components/AccountListImported.tsx b/src/pages/Accounts/components/AccountListImported.tsx index b0540097..0495b73e 100644 --- a/src/pages/Accounts/components/AccountListImported.tsx +++ b/src/pages/Accounts/components/AccountListImported.tsx @@ -7,8 +7,8 @@ import { useAccountsContext } from '@src/contexts/AccountsProvider'; import { isImportedAccount } from '@src/background/services/accounts/utils/typeGuards'; import { SelectionMode } from '../providers/AccountManagerProvider'; -import WalletHeaderNew from './WalletHeader'; import { AccountItem } from './AccountItem'; +import WalletHeader from './WalletHeader'; type AccountListProps = { accounts: Account[]; @@ -22,8 +22,9 @@ export const AccountListImported = ({ accounts }: AccountListProps) => { return ( - setIsExpanded((e) => !e)} diff --git a/src/pages/Accounts/components/WalletContainer.tsx b/src/pages/Accounts/components/WalletContainer.tsx index deac59d1..faa7005e 100644 --- a/src/pages/Accounts/components/WalletContainer.tsx +++ b/src/pages/Accounts/components/WalletContainer.tsx @@ -1,13 +1,22 @@ import { useState } from 'react'; -import { Collapse, Stack } from '@avalabs/core-k2-components'; +import { + Button, + Collapse, + Grow, + OutboundIcon, + Stack, +} from '@avalabs/core-k2-components'; +import { useTranslation } from 'react-i18next'; import { SecretType } from '@src/background/services/secrets/models'; import { WalletDetails } from '@src/background/services/wallet/models'; import { PrimaryAccount } from '@src/background/services/accounts/models'; +import { useWalletTotalBalance } from '../hooks/useWalletTotalBalance'; import { SelectionMode } from '../providers/AccountManagerProvider'; import { AccountItem } from './AccountItem'; import WalletHeader from './WalletHeader'; +import { useNetworkContext } from '@src/contexts/NetworkProvider'; export const WalletContainer = ({ walletDetails, @@ -19,7 +28,16 @@ export const WalletContainer = ({ isActive: boolean; accounts: PrimaryAccount[]; }) => { + const { t } = useTranslation(); + const { isDeveloperMode } = useNetworkContext(); const [isExpanded, setIsExpanded] = useState(true); + const { + isLoading, + // hasBalanceOfUnknownFiatValue, + hasBalanceOnUnderivedAccounts, + // hasErrorOccurred, + totalBalanceInCurrency, + } = useWalletTotalBalance(walletDetails); return ( @@ -27,6 +45,8 @@ export const WalletContainer = ({ walletDetails={walletDetails} isActive={isActive} isExpanded={isExpanded} + isLoading={isLoading} + totalBalance={totalBalanceInCurrency} toggle={() => setIsExpanded((e) => !e)} /> @@ -44,6 +64,33 @@ export const WalletContainer = ({ /> ))} + + + + + ); diff --git a/src/pages/Accounts/components/WalletHeader.tsx b/src/pages/Accounts/components/WalletHeader.tsx index 3fd5309d..09144945 100644 --- a/src/pages/Accounts/components/WalletHeader.tsx +++ b/src/pages/Accounts/components/WalletHeader.tsx @@ -5,6 +5,7 @@ import { Grow, IconButton, LedgerIcon, + LoadingDotsIcon, PencilRoundIcon, Stack, Typography, @@ -17,6 +18,7 @@ import { WalletDetails } from '@src/background/services/wallet/models'; import { useAccountManager } from '../providers/AccountManagerProvider'; import { OverflowingTypography } from './OverflowingTypography'; import { useWalletRename } from '../hooks/useWalletRename'; +import { useSettingsContext } from '@src/contexts/SettingsProvider'; const commonTransitionProps = { timeout: 200, @@ -26,6 +28,8 @@ const commonTransitionProps = { type WalletHeaderProps = { isActive: boolean; isExpanded: boolean; + isLoading: boolean; + totalBalance?: number; toggle: () => void; } & ( | { @@ -40,10 +44,13 @@ export default function WalletHeader({ name, isActive, isExpanded, + isLoading, + totalBalance, toggle, }: WalletHeaderProps) { const { t } = useTranslation(); const { isManageMode } = useAccountManager(); + const { currencyFormatter } = useSettingsContext(); const [isHovered, setIsHovered] = useState(false); const { prompt: promptRename, renderDialog: renameDialog } = @@ -109,7 +116,11 @@ export default function WalletHeader({ textAlign="end" color="text.secondary" > - {/* TODO: total balance of the entire wallet */} + {isLoading ? ( + + ) : typeof totalBalance === 'number' ? ( + currencyFormatter(totalBalance) + ) : null} { + const [isLoading, setIsLoading] = useState(false); + const [hasErrorOccurred, setHasErrorOccurred] = useState(false); + const [totalBalanceInfo, setTotalBalanceInfo] = + useState({ + hasBalanceOfUnknownFiatValue: false, + hasBalanceOnUnderivedAccounts: false, + totalBalanceInCurrency: undefined, + }); + + const { request } = useConnectionContext(); + + const fetchBalance = useCallback( + (walletId: string) => + request({ + method: ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET, + params: { + walletId, + }, + }), + [request] + ); + + useEffect(() => { + if (!walletDetails?.id) { + return; + } + + setIsLoading(true); + fetchBalance(walletDetails.id) + .then((info) => { + setHasErrorOccurred(false); + setTotalBalanceInfo(info); + }) + .catch((err) => { + console.log('Error while fetching total balance for wallet', err); + setHasErrorOccurred(true); + }) + .finally(() => { + setIsLoading(false); + }); + }, [fetchBalance, walletDetails?.id]); + + return { + isLoading, + hasErrorOccurred, + ...totalBalanceInfo, + }; +}; From d26c538128b1b508223c092442581add00081268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Thu, 28 Nov 2024 15:08:01 +0100 Subject: [PATCH 4/9] fix: balance caching bugs, ui improvements, cleanup --- .../balances/BalanceAggregatorService.ts | 5 +- .../getTotalBalanceForWallet.ts | 144 +++++++++++------- .../getTotalBalanceForWallet/helpers.ts | 84 +++------- .../getTotalBalanceForWallet/models.ts | 6 +- .../utils/calculateTotalBalance.test.ts | 74 --------- .../balances/utils/calculateTotalBalance.ts | 17 --- src/contexts/BalancesProvider.tsx | 7 +- src/pages/Accounts/Accounts.tsx | 21 ++- .../components/AccountListImported.tsx | 8 +- .../components/AccountListPrimary.tsx | 8 +- .../Accounts/components/WalletContainer.tsx | 10 +- .../Accounts/components/WalletHeader.tsx | 1 + .../Accounts/hooks/useWalletTotalBalance.ts | 19 +++ .../Accounts/hooks/useWalletTotalBalance.tsx | 56 ------- .../providers/WalletTotalBalanceProvider.tsx | 135 ++++++++++++++++ src/popup/AppRoutes.tsx | 12 +- src/utils/calculateTotalBalance.test.ts | 19 +-- src/utils/calculateTotalBalance.ts | 8 +- src/utils/getAddressForChain.ts | 2 +- src/utils/getAllAddressesForAccount.ts | 2 +- src/utils/hasAccountBalances.ts | 2 +- 21 files changed, 332 insertions(+), 308 deletions(-) delete mode 100644 src/background/services/balances/utils/calculateTotalBalance.test.ts delete mode 100644 src/background/services/balances/utils/calculateTotalBalance.ts create mode 100644 src/pages/Accounts/hooks/useWalletTotalBalance.ts delete mode 100644 src/pages/Accounts/hooks/useWalletTotalBalance.tsx create mode 100644 src/pages/Accounts/providers/WalletTotalBalanceProvider.tsx diff --git a/src/background/services/balances/BalanceAggregatorService.ts b/src/background/services/balances/BalanceAggregatorService.ts index 30768dbf..adea4a00 100644 --- a/src/background/services/balances/BalanceAggregatorService.ts +++ b/src/background/services/balances/BalanceAggregatorService.ts @@ -55,7 +55,8 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { async getBalancesForNetworks( chainIds: number[], accounts: Account[], - tokenTypes: TokenType[] + tokenTypes: TokenType[], + cacheResponse = true ): Promise<{ tokens: Balances; nfts: Balances }> { const sentryTracker = Sentry.startTransaction({ name: 'BalanceAggregatorService: getBatchedUpdatedBalancesForNetworks', @@ -151,7 +152,7 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { } } - if (hasChanges && !this.lockService.locked) { + if (cacheResponse && hasChanges && !this.lockService.locked) { this.#balances = aggregatedBalances; this.#nfts = aggregatedNfts; diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts index f51d2757..b6662036 100644 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts @@ -1,7 +1,6 @@ import { injectable } from 'tsyringe'; import { Network } from '@avalabs/glacier-sdk'; import { TokenType } from '@avalabs/vm-module-types'; -import { uniq } from 'lodash'; import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; import { ExtensionRequestHandler } from '@src/background/connections/models'; @@ -13,18 +12,23 @@ import { NetworkService } from '../../../network/NetworkService'; import { BalanceAggregatorService } from '../../BalanceAggregatorService'; import { Account } from '../../../accounts/models'; -import { GetWalletsWithActivityParams, TotalBalanceForWallet } from './models'; +import { + GetTotalBalanceForWalletParams, + TotalBalanceForWallet, + isImportedAccountsRequest, +} from './models'; import { calculateTotalBalanceForAddresses, getAccountsWithActivity, getAllAddressesForAccounts, getIncludedNetworks, + getXPChainIds, } from './helpers'; type HandlerType = ExtensionRequestHandler< ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET, TotalBalanceForWallet, - GetWalletsWithActivityParams + GetTotalBalanceForWalletParams >; @injectable() @@ -45,70 +49,106 @@ export class GetTotalBalanceForWalletHandler implements HandlerType { network: this.networkService.isMainnet() ? Network.MAINNET : Network.FUJI, }); - handle: HandlerType['handle'] = async ({ request }) => { - const { walletId } = request.params; + async #findUnderivedAccounts(walletId: string, derivedAccounts: Account[]) { const secrets = await this.secretService.getWalletAccountsSecretsById( walletId ); - if (!secrets.xpubXP) { - return { - ...request, - error: 'not available for this wallet', // TODO - }; - } + const derivedWalletAddresses = getAllAddressesForAccounts( + derivedAccounts ?? [] + ); + const derivedAddressesUnprefixed = derivedWalletAddresses.map((addr) => + addr.replace(/^[PXC]-/i, '') + ); + const underivedXPChainAddresses = secrets.xpubXP + ? Object.keys( + await getAccountsWithActivity( + secrets.xpubXP, + await this.networkService.getAvalanceProviderXP(), + this.#getAddressesActivity + ) + ).filter((address) => !derivedAddressesUnprefixed.includes(address)) + : []; + + return underivedXPChainAddresses.map>((address) => ({ + addressPVM: `P-${address}`, + addressAVM: `X-${address}`, + })); + } - try { - const walletAccounts = - this.accountsService.getPrimaryAccountsByWalletId(walletId); - const derivedWaletAddresses = getAllAddressesForAccounts(walletAccounts); - - const underivedPChainAddresses = Object.keys( - await getAccountsWithActivity( - secrets.xpubXP, - await this.networkService.getAvalanceProviderXP(), - this.#getAddressesActivity - ) - ).filter( - (address) => !derivedWaletAddresses.includes(address.toLowerCase()) - ); + handle: HandlerType['handle'] = async ({ request }) => { + const { walletId } = request.params; + const requestsImportedAccounts = isImportedAccountsRequest(walletId); - const networksIncludedInRollup = getIncludedNetworks( + try { + const allAccounts = this.accountsService.getAccounts(); + const derivedAccounts = requestsImportedAccounts + ? Object.values(allAccounts.imported ?? {}) + : allAccounts.primary[walletId] ?? []; + + if (!derivedAccounts.length) { + return { + ...request, + result: { + totalBalanceInCurrency: 0, + hasBalanceOnUnderivedAccounts: false, + }, + }; + } + + const underivedAccounts = requestsImportedAccounts + ? [] + : await this.#findUnderivedAccounts(walletId, derivedAccounts); + + const networksIncludedInTotal = getIncludedNetworks( this.networkService.isMainnet(), await this.networkService.activeNetworks.promisify(), await this.networkService.getFavoriteNetworks() ); - await this.balanceAggregatorService.getBalancesForNetworks( - networksIncludedInRollup, - [ - ...walletAccounts, - ...underivedPChainAddresses.map( - (address) => - ({ - walletId, - addressPVM: address, - addressAVM: address, - } as Account) - ), - ], - [TokenType.NATIVE, TokenType.ERC20] - ); - - const allWalletAddresses = uniq([ - ...derivedWaletAddresses, - ...underivedPChainAddresses, - ]); - - const result = calculateTotalBalanceForAddresses( - this.balanceAggregatorService.balances, - allWalletAddresses, - networksIncludedInRollup + // Get balance for derived addresses + const { tokens: derivedAddressesBalances } = + await this.balanceAggregatorService.getBalancesForNetworks( + networksIncludedInTotal, + derivedAccounts, + [TokenType.NATIVE, TokenType.ERC20] + ); + + let totalBalanceInCurrency = calculateTotalBalanceForAddresses( + derivedAddressesBalances, + derivedAccounts, + networksIncludedInTotal ); + let hasBalanceOnUnderivedAccounts = false; + + if (underivedAccounts.length > 0) { + // Get balance for underived addresses for X- and P-Chain. + // We DO NOT cache this response. When fetching balances for multiple X/P addresses at once, + // Glacier responds with all the balances aggregated into one record and the Avalanche Module + // returns it with the first address as the key. If cached, we'd save incorrect data. + const { tokens: underivedAddressesBalances } = + await this.balanceAggregatorService.getBalancesForNetworks( + getXPChainIds(this.networkService.isMainnet()), + underivedAccounts as Account[], + [TokenType.NATIVE], + false // Don't cache this + ); + + const underivedAccountsTotal = calculateTotalBalanceForAddresses( + underivedAddressesBalances, + underivedAccounts, + getXPChainIds(this.networkService.isMainnet()) + ); + totalBalanceInCurrency += underivedAccountsTotal; + hasBalanceOnUnderivedAccounts = underivedAccountsTotal > 0; + } return { ...request, - result, + result: { + totalBalanceInCurrency, + hasBalanceOnUnderivedAccounts, + }, }; } catch (e: any) { return { diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts index 94ccd8b2..1b6f5477 100644 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts @@ -5,18 +5,19 @@ import { GLACIER_ADDRESS_FETCH_LIMIT, ITERATION_LIMIT, SearchSpace, - TotalBalanceForWallet, XPAddressDictionary, } from './models'; import { ChainListWithCaipIds } from '@src/background/services/network/models'; import { ChainId } from '@avalabs/core-chains-sdk'; -import { isString, toLower, uniq } from 'lodash'; +import { isString, uniq } from 'lodash'; +import { Avalanche } from '@avalabs/core-wallets-sdk'; + import { Account } from '@src/background/services/accounts/models'; import getAllAddressesForAccount from '@src/utils/getAllAddressesForAccount'; -import { Avalanche } from '@avalabs/core-wallets-sdk'; import { getAddressesInRange } from '@src/background/services/accounts/utils/getAddressesInRange'; +import { calculateTotalBalance } from '@src/utils/calculateTotalBalance'; + import { Balances } from '../../models'; -import { isNFT } from '../../nft/utils/isNFT'; function isDone(currentGap: number) { return currentGap > ADDRESS_GAP_LIMIT; @@ -57,6 +58,13 @@ async function processGlacierAddresses( } } +function getXPChainIds(isMainnet: boolean) { + const xChainId = isMainnet ? ChainId.AVALANCHE_X : ChainId.AVALANCHE_TEST_X; + const pChainId = isMainnet ? ChainId.AVALANCHE_P : ChainId.AVALANCHE_TEST_P; + + return [pChainId, xChainId]; +} + function getIncludedNetworks( isMainnet: boolean, currentChainList: ChainListWithCaipIds, @@ -65,23 +73,17 @@ function getIncludedNetworks( const cChainId = isMainnet ? ChainId.AVALANCHE_MAINNET_ID : ChainId.AVALANCHE_TESTNET_ID; - const xChainId = isMainnet ? ChainId.AVALANCHE_X : ChainId.AVALANCHE_TEST_X; - const pChainId = isMainnet ? ChainId.AVALANCHE_P : ChainId.AVALANCHE_TEST_P; const currentEnvNetworks = Object.keys(currentChainList).map(Number); return uniq( - [cChainId, pChainId, xChainId, ...favoriteChainIds].filter((chainId) => - currentEnvNetworks.includes(chainId) + [cChainId, ...getXPChainIds(isMainnet), ...favoriteChainIds].filter( + (chainId) => currentEnvNetworks.includes(chainId) ) ); } function getAllAddressesForAccounts(accounts: Account[]): string[] { - return accounts - .flatMap(getAllAddressesForAccount) - .filter(isString) - .map(toLower) - .map((addr) => addr.replace(/^[PXC]-/i, '')); + return accounts.flatMap(getAllAddressesForAccount).filter(isString); } async function getAccountsWithActivity( @@ -145,62 +147,20 @@ async function getAccountsWithActivity( function calculateTotalBalanceForAddresses( balances: Balances, - addresses: string[], + accounts: Partial[], chainIds: number[] -): TotalBalanceForWallet { - const allBalances = Object.entries(balances); - const stringChainIds = chainIds.map(String); - - const result = { - totalBalanceInCurrency: 0, - hasBalanceOnUnderivedAccounts: false, - hasBalanceOfUnknownFiatValue: false, - }; - - for (const [chainId, chainBalances] of allBalances) { - if (!stringChainIds.includes(chainId)) { - console.log('DEBUG skipping not-favorite chain', chainId); - continue; - } - - for (const [address, addressBalanceOnChain] of Object.entries( - chainBalances - )) { - if (!addresses.includes(address.toLowerCase())) { - console.log('DEBUG skipping address', address.toLowerCase()); - continue; - } - - for (const tokenBalance of Object.values(addressBalanceOnChain)) { - if (isNFT(tokenBalance)) { - console.log('DEBUG skipping NFT', tokenBalance); - continue; - } - - if (typeof tokenBalance.balanceInCurrency !== 'number') { - result.hasBalanceOfUnknownFiatValue = true; - continue; - } - - if ( - !result.hasBalanceOnUnderivedAccounts && - addresses.includes(address) - ) { - result.hasBalanceOnUnderivedAccounts = true; - } - - result.totalBalanceInCurrency += tokenBalance.balanceInCurrency; - } - } - } - - return result; +): number { + return accounts.reduce((sum: number, account: Partial) => { + const accountBalance = calculateTotalBalance(account, chainIds, balances); + return sum + (accountBalance.sum ?? 0); + }, 0); } export { calculateTotalBalanceForAddresses, getAccountsWithActivity, getAllAddressesForAccounts, + getXPChainIds, getIncludedNetworks, isDone, processGlacierAddresses, diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts index ebf36216..96c79a11 100644 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts @@ -1,13 +1,12 @@ import { ChainAddressChainIdMapListResponse } from '@avalabs/glacier-sdk'; -export type GetWalletsWithActivityParams = { +export type GetTotalBalanceForWalletParams = { walletId: string; }; export type TotalBalanceForWallet = { totalBalanceInCurrency?: number; hasBalanceOnUnderivedAccounts: boolean; - hasBalanceOfUnknownFiatValue: boolean; }; export type SearchSpace = 'i' | 'e'; export type XPAddress = string; @@ -21,3 +20,6 @@ export type AddressActivityFetcher = ( export const ADDRESS_GAP_LIMIT = 20; export const GLACIER_ADDRESS_FETCH_LIMIT = 64; // Requested addresses are encoded as query params, and Glacier enforces URI length limits export const ITERATION_LIMIT = 10; // Abitrary number to avoid an infinite loop. +export const IMPORTED_ACCOUNTS_WALLET_ID = '__IMPORTED__'; +export const isImportedAccountsRequest = (walletId: string) => + walletId === IMPORTED_ACCOUNTS_WALLET_ID; diff --git a/src/background/services/balances/utils/calculateTotalBalance.test.ts b/src/background/services/balances/utils/calculateTotalBalance.test.ts deleted file mode 100644 index 5f2f8b65..00000000 --- a/src/background/services/balances/utils/calculateTotalBalance.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { PrimaryNetworkAssetType } from '@avalabs/glacier-sdk'; -import { calculateTotalBalance } from './calculateTotalBalance'; -import BN from 'bn.js'; - -describe('src/background/services/balances/utils/calculateTotalBalance.ts', () => { - it('should return the total balance based on PChainBalance', () => { - const utxoWithAmount1 = { - assetId: '1', - name: 'testToken', - symbol: 'AVAX', - denomination: 9, - type: PrimaryNetworkAssetType.SECP256K1, - amount: '1', - utxoCount: 1, - }; - const utxos = { - unlockedUnstaked: [utxoWithAmount1], - unlockedStaked: [utxoWithAmount1], - lockedPlatform: [utxoWithAmount1], - lockedStakeable: [utxoWithAmount1], - lockedStaked: [utxoWithAmount1], - pendingStaked: [utxoWithAmount1], - - atomicMemoryUnlocked: [ - { - ...utxoWithAmount1, - sharedWithChainId: '', - status: 'testing', - }, - ], - - atomicMemoryLocked: [ - { - ...utxoWithAmount1, - sharedWithChainId: '', - status: 'testing', - }, - ], - }; - - const result = calculateTotalBalance(utxos); - expect(result).toEqual(new BN(8)); - }); - it('should return the total balance based on XChainBalance', () => { - const utxoWithAmount1 = { - assetId: '1', - name: 'testToken', - symbol: 'AVAX', - denomination: 9, - type: PrimaryNetworkAssetType.SECP256K1, - amount: '1', - utxoCount: 1, - }; - const utxos = { - locked: [utxoWithAmount1], - - unlocked: [utxoWithAmount1], - atomicMemoryUnlocked: [ - { - ...utxoWithAmount1, - sharedWithChainId: '', - }, - ], - atomicMemoryLocked: [ - { - ...utxoWithAmount1, - sharedWithChainId: '', - }, - ], - }; - const result = calculateTotalBalance(utxos); - expect(result).toEqual(new BN(4)); - }); -}); diff --git a/src/background/services/balances/utils/calculateTotalBalance.ts b/src/background/services/balances/utils/calculateTotalBalance.ts deleted file mode 100644 index 55b7b4d3..00000000 --- a/src/background/services/balances/utils/calculateTotalBalance.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PChainBalance, XChainBalances } from '@avalabs/glacier-sdk'; -import BN from 'bn.js'; - -export function calculateTotalBalance( - uxtos: PChainBalance | XChainBalances -): BN { - const sum = Object.values(uxtos).reduce(function (totalAcc, utxoList) { - const typeSum = utxoList.reduce(function (typeAcc, utxo) { - const balanceToAdd = Number(utxo.amount); - return typeAcc + balanceToAdd; - }, 0); - - return totalAcc + typeSum; - }, 0); - - return new BN(sum); -} diff --git a/src/contexts/BalancesProvider.tsx b/src/contexts/BalancesProvider.tsx index 637ca3d1..26586a84 100644 --- a/src/contexts/BalancesProvider.tsx +++ b/src/contexts/BalancesProvider.tsx @@ -299,18 +299,17 @@ export function BalancesProvider({ children }: { children: any }) { const getTotalBalance = useCallback( (addressC: string) => { - if (balances.tokens) { + if (balances.tokens && network?.chainId) { return calculateTotalBalance( - network, getAccount(addressC), - favoriteNetworks.map(({ chainId }) => chainId), + [network.chainId, ...favoriteNetworks.map(({ chainId }) => chainId)], balances.tokens ); } return undefined; }, - [getAccount, favoriteNetworks, network, balances.tokens] + [getAccount, favoriteNetworks, network?.chainId, balances.tokens] ); const getTokenPrice = useCallback( diff --git a/src/pages/Accounts/Accounts.tsx b/src/pages/Accounts/Accounts.tsx index c83a2994..b4d3712b 100644 --- a/src/pages/Accounts/Accounts.tsx +++ b/src/pages/Accounts/Accounts.tsx @@ -4,6 +4,7 @@ import { ChevronLeftIcon, Divider, IconButton, + LoadingDotsIcon, Scrollbars, Stack, TrashIcon, @@ -33,6 +34,8 @@ import { AccountListPrimary } from './components/AccountListPrimary'; import { AccountListImported } from './components/AccountListImported'; import { AccountsActionButton } from './components/AccountsActionButton'; import { OverflowingTypography } from './components/OverflowingTypography'; +import { useWalletTotalBalance } from './hooks/useWalletTotalBalance'; +import { useWalletTotalBalanceContext } from './providers/WalletTotalBalanceProvider'; export function Accounts() { const { @@ -51,6 +54,11 @@ export function Accounts() { const theme = useTheme(); const history = useHistory(); const { walletDetails } = useWalletContext(); + const { isLoading, totalBalanceInCurrency: activeWalletTotalBalance } = + useWalletTotalBalance( + isPrimaryAccount(active) ? active.walletId : undefined + ); + const { fetchBalanceForWallet } = useWalletTotalBalanceContext(); const canCreateAccount = active?.type === AccountType.PRIMARY; const { getTotalBalance } = useBalancesContext(); @@ -69,6 +77,11 @@ export function Accounts() { walletType: walletDetails?.type, }); await selectAccount(id); + + // Refresh total balance of the wallet after adding an account + if (walletDetails?.id) { + fetchBalanceForWallet(walletDetails.id); + } } catch (e) { toast.error(t('An error occurred, please try again later')); } @@ -153,7 +166,11 @@ export function Accounts() { textAlign="end" color="text.secondary" > - {/* TODO: total balance of the active wallet */} + {isLoading ? ( + + ) : typeof activeWalletTotalBalance === 'number' ? ( + currencyFormatter(activeWalletTotalBalance) + ) : null} )} @@ -196,7 +213,7 @@ export function Accounts() { - + {hasImportedAccounts && ( diff --git a/src/pages/Accounts/components/AccountListImported.tsx b/src/pages/Accounts/components/AccountListImported.tsx index 0495b73e..daa70db4 100644 --- a/src/pages/Accounts/components/AccountListImported.tsx +++ b/src/pages/Accounts/components/AccountListImported.tsx @@ -5,7 +5,9 @@ import { Collapse, Stack } from '@avalabs/core-k2-components'; import { Account } from '@src/background/services/accounts/models'; import { useAccountsContext } from '@src/contexts/AccountsProvider'; import { isImportedAccount } from '@src/background/services/accounts/utils/typeGuards'; +import { IMPORTED_ACCOUNTS_WALLET_ID } from '@src/background/services/balances/handlers/getTotalBalanceForWallet/models'; +import { useWalletTotalBalance } from '../hooks/useWalletTotalBalance'; import { SelectionMode } from '../providers/AccountManagerProvider'; import { AccountItem } from './AccountItem'; import WalletHeader from './WalletHeader'; @@ -19,12 +21,16 @@ export const AccountListImported = ({ accounts }: AccountListProps) => { accounts: { active }, } = useAccountsContext(); const [isExpanded, setIsExpanded] = useState(true); + const { isLoading, hasErrorOccurred, totalBalanceInCurrency } = + useWalletTotalBalance(IMPORTED_ACCOUNTS_WALLET_ID); return ( setIsExpanded((e) => !e)} diff --git a/src/pages/Accounts/components/AccountListPrimary.tsx b/src/pages/Accounts/components/AccountListPrimary.tsx index 3f515155..534a05aa 100644 --- a/src/pages/Accounts/components/AccountListPrimary.tsx +++ b/src/pages/Accounts/components/AccountListPrimary.tsx @@ -9,20 +9,20 @@ import { useWalletContext } from '@src/contexts/WalletProvider'; import { WalletContainer } from './WalletContainer'; type AccountListProps = { - primaryAccount: Record; + primaryAccounts: Record; sx?: SxProps; }; export const AccountListPrimary = ({ - primaryAccount, + primaryAccounts, sx, }: AccountListProps) => { const { walletDetails: activeWalletDetails, wallets } = useWalletContext(); return ( - {Object.keys(primaryAccount).map((walletId) => { - const walletAccounts = primaryAccount[walletId]; + {Object.keys(primaryAccounts).map((walletId) => { + const walletAccounts = primaryAccounts[walletId]; const walletDetails = wallets.find((wallet) => wallet.id === walletId); if (!walletDetails) { diff --git a/src/pages/Accounts/components/WalletContainer.tsx b/src/pages/Accounts/components/WalletContainer.tsx index faa7005e..739ce5ea 100644 --- a/src/pages/Accounts/components/WalletContainer.tsx +++ b/src/pages/Accounts/components/WalletContainer.tsx @@ -11,12 +11,12 @@ import { useTranslation } from 'react-i18next'; import { SecretType } from '@src/background/services/secrets/models'; import { WalletDetails } from '@src/background/services/wallet/models'; import { PrimaryAccount } from '@src/background/services/accounts/models'; +import { useNetworkContext } from '@src/contexts/NetworkProvider'; import { useWalletTotalBalance } from '../hooks/useWalletTotalBalance'; import { SelectionMode } from '../providers/AccountManagerProvider'; import { AccountItem } from './AccountItem'; import WalletHeader from './WalletHeader'; -import { useNetworkContext } from '@src/contexts/NetworkProvider'; export const WalletContainer = ({ walletDetails, @@ -33,11 +33,10 @@ export const WalletContainer = ({ const [isExpanded, setIsExpanded] = useState(true); const { isLoading, - // hasBalanceOfUnknownFiatValue, - hasBalanceOnUnderivedAccounts, - // hasErrorOccurred, + hasErrorOccurred, totalBalanceInCurrency, - } = useWalletTotalBalance(walletDetails); + hasBalanceOnUnderivedAccounts, + } = useWalletTotalBalance(walletDetails.id); return ( @@ -46,6 +45,7 @@ export const WalletContainer = ({ isActive={isActive} isExpanded={isExpanded} isLoading={isLoading} + hasBalanceError={hasErrorOccurred} totalBalance={totalBalanceInCurrency} toggle={() => setIsExpanded((e) => !e)} /> diff --git a/src/pages/Accounts/components/WalletHeader.tsx b/src/pages/Accounts/components/WalletHeader.tsx index 09144945..c923f581 100644 --- a/src/pages/Accounts/components/WalletHeader.tsx +++ b/src/pages/Accounts/components/WalletHeader.tsx @@ -30,6 +30,7 @@ type WalletHeaderProps = { isExpanded: boolean; isLoading: boolean; totalBalance?: number; + hasBalanceError: boolean; toggle: () => void; } & ( | { diff --git a/src/pages/Accounts/hooks/useWalletTotalBalance.ts b/src/pages/Accounts/hooks/useWalletTotalBalance.ts new file mode 100644 index 00000000..c5d31130 --- /dev/null +++ b/src/pages/Accounts/hooks/useWalletTotalBalance.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; + +import { + WalletTotalBalanceState, + useWalletTotalBalanceContext, +} from '../providers/WalletTotalBalanceProvider'; + +export const useWalletTotalBalance = (walletId?: string) => { + const { walletBalances } = useWalletTotalBalanceContext(); + + return useMemo( + (): WalletTotalBalanceState => + (walletId && walletBalances[walletId]) || { + isLoading: false, + hasErrorOccurred: false, + }, + [walletBalances, walletId] + ); +}; diff --git a/src/pages/Accounts/hooks/useWalletTotalBalance.tsx b/src/pages/Accounts/hooks/useWalletTotalBalance.tsx deleted file mode 100644 index d8417d8b..00000000 --- a/src/pages/Accounts/hooks/useWalletTotalBalance.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; -import { GetTotalBalanceForWalletHandler } from '@src/background/services/balances/handlers/getTotalBalanceForWallet'; -import { TotalBalanceForWallet } from '@src/background/services/balances/handlers/getTotalBalanceForWallet/models'; -import { WalletDetails } from '@src/background/services/wallet/models'; -import { useConnectionContext } from '@src/contexts/ConnectionProvider'; -import { useCallback, useEffect, useState } from 'react'; - -export const useWalletTotalBalance = (walletDetails?: WalletDetails) => { - const [isLoading, setIsLoading] = useState(false); - const [hasErrorOccurred, setHasErrorOccurred] = useState(false); - const [totalBalanceInfo, setTotalBalanceInfo] = - useState({ - hasBalanceOfUnknownFiatValue: false, - hasBalanceOnUnderivedAccounts: false, - totalBalanceInCurrency: undefined, - }); - - const { request } = useConnectionContext(); - - const fetchBalance = useCallback( - (walletId: string) => - request({ - method: ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET, - params: { - walletId, - }, - }), - [request] - ); - - useEffect(() => { - if (!walletDetails?.id) { - return; - } - - setIsLoading(true); - fetchBalance(walletDetails.id) - .then((info) => { - setHasErrorOccurred(false); - setTotalBalanceInfo(info); - }) - .catch((err) => { - console.log('Error while fetching total balance for wallet', err); - setHasErrorOccurred(true); - }) - .finally(() => { - setIsLoading(false); - }); - }, [fetchBalance, walletDetails?.id]); - - return { - isLoading, - hasErrorOccurred, - ...totalBalanceInfo, - }; -}; diff --git a/src/pages/Accounts/providers/WalletTotalBalanceProvider.tsx b/src/pages/Accounts/providers/WalletTotalBalanceProvider.tsx new file mode 100644 index 00000000..d00af561 --- /dev/null +++ b/src/pages/Accounts/providers/WalletTotalBalanceProvider.tsx @@ -0,0 +1,135 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { isString } from 'lodash'; + +import { useAccountsContext } from '@src/contexts/AccountsProvider'; +import { + IMPORTED_ACCOUNTS_WALLET_ID, + TotalBalanceForWallet, +} from '@src/background/services/balances/handlers/getTotalBalanceForWallet/models'; +import { useWalletContext } from '@src/contexts/WalletProvider'; +import { useConnectionContext } from '@src/contexts/ConnectionProvider'; +import { GetTotalBalanceForWalletHandler } from '@src/background/services/balances/handlers/getTotalBalanceForWallet'; +import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; + +interface WalletTotalBalanceContextProps { + children?: React.ReactNode; +} + +export type WalletTotalBalanceState = Partial & { + isLoading: boolean; + hasErrorOccurred: boolean; +}; + +export const WalletTotalBalanceContext = createContext<{ + fetchBalanceForWallet(walletId: string): Promise; + walletBalances: Record; +}>({ + walletBalances: {}, + fetchBalanceForWallet: () => Promise.resolve(), +}); + +export const WalletTotalBalanceProvider = ({ + children, +}: WalletTotalBalanceContextProps) => { + const { + accounts: { imported }, + } = useAccountsContext(); + const { wallets } = useWalletContext(); + const { request } = useConnectionContext(); + + const hasImportedAccounts = useMemo( + () => Object.keys(imported).length > 0, + [imported] + ); + + const [walletBalances, setWalletBalances] = useState< + Record + >({}); + + const fetchBalanceForWallet = useCallback( + async (walletId: string) => { + setWalletBalances((prevState) => ({ + ...prevState, + [walletId]: { + ...prevState[walletId], + hasErrorOccurred: false, + isLoading: true, + }, + })); + + request({ + method: ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET, + params: { + walletId, + }, + }) + .then((walletBalanceInfo) => { + setWalletBalances((prevState) => ({ + ...prevState, + [walletId]: { + ...walletBalanceInfo, + hasErrorOccurred: false, + isLoading: false, + }, + })); + }) + .catch((err) => { + console.log('Error while fetching total balance for wallet', err); + setWalletBalances((prevState) => ({ + ...prevState, + [walletId]: { + ...prevState[walletId], + hasErrorOccurred: true, + isLoading: false, + }, + })); + }); + }, + [request] + ); + useEffect(() => { + let isMounted = true; + + const fetchWalletBalancesSequentially = async (walletIds: string[]) => { + for (const walletId of walletIds) { + await fetchBalanceForWallet(walletId); + if (!isMounted) { + return; + } + } + }; + + const walletIds = [ + ...wallets.map(({ id }) => id), + hasImportedAccounts ? IMPORTED_ACCOUNTS_WALLET_ID : undefined, + ].filter(isString); + + fetchWalletBalancesSequentially(walletIds); + + return () => { + isMounted = false; + }; + }, [wallets, hasImportedAccounts, fetchBalanceForWallet]); + + return ( + + {children} + + ); +}; + +export function useWalletTotalBalanceContext() { + return useContext(WalletTotalBalanceContext); +} diff --git a/src/popup/AppRoutes.tsx b/src/popup/AppRoutes.tsx index d23e41d4..e32e516e 100644 --- a/src/popup/AppRoutes.tsx +++ b/src/popup/AppRoutes.tsx @@ -187,6 +187,14 @@ const AccountManagerProvider = lazy(() => { ); }); +const WalletTotalBalanceProvider = lazy(() => { + return import('../pages/Accounts/providers/WalletTotalBalanceProvider').then( + (m) => ({ + default: m.WalletTotalBalanceProvider, + }) + ); +}); + const AccountDetailsView = lazy(() => { return import('../pages/Accounts/AccountDetailsView').then((m) => ({ default: m.AccountDetailsView, @@ -303,7 +311,9 @@ export const AppRoutes = () => ( }> - + + + diff --git a/src/utils/calculateTotalBalance.test.ts b/src/utils/calculateTotalBalance.test.ts index cd54ebe1..0eae7400 100644 --- a/src/utils/calculateTotalBalance.test.ts +++ b/src/utils/calculateTotalBalance.test.ts @@ -1,9 +1,4 @@ -import { - ChainId, - Network, - NetworkToken, - NetworkVMType, -} from '@avalabs/core-chains-sdk'; +import { ChainId, NetworkToken } from '@avalabs/core-chains-sdk'; import { Account, AccountType } from '@src/background/services/accounts/models'; import { Balances } from '@src/background/services/balances/models'; import { calculateTotalBalance } from './calculateTotalBalance'; @@ -27,17 +22,6 @@ describe('utils/calculateTotalBalance', () => { logoUri: 'network.token.one.com', }; - const network1: Network = { - chainName: 'test network 1', - chainId: ChainId.AVALANCHE_MAINNET_ID, - vmName: NetworkVMType.EVM, - rpcUrl: 'test.one.com/rpc', - explorerUrl: 'https://explorer.url', - networkToken: networkToken1, - logoUri: 'test.one.com/logo', - primaryColor: 'pink', - }; - const network1TokenBalance: NetworkTokenWithBalance = { ...networkToken1, type: TokenType.NATIVE, @@ -68,7 +52,6 @@ describe('utils/calculateTotalBalance', () => { it('it should calculate the balance', () => { const balance = calculateTotalBalance( - network1, account1, [ChainId.AVALANCHE_MAINNET_ID, ChainId.DFK], balances diff --git a/src/utils/calculateTotalBalance.ts b/src/utils/calculateTotalBalance.ts index b634cdb0..e72fe9bf 100644 --- a/src/utils/calculateTotalBalance.ts +++ b/src/utils/calculateTotalBalance.ts @@ -1,4 +1,3 @@ -import { Network } from '@avalabs/core-chains-sdk'; import { Account } from '@src/background/services/accounts/models'; import { Balances, @@ -8,12 +7,11 @@ import { getAddressForChain } from '@src/utils/getAddressForChain'; import { hasAccountBalances } from './hasAccountBalances'; export function calculateTotalBalance( - network?: Network, - account?: Account, + account?: Partial, networkIds?: number[], balances?: Balances ) { - if (!account || !balances || !network) { + if (!account || !balances || !networkIds?.length) { return { sum: null, priceChange: { @@ -23,7 +21,7 @@ export function calculateTotalBalance( }; } - const chainIdsToSum = new Set([network.chainId, ...(networkIds ?? [])]); + const chainIdsToSum = new Set(networkIds); const hasBalances = hasAccountBalances( balances, diff --git a/src/utils/getAddressForChain.ts b/src/utils/getAddressForChain.ts index b8c21e65..96720bee 100644 --- a/src/utils/getAddressForChain.ts +++ b/src/utils/getAddressForChain.ts @@ -3,7 +3,7 @@ import { Account } from '@src/background/services/accounts/models'; import { isPchainNetworkId } from '@src/background/services/network/utils/isAvalanchePchainNetwork'; import { isXchainNetworkId } from '@src/background/services/network/utils/isAvalancheXchainNetwork'; -export function getAddressForChain(chainId: number, account: Account) { +export function getAddressForChain(chainId: number, account: Partial) { return isBitcoinChainId(chainId) ? account.addressBTC : isPchainNetworkId(chainId) diff --git a/src/utils/getAllAddressesForAccount.ts b/src/utils/getAllAddressesForAccount.ts index 2f7dd441..8e15d8a5 100644 --- a/src/utils/getAllAddressesForAccount.ts +++ b/src/utils/getAllAddressesForAccount.ts @@ -1,6 +1,6 @@ import { Account } from '@src/background/services/accounts/models'; -export default function getAllAddressesForAccount(acc: Account) { +export default function getAllAddressesForAccount(acc: Partial) { return [ acc.addressC, acc.addressBTC, diff --git a/src/utils/hasAccountBalances.ts b/src/utils/hasAccountBalances.ts index 3f94d1c7..9c03282d 100644 --- a/src/utils/hasAccountBalances.ts +++ b/src/utils/hasAccountBalances.ts @@ -4,7 +4,7 @@ import getAllAddressesForAccount from './getAllAddressesForAccount'; export function hasAccountBalances( balances: Balances, - account: Account, + account: Partial, networkIds: number[] ) { const accountAddresses = getAllAddressesForAccount(account); From 3bc00fb97b873a803c13526acb4161a279fb71f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Sun, 1 Dec 2024 21:43:20 +0100 Subject: [PATCH 5/9] test: add test scenarios for wallet balance fetching --- .../getTotalBalanceForWallet.test.ts | 475 ++++++++++++++++++ .../getTotalBalanceForWallet.ts | 8 +- .../getTotalBalanceForWallet/helpers.ts | 167 ------ .../calculateTotalBalanceForAccounts.test.ts | 97 ++++ .../calculateTotalBalanceForAccounts.ts | 15 + .../helpers/getAccountsWithActivity.test.ts | 94 ++++ .../helpers/getAccountsWithActivity.ts | 60 +++ .../helpers/getAllAddressesForAccounts.ts | 8 + .../helpers/getIncludedNetworks.test.ts | 63 +++ .../helpers/getIncludedNetworks.ts | 28 ++ .../getTotalBalanceForWallet/helpers/index.ts | 5 + .../helpers/isDone.test.ts | 10 + .../helpers/isDone.ts | 5 + .../helpers/processGlacierAddresses.test.ts | 105 ++++ .../helpers/processGlacierAddresses.ts | 37 ++ .../getTotalBalanceForWallet/models.ts | 7 +- 16 files changed, 1008 insertions(+), 176 deletions(-) create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts delete mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/calculateTotalBalanceForAccounts.test.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/calculateTotalBalanceForAccounts.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAllAddressesForAccounts.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.test.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/index.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone.test.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/processGlacierAddresses.test.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/processGlacierAddresses.ts diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts new file mode 100644 index 00000000..17078679 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts @@ -0,0 +1,475 @@ +import { Network } from '@avalabs/glacier-sdk'; +import { ChainId } from '@avalabs/core-chains-sdk'; +import { TokenType, type TokenWithBalance } from '@avalabs/vm-module-types'; + +import { buildRpcCall } from '@src/tests/test-utils'; +import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; +import type { SecretsService } from '@src/background/services/secrets/SecretsService'; +import type { NetworkService } from '@src/background/services/network/NetworkService'; +import type { GlacierService } from '@src/background/services/glacier/GlacierService'; +import type { AccountsService } from '@src/background/services/accounts/AccountsService'; +import { + type Accounts, + AccountType, + PrimaryAccount, + ImportedAccount, +} from '@src/background/services/accounts/models'; + +import type { Balances } from '../../models'; +import type { BalanceAggregatorService } from '../../BalanceAggregatorService'; + +import { getAccountsWithActivity } from './helpers'; +import { IMPORTED_ACCOUNTS_WALLET_ID } from './models'; +import { GetTotalBalanceForWalletHandler } from './getTotalBalanceForWallet'; + +jest.mock('./helpers/getAccountsWithActivity'); + +describe('background/services/balances/handlers/getTotalBalanceForWallet.test.ts', () => { + const secretsService: jest.Mocked = { + getWalletAccountsSecretsById: jest.fn(), + } as any; + + const glacierService: jest.Mocked = { + getChainIdsForAddresses: jest.fn(), + } as any; + + const networkService: jest.Mocked = { + getAvalanceProviderXP: jest.fn(), + getFavoriteNetworks: jest.fn(), + isMainnet: jest.fn(), + activeNetworks: { + promisify: jest.fn(), + }, + } as any; + + const accountsService: jest.Mocked = { + getAccounts: jest.fn(), + } as any; + + const balanceAggregatorService: jest.Mocked = { + getBalancesForNetworks: jest.fn(), + } as any; + + const FAVORITE_NETWORKS = [ + ChainId.BITCOIN, + ChainId.BITCOIN_TESTNET, + ChainId.ETHEREUM_HOMESTEAD, + ChainId.ETHEREUM_TEST_SEPOLIA, + ]; + const PROVIDER_XP = {} as any; + + const MAINNETS = { + [ChainId.AVALANCHE_MAINNET_ID]: {}, + [ChainId.AVALANCHE_X]: {}, + [ChainId.AVALANCHE_P]: {}, + [ChainId.ETHEREUM_HOMESTEAD]: {}, + [ChainId.BITCOIN]: {}, + } as any; + + const TESTNETS = { + [ChainId.AVALANCHE_TESTNET_ID]: {}, + [ChainId.AVALANCHE_TEST_X]: {}, + [ChainId.AVALANCHE_TEST_P]: {}, + [ChainId.ETHEREUM_TEST_SEPOLIA]: {}, + [ChainId.BITCOIN_TESTNET]: {}, + } as any; + + const buildHandler = () => + new GetTotalBalanceForWalletHandler( + secretsService, + glacierService, + networkService, + accountsService, + balanceAggregatorService + ); + + const handleRequest = (walletId: string) => + buildHandler().handle( + buildRpcCall({ + id: '123', + method: ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET, + params: { + walletId, + }, + }) + ); + + const mockEnv = (isMainnet = true) => { + networkService.isMainnet.mockReturnValue(isMainnet); + jest + .mocked(networkService.activeNetworks.promisify) + .mockResolvedValue(isMainnet ? MAINNETS : TESTNETS); + }; + + const mockAccounts = (accounts = ACCOUNTS) => { + accountsService.getAccounts.mockReturnValue(accounts); + }; + + const mockSecrets = (xpubXP?: string) => { + secretsService.getWalletAccountsSecretsById.mockResolvedValueOnce({ + xpubXP, + } as any); + }; + + const mockAccountsWithActivity = (addresses: string[]) => { + jest.mocked(getAccountsWithActivity).mockResolvedValue(addresses); + }; + + const buildAccount = ({ id, ...opts }) => + ({ + id, + name: `name-${id}`, + addressC: `${id}-addressC`, + addressPVM: `${id}-addressPVM`, + addressAVM: `${id}-addressAVM`, + addressBTC: `${id}-addressBTC`, + ...opts, + type: opts.type ?? AccountType.PRIMARY, + } as T); + + const ACCOUNT_IMPORTED_0 = buildAccount({ + id: 'imported-0', + type: AccountType.IMPORTED, + }); + const ACCOUNT_IMPORTED_1 = buildAccount({ + id: 'imported-1', + type: AccountType.IMPORTED, + }); + const ACCOUNT_SEED_0 = buildAccount({ + id: 'seedphrase-0', + index: 0, + walletId: 'seedphrase', + }); + const ACCOUNT_SEED_1 = buildAccount({ + id: 'seedphrase-1', + index: 1, + walletId: 'seedphrase', + }); + const ACCOUNT_LEDGER_0 = buildAccount({ + id: 'ledger-0', + index: 0, + walletId: 'ledger', + }); + const ACCOUNT_LEDGER_1 = buildAccount({ + id: 'ledger-1', + index: 1, + walletId: 'ledger', + }); + const ACCOUNT_SEEDLESS = buildAccount({ + id: 'seedless-0', + index: 0, + walletId: 'seedless', + }); + + const ACCOUNTS: Accounts = { + imported: { + [ACCOUNT_IMPORTED_0.id]: ACCOUNT_IMPORTED_0, + [ACCOUNT_IMPORTED_1.id]: ACCOUNT_IMPORTED_1, + }, + primary: { + seedphrase: [ACCOUNT_SEED_0, ACCOUNT_SEED_1], + ledger: [ACCOUNT_LEDGER_0, ACCOUNT_LEDGER_1], + seedless: [ACCOUNT_SEEDLESS], + }, + }; + + const buildBalance = (symbolOrAddress: string, value: number) => ({ + [symbolOrAddress]: { balanceInCurrency: value } as TokenWithBalance, + }); + + const mockBalances = ( + isMainnet = true, + secondCallBalances?: { + P: Balances[keyof Balances]; + X: Balances[keyof Balances]; + } + ) => { + balanceAggregatorService.getBalancesForNetworks.mockResolvedValue({ + nfts: {}, + tokens: { + [isMainnet + ? ChainId.AVALANCHE_MAINNET_ID + : ChainId.AVALANCHE_TESTNET_ID]: { + [ACCOUNT_SEED_0.addressC]: { + ...buildBalance('AVAX', 100), + ...buildBalance('BTC.b', 1000), + }, + [ACCOUNT_SEED_1.addressC]: { + ...buildBalance('AVAX', 10), + }, + [ACCOUNT_LEDGER_0.addressC]: { + ...buildBalance('AVAX', 20), + }, + [ACCOUNT_LEDGER_1.addressC]: { + ...buildBalance('AVAX', 120), + ...buildBalance('WETH.e', 1300), + }, + [ACCOUNT_IMPORTED_0.addressC]: { + ...buildBalance('AVAX', 50), + }, + [ACCOUNT_IMPORTED_1.addressC]: { + ...buildBalance('AVAX', 75), + }, + [ACCOUNT_SEEDLESS.addressC]: { + ...buildBalance('AVAX', 750), + ...buildBalance('JOE', 43000), + }, + }, + [isMainnet ? ChainId.BITCOIN : ChainId.BITCOIN_TESTNET]: { + [ACCOUNT_SEED_0.addressBTC]: { + ...buildBalance('BTC', 15000), + }, + }, + [isMainnet ? ChainId.AVALANCHE_P : ChainId.AVALANCHE_TEST_P]: { + [ACCOUNT_SEED_0.addressPVM as string]: { + ...buildBalance('AVAX', 350), + }, + ...secondCallBalances?.P, + }, + [isMainnet ? ChainId.AVALANCHE_X : ChainId.AVALANCHE_TEST_X]: { + [ACCOUNT_SEED_1.addressAVM as string]: { + ...buildBalance('AVAX', 650), + }, + ...secondCallBalances?.X, + }, + [isMainnet + ? ChainId.ETHEREUM_HOMESTEAD + : ChainId.ETHEREUM_TEST_SEPOLIA]: { + [ACCOUNT_SEED_1.addressC as string]: { + ...buildBalance('AVAX', 400), + }, + }, + }, + }); + }; + + beforeEach(() => { + jest.resetAllMocks(); + + networkService.getFavoriteNetworks.mockResolvedValue(FAVORITE_NETWORKS); + networkService.getAvalanceProviderXP.mockResolvedValue(PROVIDER_XP); + }); + + describe(`when passed walletId is "${IMPORTED_ACCOUNTS_WALLET_ID}"`, () => { + beforeEach(() => { + mockEnv(true); + mockAccounts(); + mockBalances(true); + }); + + it('does not look for underived addresses', async () => { + const response = await handleRequest(IMPORTED_ACCOUNTS_WALLET_ID); + expect(response.error).toBeUndefined(); + expect(getAccountsWithActivity).not.toHaveBeenCalled(); + }); + + it('only fetches balances for the imported accounts', async () => { + const response = await handleRequest(IMPORTED_ACCOUNTS_WALLET_ID); + expect(response.error).toBeUndefined(); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledTimes(1); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledWith( + expect.any(Array), + [ACCOUNT_IMPORTED_0, ACCOUNT_IMPORTED_1], + [TokenType.NATIVE, TokenType.ERC20] + ); + }); + + it('returns the correct total balance for imported accounts', async () => { + const response = await handleRequest(IMPORTED_ACCOUNTS_WALLET_ID); + expect(response.result).toEqual({ + hasBalanceOnUnderivedAccounts: false, + totalBalanceInCurrency: 125, + }); + }); + }); + + describe('when requested wallet does not include XP public key', () => { + beforeEach(() => { + mockEnv(true); + mockAccounts(); + mockBalances(true); + mockSecrets(undefined); // No xpubXP + }); + + it('does not look for underived addresses', async () => { + const response = await handleRequest('seedless'); + expect(response.error).toBeUndefined(); + expect(getAccountsWithActivity).not.toHaveBeenCalled(); + }); + + it('only fetches balances for already derived accounts', async () => { + const response = await handleRequest('seedless'); + expect(response.error).toBeUndefined(); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledTimes(1); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledWith( + expect.any(Array), + [ACCOUNT_SEEDLESS], + [TokenType.NATIVE, TokenType.ERC20] + ); + }); + + it('returns the correct total balance for already derived accounts', async () => { + const response = await handleRequest('seedless'); + expect(response.result).toEqual({ + hasBalanceOnUnderivedAccounts: false, + totalBalanceInCurrency: 43750, + }); + }); + }); + + describe('when requested wallet does include XP public key', () => { + beforeEach(() => { + mockEnv(true); + mockAccounts(); + mockBalances(true); + mockSecrets('xpubXP'); // We've got xpubXP + }); + + it('looks for XP-chain activity on underived addresses of the requested wallet', async () => { + const unresolvedAddresses = ['avaxUnresolvedAddress']; + mockAccountsWithActivity(unresolvedAddresses); + + const response = await handleRequest('seedphrase'); + expect(response.error).toBeUndefined(); + expect(getAccountsWithActivity).toHaveBeenCalledWith( + 'xpubXP', + PROVIDER_XP, + expect.any(Function) + ); + + // Let's also make sure the passed activity fetcher actually invokes the Glacier API: + const fetcher = jest.mocked(getAccountsWithActivity).mock.lastCall?.[2]; + expect(fetcher).toEqual(expect.any(Function)); + fetcher?.(unresolvedAddresses); + expect(glacierService.getChainIdsForAddresses).toHaveBeenCalledWith({ + addresses: unresolvedAddresses, + network: Network.MAINNET, + }); + }); + + it('fetches C-, X- and P-Chain balances along with favorite networks for already derived accounts within the wallet', async () => { + mockAccountsWithActivity([]); // No underived addresses with activity + + const response = await handleRequest('seedphrase'); + expect(response.error).toBeUndefined(); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledTimes(1); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledWith( + [ + ChainId.AVALANCHE_MAINNET_ID, + ChainId.AVALANCHE_P, + ChainId.AVALANCHE_X, + ChainId.BITCOIN, + ChainId.ETHEREUM_HOMESTEAD, + ], + [ACCOUNT_SEED_0, ACCOUNT_SEED_1], + [TokenType.NATIVE, TokenType.ERC20] + ); + + expect(response.result).toEqual({ + hasBalanceOnUnderivedAccounts: false, + totalBalanceInCurrency: 17510, + }); + }); + + it('works with testnets', async () => { + mockEnv(false); + mockBalances(false); + mockAccountsWithActivity([]); // No underived addresses with activity + + const response = await handleRequest('seedphrase'); + expect(response.error).toBeUndefined(); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledTimes(1); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledWith( + [ + ChainId.AVALANCHE_TESTNET_ID, + ChainId.AVALANCHE_TEST_P, + ChainId.AVALANCHE_TEST_X, + ChainId.BITCOIN_TESTNET, + ChainId.ETHEREUM_TEST_SEPOLIA, + ], + [ACCOUNT_SEED_0, ACCOUNT_SEED_1], + [TokenType.NATIVE, TokenType.ERC20] + ); + + expect(response.result).toEqual({ + hasBalanceOnUnderivedAccounts: false, + totalBalanceInCurrency: 17510, + }); + }); + + it('fetches XP balances for underived accounts with activity', async () => { + const xpAddress = 'ledger-2-address'; + const underivedAddresses = [xpAddress]; // One underived account with activity + mockAccountsWithActivity(underivedAddresses); + mockBalances(true, { + X: { + [`X-${xpAddress}`]: { + ...buildBalance('AVAX', 300), + }, + }, + P: { + [`P-${xpAddress}`]: { + ...buildBalance('AVAX', 450), + }, + }, + }); + + const response = await handleRequest('ledger'); + expect(response.error).toBeUndefined(); + + // Fetching balances of derived accounts + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledTimes(2); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenNthCalledWith( + 1, + [ + ChainId.AVALANCHE_MAINNET_ID, + ChainId.AVALANCHE_P, + ChainId.AVALANCHE_X, + ChainId.BITCOIN, + ChainId.ETHEREUM_HOMESTEAD, + ], + [ACCOUNT_LEDGER_0, ACCOUNT_LEDGER_1], + [TokenType.NATIVE, TokenType.ERC20] + ); + + // Fetching XP balances of underived accounts, without caching + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenCalledTimes(2); + expect( + balanceAggregatorService.getBalancesForNetworks + ).toHaveBeenNthCalledWith( + 2, + [ChainId.AVALANCHE_P, ChainId.AVALANCHE_X], + [{ addressPVM: `P-${xpAddress}`, addressAVM: `X-${xpAddress}` }], + [TokenType.NATIVE], + false + ); + + expect(response.result).toEqual({ + hasBalanceOnUnderivedAccounts: true, + totalBalanceInCurrency: 2190, // 750 on underived accounts + 1440 on those mocked by default (already derived) + }); + }); + }); +}); diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts index b6662036..dd0303c3 100644 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts @@ -18,7 +18,7 @@ import { isImportedAccountsRequest, } from './models'; import { - calculateTotalBalanceForAddresses, + calculateTotalBalanceForAccounts, getAccountsWithActivity, getAllAddressesForAccounts, getIncludedNetworks, @@ -61,7 +61,7 @@ export class GetTotalBalanceForWalletHandler implements HandlerType { addr.replace(/^[PXC]-/i, '') ); const underivedXPChainAddresses = secrets.xpubXP - ? Object.keys( + ? ( await getAccountsWithActivity( secrets.xpubXP, await this.networkService.getAvalanceProviderXP(), @@ -114,7 +114,7 @@ export class GetTotalBalanceForWalletHandler implements HandlerType { [TokenType.NATIVE, TokenType.ERC20] ); - let totalBalanceInCurrency = calculateTotalBalanceForAddresses( + let totalBalanceInCurrency = calculateTotalBalanceForAccounts( derivedAddressesBalances, derivedAccounts, networksIncludedInTotal @@ -134,7 +134,7 @@ export class GetTotalBalanceForWalletHandler implements HandlerType { false // Don't cache this ); - const underivedAccountsTotal = calculateTotalBalanceForAddresses( + const underivedAccountsTotal = calculateTotalBalanceForAccounts( underivedAddressesBalances, underivedAccounts, getXPChainIds(this.networkService.isMainnet()) diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts deleted file mode 100644 index 1b6f5477..00000000 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { ChainAddressChainIdMap } from '@avalabs/glacier-sdk'; -import { - ADDRESS_GAP_LIMIT, - AddressActivityFetcher, - GLACIER_ADDRESS_FETCH_LIMIT, - ITERATION_LIMIT, - SearchSpace, - XPAddressDictionary, -} from './models'; -import { ChainListWithCaipIds } from '@src/background/services/network/models'; -import { ChainId } from '@avalabs/core-chains-sdk'; -import { isString, uniq } from 'lodash'; -import { Avalanche } from '@avalabs/core-wallets-sdk'; - -import { Account } from '@src/background/services/accounts/models'; -import getAllAddressesForAccount from '@src/utils/getAllAddressesForAccount'; -import { getAddressesInRange } from '@src/background/services/accounts/utils/getAddressesInRange'; -import { calculateTotalBalance } from '@src/utils/calculateTotalBalance'; - -import { Balances } from '../../models'; - -function isDone(currentGap: number) { - return currentGap > ADDRESS_GAP_LIMIT; -} - -async function processGlacierAddresses( - addresses: string[], - fetchActivity: AddressActivityFetcher, - space: SearchSpace, - start: number, - gap: number -) { - if (isDone(gap)) { - return { gap, result: {} }; - } else { - const { addresses: glacierAddresses } = await fetchActivity(addresses); - - const seenByGlacier: Record = - glacierAddresses.reduce( - (acc, addressInfo) => ({ - ...acc, - [addressInfo.address]: addressInfo, - }), - {} - ); - const result: XPAddressDictionary = {}; - for (let i = 0; i < addresses.length && !isDone(gap); i++) { - const address = addresses[i]; - if (address && address in seenByGlacier) { - result[address] = { space, index: start + i }; - gap = 0; - } else { - gap += 1; - } - } - - return { gap, result }; - } -} - -function getXPChainIds(isMainnet: boolean) { - const xChainId = isMainnet ? ChainId.AVALANCHE_X : ChainId.AVALANCHE_TEST_X; - const pChainId = isMainnet ? ChainId.AVALANCHE_P : ChainId.AVALANCHE_TEST_P; - - return [pChainId, xChainId]; -} - -function getIncludedNetworks( - isMainnet: boolean, - currentChainList: ChainListWithCaipIds, - favoriteChainIds: number[] -) { - const cChainId = isMainnet - ? ChainId.AVALANCHE_MAINNET_ID - : ChainId.AVALANCHE_TESTNET_ID; - const currentEnvNetworks = Object.keys(currentChainList).map(Number); - - return uniq( - [cChainId, ...getXPChainIds(isMainnet), ...favoriteChainIds].filter( - (chainId) => currentEnvNetworks.includes(chainId) - ) - ); -} - -function getAllAddressesForAccounts(accounts: Account[]): string[] { - return accounts.flatMap(getAllAddressesForAccount).filter(isString); -} - -async function getAccountsWithActivity( - xpubXP: string, - providerXP: Avalanche.JsonRpcProvider, - activityFetcher: AddressActivityFetcher -) { - let externalGap = 0; - let internalGap = 0; - let externalStart = 0; - let internalStart = 0; - let tooManyIterations = false; - let result: XPAddressDictionary = {}; - let iteration = 0; - - while (!isDone(externalGap) && !isDone(internalGap) && !tooManyIterations) { - const external = getAddressesInRange( - xpubXP, - providerXP, - false, - externalStart - ); - const internal = getAddressesInRange( - xpubXP, - providerXP, - true, - internalStart - ); - const [externalResult, internalResult] = await Promise.all([ - processGlacierAddresses( - external, - activityFetcher, - 'e', - externalStart, - externalGap - ), - processGlacierAddresses( - internal, - activityFetcher, - 'i', - internalStart, - internalGap - ), - ]); - - result = { ...result, ...externalResult.result }; - result = { ...result, ...internalResult.result }; - - externalGap = externalResult.gap; - externalStart += GLACIER_ADDRESS_FETCH_LIMIT; - - internalGap = internalResult.gap; - internalStart += GLACIER_ADDRESS_FETCH_LIMIT; - - iteration += 1; - tooManyIterations = iteration >= ITERATION_LIMIT; - } - - return result; -} - -function calculateTotalBalanceForAddresses( - balances: Balances, - accounts: Partial[], - chainIds: number[] -): number { - return accounts.reduce((sum: number, account: Partial) => { - const accountBalance = calculateTotalBalance(account, chainIds, balances); - return sum + (accountBalance.sum ?? 0); - }, 0); -} - -export { - calculateTotalBalanceForAddresses, - getAccountsWithActivity, - getAllAddressesForAccounts, - getXPChainIds, - getIncludedNetworks, - isDone, - processGlacierAddresses, -}; diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/calculateTotalBalanceForAccounts.test.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/calculateTotalBalanceForAccounts.test.ts new file mode 100644 index 00000000..109ca6bf --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/calculateTotalBalanceForAccounts.test.ts @@ -0,0 +1,97 @@ +import { Account } from '@src/background/services/accounts/models'; +import { calculateTotalBalance } from '@src/utils/calculateTotalBalance'; + +import { calculateTotalBalanceForAccounts } from './calculateTotalBalanceForAccounts'; + +jest.mock('@src/utils/calculateTotalBalance'); + +describe('src/background/services/balances/handlers/helpers/calculateTotalBalanceForAccounts', () => { + it('aggregates results of calculateTotalBalance() for provided accounts', () => { + jest + .mocked(calculateTotalBalance) + .mockReturnValueOnce({ + sum: 100, + priceChange: { + percentage: [0], + value: 0, + }, + }) + .mockReturnValueOnce({ + sum: 0, + priceChange: { + percentage: [0], + value: 0, + }, + }) + .mockReturnValueOnce({ + sum: null, + priceChange: { + percentage: [], + value: 0, + }, + }) + .mockReturnValueOnce({ + sum: 1500, + priceChange: { + percentage: [0], + value: 0, + }, + }); + + const accounts: Partial[] = [ + { + addressAVM: 'addressAVM', + addressPVM: 'addressPVM', + }, + { + addressPVM: 'addressPVM', + }, + { + addressC: 'addressC', + addressBTC: 'addressBTC', + }, + { + addressC: 'addressC', + addressAVM: 'addressAVM', + addressPVM: 'addressPVM', + }, + ]; + + const balances = {} as any; + const chainIds = []; + + const result = calculateTotalBalanceForAccounts( + balances, + accounts, + chainIds + ); + + expect(calculateTotalBalance).toHaveBeenCalledTimes(4); + expect(calculateTotalBalance).toHaveBeenNthCalledWith( + 1, + accounts[0], + chainIds, + balances + ); + expect(calculateTotalBalance).toHaveBeenNthCalledWith( + 2, + accounts[1], + chainIds, + balances + ); + expect(calculateTotalBalance).toHaveBeenNthCalledWith( + 3, + accounts[2], + chainIds, + balances + ); + expect(calculateTotalBalance).toHaveBeenNthCalledWith( + 4, + accounts[3], + chainIds, + balances + ); + + expect(result).toEqual(1600); + }); +}); diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/calculateTotalBalanceForAccounts.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/calculateTotalBalanceForAccounts.ts new file mode 100644 index 00000000..4b7bdc1a --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/calculateTotalBalanceForAccounts.ts @@ -0,0 +1,15 @@ +import { Account } from '@src/background/services/accounts/models'; +import { calculateTotalBalance } from '@src/utils/calculateTotalBalance'; + +import { Balances } from '../../../models'; + +export function calculateTotalBalanceForAccounts( + balances: Balances, + accounts: Partial[], + chainIds: number[] +): number { + return accounts.reduce((sum: number, account: Partial) => { + const accountBalance = calculateTotalBalance(account, chainIds, balances); + return sum + (accountBalance.sum ?? 0); + }, 0); +} diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts new file mode 100644 index 00000000..8e3c6cab --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts @@ -0,0 +1,94 @@ +import { Avalanche } from '@avalabs/core-wallets-sdk'; + +import { processGlacierAddresses } from './processGlacierAddresses'; +import { getAccountsWithActivity } from './getAccountsWithActivity'; + +jest.mock('@avalabs/core-wallets-sdk'); +jest.mock('./processGlacierAddresses'); + +describe('src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity', () => { + const mockedActivityFetcher = jest.fn(); + const xpubXP = 'xpubXP'; + const providerXP = {} as any; + + beforeEach(() => { + jest.resetAllMocks(); + + jest + .spyOn(Avalanche, 'getAddressFromXpub') + .mockImplementation( + (_, index, __, prefix, internal) => + `${prefix}-address${index}/${internal}` + ); + + jest + .mocked(processGlacierAddresses) + // First batch for external addresses + .mockResolvedValueOnce({ + gap: 5, + result: ['ext-address-0', 'ext-address-15'], + }) + // First batch for internal addresses + .mockResolvedValueOnce({ + gap: 9, + result: ['int-address-0', 'int-address-11'], + }) + // Second batch for external addresses + .mockResolvedValueOnce({ + gap: 21, + result: [], + }) + // Second batch for internal addresses + .mockResolvedValue({ + gap: 21, + result: [], + }); + // mockedActivityFetcher + // .mockImplementationOnce(makeActiveAddresses) // First batch, external addresses + // .mockImplementationOnce(makeActiveAddresses) // First batch, internal addresses + // .mockImplementation(() => ({ addresses: [] })); // All subsequent batches + }); + + it(`iterates until it finds a set number of addresses with no activity`, async () => { + await getAccountsWithActivity(xpubXP, providerXP, mockedActivityFetcher); + + // We mocked only two batches (one of external, one of internal) to come back with active + // addresses, therefore it should stop fetching after seeing the next two batches come back empty. + expect(processGlacierAddresses).toHaveBeenCalledTimes(4); + expect(processGlacierAddresses).toHaveBeenNthCalledWith( + 1, + expect.any(Array), + mockedActivityFetcher, + 0 + ); + expect(processGlacierAddresses).toHaveBeenNthCalledWith( + 2, + expect.any(Array), + mockedActivityFetcher, + 0 + ); + expect(processGlacierAddresses).toHaveBeenNthCalledWith( + 3, + expect.any(Array), + mockedActivityFetcher, + 5 + ); + expect(processGlacierAddresses).toHaveBeenNthCalledWith( + 4, + expect.any(Array), + mockedActivityFetcher, + 9 + ); + }); + + it('returns the addresses with activity', async () => { + expect( + await getAccountsWithActivity(xpubXP, providerXP, mockedActivityFetcher) + ).toEqual([ + 'ext-address-0', + 'ext-address-15', + 'int-address-0', + 'int-address-11', + ]); + }); +}); diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.ts new file mode 100644 index 00000000..40679ba6 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.ts @@ -0,0 +1,60 @@ +import { uniq } from 'lodash'; +import { Avalanche } from '@avalabs/core-wallets-sdk'; + +import { getAddressesInRange } from '@src/background/services/accounts/utils/getAddressesInRange'; + +import { + AddressActivityFetcher, + GLACIER_ADDRESS_FETCH_LIMIT, + ITERATION_LIMIT, +} from '../models'; + +import { processGlacierAddresses } from './processGlacierAddresses'; +import { isDone } from './isDone'; + +export async function getAccountsWithActivity( + xpubXP: string, + providerXP: Avalanche.JsonRpcProvider, + activityFetcher: AddressActivityFetcher +) { + let externalGap = 0; + let internalGap = 0; + let externalStart = 0; + let internalStart = 0; + let tooManyIterations = false; + let result: string[] = []; + let iteration = 0; + + while (!isDone(externalGap) && !isDone(internalGap) && !tooManyIterations) { + const external = getAddressesInRange( + xpubXP, + providerXP, + false, + externalStart + ); + const internal = getAddressesInRange( + xpubXP, + providerXP, + true, + internalStart + ); + const [externalResult, internalResult] = await Promise.all([ + processGlacierAddresses(external, activityFetcher, externalGap), + processGlacierAddresses(internal, activityFetcher, internalGap), + ]); + + result = [...result, ...externalResult.result]; + result = [...result, ...internalResult.result]; + + externalGap = externalResult.gap; + externalStart += GLACIER_ADDRESS_FETCH_LIMIT; + + internalGap = internalResult.gap; + internalStart += GLACIER_ADDRESS_FETCH_LIMIT; + + iteration += 1; + tooManyIterations = iteration >= ITERATION_LIMIT; + } + + return uniq(result); +} diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAllAddressesForAccounts.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAllAddressesForAccounts.ts new file mode 100644 index 00000000..180989db --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAllAddressesForAccounts.ts @@ -0,0 +1,8 @@ +import { isString } from 'lodash'; + +import type { Account } from '@src/background/services/accounts/models'; +import getAllAddressesForAccount from '@src/utils/getAllAddressesForAccount'; + +export function getAllAddressesForAccounts(accounts: Account[]): string[] { + return accounts.flatMap(getAllAddressesForAccount).filter(isString); +} diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.test.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.test.ts new file mode 100644 index 00000000..c332680a --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.test.ts @@ -0,0 +1,63 @@ +import { ChainId } from '@avalabs/core-chains-sdk'; + +import { ChainListWithCaipIds } from '@src/background/services/network/models'; + +import { getIncludedNetworks } from './getIncludedNetworks'; + +describe('src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks', () => { + const favoriteNetworks = [ChainId.BITCOIN, ChainId.ETHEREUM_TEST_SEPOLIA]; + + describe('for mainnet environment', () => { + const mainnets = { + [ChainId.AVALANCHE_MAINNET_ID]: {}, + [ChainId.AVALANCHE_X]: {}, + [ChainId.AVALANCHE_P]: {}, + [ChainId.ETHEREUM_HOMESTEAD]: {}, + [ChainId.BITCOIN]: {}, + } as unknown as ChainListWithCaipIds; + + it('always adds C-, X- and P-Chain mainnets', () => { + expect(getIncludedNetworks(true, mainnets, [])).toEqual([ + ChainId.AVALANCHE_MAINNET_ID, + ChainId.AVALANCHE_P, + ChainId.AVALANCHE_X, + ]); + }); + + it('adds favorite networks', () => { + expect(getIncludedNetworks(true, mainnets, favoriteNetworks)).toEqual([ + ChainId.AVALANCHE_MAINNET_ID, + ChainId.AVALANCHE_P, + ChainId.AVALANCHE_X, + ChainId.BITCOIN, + ]); + }); + }); + + describe('for mainnet environment', () => { + const testnets = { + [ChainId.AVALANCHE_TESTNET_ID]: {}, + [ChainId.AVALANCHE_TEST_X]: {}, + [ChainId.AVALANCHE_TEST_P]: {}, + [ChainId.ETHEREUM_TEST_SEPOLIA]: {}, + [ChainId.BITCOIN_TESTNET]: {}, + } as unknown as ChainListWithCaipIds; + + it('always adds testnet C-, X- and P-Chain', () => { + expect(getIncludedNetworks(false, testnets, [])).toEqual([ + ChainId.AVALANCHE_TESTNET_ID, + ChainId.AVALANCHE_TEST_P, + ChainId.AVALANCHE_TEST_X, + ]); + }); + + it('adds favorite networks', () => { + expect(getIncludedNetworks(false, testnets, favoriteNetworks)).toEqual([ + ChainId.AVALANCHE_TESTNET_ID, + ChainId.AVALANCHE_TEST_P, + ChainId.AVALANCHE_TEST_X, + ChainId.ETHEREUM_TEST_SEPOLIA, + ]); + }); + }); +}); diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts new file mode 100644 index 00000000..6b4cd1a4 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts @@ -0,0 +1,28 @@ +import { uniq } from 'lodash'; +import { ChainId } from '@avalabs/core-chains-sdk'; + +import { ChainListWithCaipIds } from '@src/background/services/network/models'; + +export function getIncludedNetworks( + isMainnet: boolean, + currentChainList: ChainListWithCaipIds, + favoriteChainIds: number[] +) { + const cChainId = isMainnet + ? ChainId.AVALANCHE_MAINNET_ID + : ChainId.AVALANCHE_TESTNET_ID; + const currentEnvNetworks = Object.keys(currentChainList).map(Number); + + return uniq( + [cChainId, ...getXPChainIds(isMainnet), ...favoriteChainIds].filter( + (chainId) => currentEnvNetworks.includes(chainId) + ) + ); +} + +export function getXPChainIds(isMainnet: boolean) { + const xChainId = isMainnet ? ChainId.AVALANCHE_X : ChainId.AVALANCHE_TEST_X; + const pChainId = isMainnet ? ChainId.AVALANCHE_P : ChainId.AVALANCHE_TEST_P; + + return [pChainId, xChainId]; +} diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/index.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/index.ts new file mode 100644 index 00000000..de16e0c6 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/index.ts @@ -0,0 +1,5 @@ +export * from './calculateTotalBalanceForAccounts'; +export * from './getAccountsWithActivity'; +export * from './getAllAddressesForAccounts'; +export * from './getIncludedNetworks'; +export * from './isDone'; diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone.test.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone.test.ts new file mode 100644 index 00000000..c418e4ba --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone.test.ts @@ -0,0 +1,10 @@ +import { ADDRESS_GAP_LIMIT } from '../models'; +import { isDone } from './isDone'; + +describe('src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone', () => { + it(`returns false beyond ${ADDRESS_GAP_LIMIT}`, () => { + expect(isDone(ADDRESS_GAP_LIMIT - 1)).toBe(false); + expect(isDone(ADDRESS_GAP_LIMIT)).toBe(false); + expect(isDone(ADDRESS_GAP_LIMIT + 1)).toBe(true); + }); +}); diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone.ts new file mode 100644 index 00000000..57f02341 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/isDone.ts @@ -0,0 +1,5 @@ +import { ADDRESS_GAP_LIMIT } from '../models'; + +export function isDone(currentGap: number) { + return currentGap > ADDRESS_GAP_LIMIT; +} diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/processGlacierAddresses.test.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/processGlacierAddresses.test.ts new file mode 100644 index 00000000..a91166c0 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/processGlacierAddresses.test.ts @@ -0,0 +1,105 @@ +import { startsWith } from 'lodash'; +import { BlockchainIds } from '@avalabs/glacier-sdk'; + +import { ADDRESS_GAP_LIMIT } from '../models'; +import { processGlacierAddresses } from './processGlacierAddresses'; + +describe('src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity', () => { + const allAddresses = [ + 'active0', + 'active1', + 'active2', + 'active3', + 'inactive4', + 'inactive5', + 'inactive6', + 'inactive7', + 'active8', + 'inactive9', + 'inactive10', + 'inactive11', + 'inactive12', + 'inactive13', + 'inactive14', + 'inactive15', + 'inactive16', + 'inactive17', + 'inactive18', + 'inactive19', + 'inactive20', + 'inactive21', + 'inactive22', + 'inactive23', + 'inactive24', + 'inactive25', + 'inactive26', + 'inactive27', + 'inactive28', + 'inactive29', + 'inactive30', + ]; + + it('returns early if gap is larger than we look for', async () => { + const activityFetcher = jest.fn(); + + expect( + await processGlacierAddresses( + allAddresses, + activityFetcher, + ADDRESS_GAP_LIMIT + 1 + ) + ).toEqual({ + gap: ADDRESS_GAP_LIMIT + 1, + result: [], + }); + + expect(activityFetcher).not.toHaveBeenCalled(); + }); + + it('fetches activity for given set of addresses', async () => { + const activityFetcher = jest.fn().mockResolvedValueOnce({ addresses: [] }); + + await processGlacierAddresses(allAddresses, activityFetcher, 0); + + expect(activityFetcher).toHaveBeenCalledWith(allAddresses); + }); + + it('sums the previous gap with the current gap', async () => { + const mockedActivityFetcher = jest.fn().mockResolvedValueOnce({ + addresses: [], + }); + + const addressesToProcess = allAddresses.slice(-10); // all inactive + const currentGap = 5; + + expect( + await processGlacierAddresses( + allAddresses.slice(-10), + mockedActivityFetcher, + currentGap + ) + ).toEqual({ + gap: currentGap + addressesToProcess.length, + result: [], + }); + }); + + it('returns all active addresses and the number of consecutive inactive addresses (gap)', async () => { + const activeAddresses = allAddresses.filter((addr) => + startsWith(addr, 'active') + ); + const mockedActivityFetcher = jest.fn().mockResolvedValueOnce({ + addresses: activeAddresses.map((address) => ({ + address, + blockchainIds: [BlockchainIds._11111111111111111111111111111111LPO_YY], + })), + }); + + expect( + await processGlacierAddresses(allAddresses, mockedActivityFetcher, 0) + ).toEqual({ + gap: 21, + result: activeAddresses, + }); + }); +}); diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/processGlacierAddresses.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/processGlacierAddresses.ts new file mode 100644 index 00000000..18cb1c7d --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/processGlacierAddresses.ts @@ -0,0 +1,37 @@ +import { ChainAddressChainIdMap } from '@avalabs/glacier-sdk'; + +import { AddressActivityFetcher } from '../models'; +import { isDone } from './isDone'; + +export async function processGlacierAddresses( + addresses: string[], + fetchActivity: AddressActivityFetcher, + gap: number +) { + if (isDone(gap)) { + return { gap, result: [] }; + } else { + const { addresses: glacierAddresses } = await fetchActivity(addresses); + + const seenByGlacier: Record = + glacierAddresses.reduce( + (acc, addressInfo) => ({ + ...acc, + [addressInfo.address]: addressInfo, + }), + {} + ); + const result: string[] = []; + for (let i = 0; i < addresses.length && !isDone(gap); i++) { + const address = addresses[i]; + if (address && address in seenByGlacier) { + result.push(address); + gap = 0; + } else { + gap += 1; + } + } + + return { gap, result }; + } +} diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts index 96c79a11..540b7bcc 100644 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts @@ -8,18 +8,15 @@ export type TotalBalanceForWallet = { totalBalanceInCurrency?: number; hasBalanceOnUnderivedAccounts: boolean; }; -export type SearchSpace = 'i' | 'e'; -export type XPAddress = string; -export type XPAddressData = { index: number; space: SearchSpace }; -export type XPAddressDictionary = Record; export type AddressActivityFetcher = ( addresses: string[] ) => Promise; +export const ITERATION_LIMIT = 10; // Abitrary number to avoid an infinite loop. export const ADDRESS_GAP_LIMIT = 20; export const GLACIER_ADDRESS_FETCH_LIMIT = 64; // Requested addresses are encoded as query params, and Glacier enforces URI length limits -export const ITERATION_LIMIT = 10; // Abitrary number to avoid an infinite loop. export const IMPORTED_ACCOUNTS_WALLET_ID = '__IMPORTED__'; + export const isImportedAccountsRequest = (walletId: string) => walletId === IMPORTED_ACCOUNTS_WALLET_ID; From 0284bac8f9e539581c0da236a00fe4d2a26e1a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 6 Dec 2024 12:02:37 +0100 Subject: [PATCH 6/9] fix: balance caching --- .../balances/BalanceAggregatorService.test.ts | 57 +++++++++++++++++++ .../balances/BalanceAggregatorService.ts | 4 +- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/background/services/balances/BalanceAggregatorService.test.ts b/src/background/services/balances/BalanceAggregatorService.test.ts index 39295ead..ec715bf1 100644 --- a/src/background/services/balances/BalanceAggregatorService.test.ts +++ b/src/background/services/balances/BalanceAggregatorService.test.ts @@ -218,6 +218,63 @@ describe('src/background/services/balances/BalanceAggregatorService.ts', () => { ); }); + it('only updates the balances of the requested accounts', async () => { + // Mock the existing balances for other accounts + (balancesServiceMock.getBalancesForNetwork as jest.Mock).mockReset(); + + balancesServiceMock.getBalancesForNetwork + .mockResolvedValueOnce({ + [account2.addressC]: { + [networkToken1.symbol]: network1TokenBalance, + }, + }) + .mockResolvedValueOnce({ + [account1.addressC]: { + [networkToken1.symbol]: network1TokenBalance, + }, + }); + + // Get balances for the `account2` so they get cached + await service.getBalancesForNetworks( + [network1.chainId], + [account2], + [TokenType.NATIVE, TokenType.ERC20, TokenType.ERC721] + ); + + expect(balancesServiceMock.getBalancesForNetwork).toHaveBeenCalledTimes( + 1 + ); + + expect(service.balances).toEqual({ + [network1.chainId]: { + [account2.addressC]: { + [networkToken1.symbol]: network1TokenBalance, + }, + }, + }); + + // Now get the balances for the first account and verify the `account2` balances are kept in cache + await service.getBalancesForNetworks( + [network1.chainId], + [account1], + [TokenType.NATIVE, TokenType.ERC20, TokenType.ERC721] + ); + + expect(balancesServiceMock.getBalancesForNetwork).toHaveBeenCalledTimes( + 2 + ); + expect(service.balances).toEqual({ + [network1.chainId]: { + [account1.addressC]: { + [networkToken1.symbol]: network1TokenBalance, + }, + [account2.addressC]: { + [networkToken1.symbol]: network1TokenBalance, + }, + }, + }); + }); + it('can fetch the balance for multiple networks and one account', async () => { const balances = await service.getBalancesForNetworks( [network1.chainId, network2.chainId], diff --git a/src/background/services/balances/BalanceAggregatorService.ts b/src/background/services/balances/BalanceAggregatorService.ts index adea4a00..e2b87f1e 100644 --- a/src/background/services/balances/BalanceAggregatorService.ts +++ b/src/background/services/balances/BalanceAggregatorService.ts @@ -6,7 +6,7 @@ import { BalancesService } from './BalancesService'; import { NetworkService } from '../network/NetworkService'; import { EventEmitter } from 'events'; import * as Sentry from '@sentry/browser'; -import { isEqual, pick } from 'lodash'; +import { isEqual, omit, pick } from 'lodash'; import { LockService } from '../lock/LockService'; import { StorageService } from '../storage/StorageService'; @@ -144,7 +144,7 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { for (const [chainId, chainBalances] of freshData) { for (const [address, addressBalance] of Object.entries(chainBalances)) { aggregatedBalances[chainId] = { - ...aggregatedBalances[chainId], + ...omit(aggregatedBalances[chainId], address), // Keep cached balances for other accounts ...chainBalances, [address]: addressBalance, }; From 171a8e6fb5a7fef3904700a6a1797990b5ba4af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 6 Dec 2024 12:13:49 +0100 Subject: [PATCH 7/9] fix: jumpy ui in the account switcher --- src/pages/Accounts/Accounts.tsx | 2 ++ src/pages/Accounts/components/WalletHeader.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/Accounts/Accounts.tsx b/src/pages/Accounts/Accounts.tsx index b4d3712b..5b711880 100644 --- a/src/pages/Accounts/Accounts.tsx +++ b/src/pages/Accounts/Accounts.tsx @@ -165,6 +165,8 @@ export function Accounts() { fontSize={13} textAlign="end" color="text.secondary" + // Prevents UI from jumping due to LoadingDotsIcon being larger than they appear + sx={isLoading ? { height: 15, overflow: 'hidden' } : null} > {isLoading ? ( diff --git a/src/pages/Accounts/components/WalletHeader.tsx b/src/pages/Accounts/components/WalletHeader.tsx index c923f581..1cf32ec9 100644 --- a/src/pages/Accounts/components/WalletHeader.tsx +++ b/src/pages/Accounts/components/WalletHeader.tsx @@ -118,7 +118,7 @@ export default function WalletHeader({ color="text.secondary" > {isLoading ? ( - + ) : typeof totalBalance === 'number' ? ( currencyFormatter(totalBalance) ) : null} From f0e102fb006afff19a5aab52c336539d9f51ac81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 6 Dec 2024 12:14:44 +0100 Subject: [PATCH 8/9] chore: cleanup --- .../helpers/getAccountsWithActivity.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts index 8e3c6cab..9a9431d9 100644 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts @@ -43,10 +43,6 @@ describe('src/background/services/balances/handlers/getTotalBalanceForWallet/hel gap: 21, result: [], }); - // mockedActivityFetcher - // .mockImplementationOnce(makeActiveAddresses) // First batch, external addresses - // .mockImplementationOnce(makeActiveAddresses) // First batch, internal addresses - // .mockImplementation(() => ({ addresses: [] })); // All subsequent batches }); it(`iterates until it finds a set number of addresses with no activity`, async () => { From 634ca1643a5c97febf6483d541209e46cd5bebec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 6 Dec 2024 12:40:24 +0100 Subject: [PATCH 9/9] fix: c-chain balance not included in account total --- .../getTotalBalanceForWallet.ts | 2 +- .../helpers/getIncludedNetworks.ts | 16 +++------------- src/contexts/BalancesProvider.tsx | 15 +++++++++++++-- src/pages/Accounts/components/AccountItem.tsx | 2 +- src/utils/getDefaultChainIds.ts | 15 +++++++++++++++ 5 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 src/utils/getDefaultChainIds.ts diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts index dd0303c3..49288cfd 100644 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts @@ -22,8 +22,8 @@ import { getAccountsWithActivity, getAllAddressesForAccounts, getIncludedNetworks, - getXPChainIds, } from './helpers'; +import { getXPChainIds } from '@src/utils/getDefaultChainIds'; type HandlerType = ExtensionRequestHandler< ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET, diff --git a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts index 6b4cd1a4..fd53907e 100644 --- a/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts @@ -1,28 +1,18 @@ import { uniq } from 'lodash'; -import { ChainId } from '@avalabs/core-chains-sdk'; import { ChainListWithCaipIds } from '@src/background/services/network/models'; +import { getDefaultChainIds } from '@src/utils/getDefaultChainIds'; export function getIncludedNetworks( isMainnet: boolean, currentChainList: ChainListWithCaipIds, favoriteChainIds: number[] ) { - const cChainId = isMainnet - ? ChainId.AVALANCHE_MAINNET_ID - : ChainId.AVALANCHE_TESTNET_ID; const currentEnvNetworks = Object.keys(currentChainList).map(Number); return uniq( - [cChainId, ...getXPChainIds(isMainnet), ...favoriteChainIds].filter( - (chainId) => currentEnvNetworks.includes(chainId) + [...getDefaultChainIds(isMainnet), ...favoriteChainIds].filter((chainId) => + currentEnvNetworks.includes(chainId) ) ); } - -export function getXPChainIds(isMainnet: boolean) { - const xChainId = isMainnet ? ChainId.AVALANCHE_X : ChainId.AVALANCHE_TEST_X; - const pChainId = isMainnet ? ChainId.AVALANCHE_P : ChainId.AVALANCHE_TEST_P; - - return [pChainId, xChainId]; -} diff --git a/src/contexts/BalancesProvider.tsx b/src/contexts/BalancesProvider.tsx index 26586a84..f9f32a14 100644 --- a/src/contexts/BalancesProvider.tsx +++ b/src/contexts/BalancesProvider.tsx @@ -33,6 +33,7 @@ import { calculateTotalBalance } from '@src/utils/calculateTotalBalance'; import { NftTokenWithBalance, TokenType } from '@avalabs/vm-module-types'; import { Network } from '@src/background/services/network/models'; import { getAddressForChain } from '@src/utils/getAddressForChain'; +import { getDefaultChainIds } from '@src/utils/getDefaultChainIds'; export const IPFS_URL = 'https://ipfs.io'; @@ -302,14 +303,24 @@ export function BalancesProvider({ children }: { children: any }) { if (balances.tokens && network?.chainId) { return calculateTotalBalance( getAccount(addressC), - [network.chainId, ...favoriteNetworks.map(({ chainId }) => chainId)], + [ + network.chainId, + ...getDefaultChainIds(!network?.isTestnet), + ...favoriteNetworks.map(({ chainId }) => chainId), + ], balances.tokens ); } return undefined; }, - [getAccount, favoriteNetworks, network?.chainId, balances.tokens] + [ + getAccount, + favoriteNetworks, + network?.chainId, + network?.isTestnet, + balances.tokens, + ] ); const getTokenPrice = useCallback( diff --git a/src/pages/Accounts/components/AccountItem.tsx b/src/pages/Accounts/components/AccountItem.tsx index 2d05d35d..a6484011 100644 --- a/src/pages/Accounts/components/AccountItem.tsx +++ b/src/pages/Accounts/components/AccountItem.tsx @@ -244,7 +244,7 @@ export const AccountItem = forwardRef( /> {address && ( - +