From 2970d52a295af5dfc47512f512af8ddcfa3b1121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Leszczyk?= Date: Fri, 6 Dec 2024 14:17:55 +0100 Subject: [PATCH] feat: new account switcher (#94) --- package.json | 2 +- .../connections/extensionConnection/models.ts | 1 + .../extensionConnection/registry.ts | 5 + .../services/accounts/AccountsService.ts | 4 + .../handlers/avalanche_getAddressesInRange.ts | 45 +- .../accounts/utils/getAddressesInRange.ts | 21 + .../services/accounts/utils/typeGuards.ts | 5 + .../balances/BalanceAggregatorService.test.ts | 57 +++ .../balances/BalanceAggregatorService.ts | 8 +- .../getTotalBalanceForWallet.test.ts | 475 ++++++++++++++++++ .../getTotalBalanceForWallet.ts | 160 ++++++ .../calculateTotalBalanceForAccounts.test.ts | 97 ++++ .../calculateTotalBalanceForAccounts.ts | 15 + .../helpers/getAccountsWithActivity.test.ts | 90 ++++ .../helpers/getAccountsWithActivity.ts | 60 +++ .../helpers/getAllAddressesForAccounts.ts | 8 + .../helpers/getIncludedNetworks.test.ts | 63 +++ .../helpers/getIncludedNetworks.ts | 18 + .../getTotalBalanceForWallet/helpers/index.ts | 5 + .../helpers/isDone.test.ts | 10 + .../helpers/isDone.ts | 5 + .../helpers/processGlacierAddresses.test.ts | 105 ++++ .../helpers/processGlacierAddresses.ts | 37 ++ .../getTotalBalanceForWallet/index.ts | 3 + .../getTotalBalanceForWallet/models.ts | 22 + .../utils/calculateTotalBalance.test.ts | 74 --- .../balances/utils/calculateTotalBalance.ts | 17 - .../services/glacier/GlacierService.ts | 20 +- src/components/common/SimpleAddress.tsx | 7 +- src/contexts/BalancesProvider.tsx | 46 +- src/hooks/useKeyboardShortcuts.ts | 2 +- src/hooks/useScopedToast.ts | 27 + src/localization/locales/en/translation.json | 35 +- src/pages/Accounts/AccountBalance.tsx | 16 +- src/pages/Accounts/AccountDetailsView.tsx | 400 ++++++++------- src/pages/Accounts/Accounts.tsx | 350 ++++++------- .../Accounts/AddWalletWithKeystoreFile.tsx | 2 +- .../components/AccountDetailsAddressRow.tsx | 70 +-- src/pages/Accounts/components/AccountItem.tsx | 303 ++++++----- .../Accounts/components/AccountItemMenu.tsx | 109 ++-- src/pages/Accounts/components/AccountList.tsx | 73 --- .../components/AccountListImported.tsx | 51 ++ .../components/AccountListPrimary.tsx | 102 +--- src/pages/Accounts/components/AccountName.tsx | 145 ++---- .../Accounts/components/AccountNameInput.tsx | 36 -- .../components/AccountsActionButton.tsx | 58 ++- .../ConfirmAccountRemovalDialog.tsx | 10 +- .../Accounts/components/NameYourWallet.tsx | 2 +- .../components/OverflowingTypography.tsx | 47 ++ .../Accounts/components/RenameDialog.tsx | 108 ++++ .../Accounts/components/WalletContainer.tsx | 97 ++++ .../Accounts/components/WalletHeader.tsx | 191 +++---- src/pages/Accounts/components/XPChainIcon.tsx | 43 -- .../Accounts/hooks/useAccountRemoval.tsx | 66 +++ src/pages/Accounts/hooks/useAccountRename.tsx | 36 ++ src/pages/Accounts/hooks/useEntityRename.tsx | 65 +++ src/pages/Accounts/hooks/useWalletRename.tsx | 42 ++ .../Accounts/hooks/useWalletTotalBalance.ts | 19 + .../Accounts/hooks/useWalletTypeName.tsx | 57 +++ .../providers/AccountManagerProvider.tsx | 6 + .../providers/WalletTotalBalanceProvider.tsx | 135 +++++ src/pages/Fireblocks/ConnectBitcoinWallet.tsx | 3 +- .../components/FireblocksBitcoinDialog.tsx | 3 +- .../PchainActiveNetworkWidgetContent.tsx | 14 +- .../ImportPrivateKey/ImportPrivateKey.tsx | 11 +- .../ImportWithWalletConnect.tsx | 3 +- src/pages/Network/AddCustomNetworkPopup.tsx | 2 +- 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/getDefaultChainIds.ts | 15 + src/utils/hasAccountBalances.ts | 2 +- yarn.lock | 8 +- 75 files changed, 3058 insertions(+), 1234 deletions(-) create mode 100644 src/background/services/accounts/utils/getAddressesInRange.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.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 create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/index.ts create mode 100644 src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts 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/hooks/useScopedToast.ts delete mode 100644 src/pages/Accounts/components/AccountList.tsx create mode 100644 src/pages/Accounts/components/AccountListImported.tsx delete mode 100644 src/pages/Accounts/components/AccountNameInput.tsx create mode 100644 src/pages/Accounts/components/OverflowingTypography.tsx create mode 100644 src/pages/Accounts/components/RenameDialog.tsx create mode 100644 src/pages/Accounts/components/WalletContainer.tsx delete mode 100644 src/pages/Accounts/components/XPChainIcon.tsx create mode 100644 src/pages/Accounts/hooks/useAccountRemoval.tsx create mode 100644 src/pages/Accounts/hooks/useAccountRename.tsx create mode 100644 src/pages/Accounts/hooks/useEntityRename.tsx create mode 100644 src/pages/Accounts/hooks/useWalletRename.tsx create mode 100644 src/pages/Accounts/hooks/useWalletTotalBalance.ts create mode 100644 src/pages/Accounts/hooks/useWalletTypeName.tsx create mode 100644 src/pages/Accounts/providers/WalletTotalBalanceProvider.tsx create mode 100644 src/utils/getDefaultChainIds.ts diff --git a/package.json b/package.json index 3c2d4b065..943c5c189 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@avalabs/core-coingecko-sdk": "3.1.0-alpha.19", "@avalabs/core-covalent-sdk": "3.1.0-alpha.19", "@avalabs/core-etherscan-sdk": "3.1.0-alpha.19", - "@avalabs/core-k2-components": "4.18.0-alpha.47", + "@avalabs/core-k2-components": "4.18.0-alpha.50", "@avalabs/core-snowtrace-sdk": "3.1.0-alpha.19", "@avalabs/core-token-prices-sdk": "3.1.0-alpha.19", "@avalabs/core-utils-sdk": "3.1.0-alpha.19", diff --git a/src/background/connections/extensionConnection/models.ts b/src/background/connections/extensionConnection/models.ts index 8104c38eb..94b73d7f8 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 49f607ce1..f2e7e4867 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 43bc8e0fb..919e11d82 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 2e85c3ba1..80a1c847f 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 000000000..2ff423ef3 --- /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/accounts/utils/typeGuards.ts b/src/background/services/accounts/utils/typeGuards.ts index a42b4c664..b6aa6d703 100644 --- a/src/background/services/accounts/utils/typeGuards.ts +++ b/src/background/services/accounts/utils/typeGuards.ts @@ -2,6 +2,7 @@ import { Account, AccountType, FireblocksAccount, + ImportedAccount, PrimaryAccount, WalletConnectAccount, } from '../models'; @@ -18,3 +19,7 @@ export const isWalletConnectAccount = ( export const isPrimaryAccount = ( account?: Account ): account is PrimaryAccount => account?.type === AccountType.PRIMARY; + +export const isImportedAccount = ( + account?: Account +): account is ImportedAccount => Boolean(account) && !isPrimaryAccount(account); diff --git a/src/background/services/balances/BalanceAggregatorService.test.ts b/src/background/services/balances/BalanceAggregatorService.test.ts index 39295eadb..ec715bf1d 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 c6951cc2a..e2b87f1ec 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'; @@ -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', @@ -143,6 +144,7 @@ export class BalanceAggregatorService implements OnLock, OnUnlock { for (const [chainId, chainBalances] of freshData) { for (const [address, addressBalance] of Object.entries(chainBalances)) { aggregatedBalances[chainId] = { + ...omit(aggregatedBalances[chainId], address), // Keep cached balances for other accounts ...chainBalances, [address]: addressBalance, }; @@ -150,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.test.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.test.ts new file mode 100644 index 000000000..170786798 --- /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 new file mode 100644 index 000000000..49288cfd7 --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/getTotalBalanceForWallet.ts @@ -0,0 +1,160 @@ +import { injectable } from 'tsyringe'; +import { Network } from '@avalabs/glacier-sdk'; +import { TokenType } from '@avalabs/vm-module-types'; + +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 { + GetTotalBalanceForWalletParams, + TotalBalanceForWallet, + isImportedAccountsRequest, +} from './models'; +import { + calculateTotalBalanceForAccounts, + getAccountsWithActivity, + getAllAddressesForAccounts, + getIncludedNetworks, +} from './helpers'; +import { getXPChainIds } from '@src/utils/getDefaultChainIds'; + +type HandlerType = ExtensionRequestHandler< + ExtensionRequest.BALANCES_GET_TOTAL_FOR_WALLET, + TotalBalanceForWallet, + GetTotalBalanceForWalletParams +>; + +@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, + }); + + async #findUnderivedAccounts(walletId: string, derivedAccounts: Account[]) { + const secrets = await this.secretService.getWalletAccountsSecretsById( + walletId + ); + + const derivedWalletAddresses = getAllAddressesForAccounts( + derivedAccounts ?? [] + ); + const derivedAddressesUnprefixed = derivedWalletAddresses.map((addr) => + addr.replace(/^[PXC]-/i, '') + ); + const underivedXPChainAddresses = secrets.xpubXP + ? ( + 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}`, + })); + } + + handle: HandlerType['handle'] = async ({ request }) => { + const { walletId } = request.params; + const requestsImportedAccounts = isImportedAccountsRequest(walletId); + + 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() + ); + + // Get balance for derived addresses + const { tokens: derivedAddressesBalances } = + await this.balanceAggregatorService.getBalancesForNetworks( + networksIncludedInTotal, + derivedAccounts, + [TokenType.NATIVE, TokenType.ERC20] + ); + + let totalBalanceInCurrency = calculateTotalBalanceForAccounts( + 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 = calculateTotalBalanceForAccounts( + underivedAddressesBalances, + underivedAccounts, + getXPChainIds(this.networkService.isMainnet()) + ); + totalBalanceInCurrency += underivedAccountsTotal; + hasBalanceOnUnderivedAccounts = underivedAccountsTotal > 0; + } + + return { + ...request, + result: { + totalBalanceInCurrency, + hasBalanceOnUnderivedAccounts, + }, + }; + } catch (e: any) { + return { + ...request, + error: e.toString(), + }; + } + }; +} 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 000000000..109ca6bfc --- /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 000000000..4b7bdc1a1 --- /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 000000000..9a9431d9c --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getAccountsWithActivity.test.ts @@ -0,0 +1,90 @@ +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: [], + }); + }); + + 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 000000000..40679ba65 --- /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 000000000..180989dbe --- /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 000000000..c332680a5 --- /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 000000000..fd53907ef --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/helpers/getIncludedNetworks.ts @@ -0,0 +1,18 @@ +import { uniq } from 'lodash'; + +import { ChainListWithCaipIds } from '@src/background/services/network/models'; +import { getDefaultChainIds } from '@src/utils/getDefaultChainIds'; + +export function getIncludedNetworks( + isMainnet: boolean, + currentChainList: ChainListWithCaipIds, + favoriteChainIds: number[] +) { + const currentEnvNetworks = Object.keys(currentChainList).map(Number); + + return uniq( + [...getDefaultChainIds(isMainnet), ...favoriteChainIds].filter((chainId) => + currentEnvNetworks.includes(chainId) + ) + ); +} 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 000000000..de16e0c64 --- /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 000000000..c418e4ba6 --- /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 000000000..57f02341f --- /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 000000000..a91166c04 --- /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 000000000..18cb1c7dc --- /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/index.ts b/src/background/services/balances/handlers/getTotalBalanceForWallet/index.ts new file mode 100644 index 000000000..d05050d4c --- /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 000000000..540b7bccb --- /dev/null +++ b/src/background/services/balances/handlers/getTotalBalanceForWallet/models.ts @@ -0,0 +1,22 @@ +import { ChainAddressChainIdMapListResponse } from '@avalabs/glacier-sdk'; + +export type GetTotalBalanceForWalletParams = { + walletId: string; +}; + +export type TotalBalanceForWallet = { + totalBalanceInCurrency?: number; + hasBalanceOnUnderivedAccounts: boolean; +}; + +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 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 5f2f8b650..000000000 --- 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 55b7b4d38..000000000 --- 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/background/services/glacier/GlacierService.ts b/src/background/services/glacier/GlacierService.ts index 776164dcf..7ee7625ca 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/components/common/SimpleAddress.tsx b/src/components/common/SimpleAddress.tsx index 6c29a3dd4..59cfd262c 100644 --- a/src/components/common/SimpleAddress.tsx +++ b/src/components/common/SimpleAddress.tsx @@ -5,23 +5,25 @@ import { toast, CopyIcon, TypographyProps, + StackProps, } from '@avalabs/core-k2-components'; import { useTranslation } from 'react-i18next'; import { truncateAddress } from '@src/utils/truncateAddress'; -export interface SimpleAddressProps { +export type SimpleAddressProps = StackProps & { address: string; textColor?: TypographyProps['color']; iconColor?: TypographyProps['color']; copyCallback?: () => void; -} +}; export function SimpleAddress({ address, iconColor, textColor, copyCallback, + ...props }: SimpleAddressProps) { const { t } = useTranslation(); @@ -45,6 +47,7 @@ export function SimpleAddress({ textAlign: 'center', }} onClick={copyAddress} + {...props} > ({}); - const [isPolling, setIsPolling] = useState(false); - const polledChainIds = useMemo( () => favoriteNetworks.map(({ chainId }) => chainId), [favoriteNetworks] @@ -236,11 +235,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 +249,10 @@ export function BalancesProvider({ children }: { children: any }) { payload: balancesData, }); }); + } else { + request({ + method: ExtensionRequest.BALANCES_STOP_POLLING, + }); } return () => { @@ -257,19 +260,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[]) => { @@ -309,18 +300,27 @@ 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, + ...getDefaultChainIds(!network?.isTestnet), + ...favoriteNetworks.map(({ chainId }) => chainId), + ], balances.tokens ); } return undefined; }, - [getAccount, favoriteNetworks, network, balances.tokens] + [ + getAccount, + favoriteNetworks, + network?.chainId, + network?.isTestnet, + balances.tokens, + ] ); const getTokenPrice = useCallback( diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 59ef9bef9..c593631f0 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -1,7 +1,7 @@ import { KeyboardEventHandler, useCallback } from 'react'; type Callback = () => void; -type KeyNames = 'Enter' | 'Esc'; +type KeyNames = 'Enter' | 'Escape'; type KeyboardShortcuts = Partial>; export const useKeyboardShortcuts = (shortcuts: KeyboardShortcuts) => { diff --git a/src/hooks/useScopedToast.ts b/src/hooks/useScopedToast.ts new file mode 100644 index 000000000..51626dd23 --- /dev/null +++ b/src/hooks/useScopedToast.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { toast } from '@avalabs/core-k2-components'; + +export const useScopedToast = (id: string) => { + const success = useCallback( + (...[message, opts]: Parameters) => { + toast.dismiss(id); + + return toast.success(message, { ...opts, id: id }); + }, + [id] + ); + + const error = useCallback( + (...[message, opts]: Parameters) => { + toast.dismiss(id); + + return toast.error(message, { ...opts, id: id }); + }, + [id] + ); + + return { + success, + error, + }; +}; diff --git a/src/localization/locales/en/translation.json b/src/localization/locales/en/translation.json index 2de110eee..623de1318 100644 --- a/src/localization/locales/en/translation.json +++ b/src/localization/locales/en/translation.json @@ -7,6 +7,7 @@ "3.": "3.", "{{timeLeft}} Remaining": "{{timeLeft}} Remaining", "This process retrieves the addresses
from your ledger
": "This process retrieves the addresses
from your ledger
", + "A private key is like a password for this specific account. Keep it secure, anyone with this private key can access your funds.": "A private key is like a password for this specific account. Keep it secure, anyone with this private key can access your funds.", "API credentials have not been provided": "API credentials have not been provided", "About Ava Labs": "About Ava Labs", "About Avalanche": "About Avalanche", @@ -14,12 +15,11 @@ "Access Existing Wallet": "Access Existing Wallet", "Access an existing wallet with your recovery phrase. You can paste your entire phrase in the first field.": "Access an existing wallet with your recovery phrase. You can paste your entire phrase in the first field.", "Account": "Account", + "Account \"{{accountName}}\" is now active": "Account \"{{accountName}}\" is now active", "Account Details": "Account Details", - "Account Manager": "Account Manager", "Account not found": "Account not found", "Account renamed": "Account renamed", "Account to rename": "Account to rename", - "Account(s) Deleted!": "Account(s) Deleted!", "Account(s) removal has failed!": "Account(s) removal has failed!", "Action Details": "Action Details", "Action was not approved. Please try again.": "Action was not approved. Please try again.", @@ -30,6 +30,7 @@ "Active Wallet:": "Active Wallet:", "Activity": "Activity", "Add one recovery method to continue.": "Add one recovery method to continue.", + "Add Account": "Add Account", "Add Custom Token": "Add Custom Token", "Add Delegator": "Add Delegator", "Add Network": "Add Network", @@ -104,6 +105,8 @@ "Avalanche (P-Chain) Address": "Avalanche (P-Chain) Address", "Avalanche (X-Chain) Address": "Avalanche (X-Chain) Address", "Avalanche (X/P-Chain) Address": "Avalanche (X/P-Chain) Address", + "Avalanche C-Chain": "Avalanche C-Chain", + "Avalanche X/P-Chain": "Avalanche X/P-Chain", "Avoid using a password that you use with other websites or that might be easy for someone to guess.": "Avoid using a password that you use with other websites or that might be easy for someone to guess.", "BIP44 (Default)": "BIP44 (Default)", "Back": "Back", @@ -223,7 +226,6 @@ "Could not swap {{srcAmount}} {{srcToken}} to {{destAmount}} {{destToken}}": "Could not swap {{srcAmount}} {{srcToken}} to {{destAmount}} {{destToken}}", "Couldn’t Connect": "Couldn’t Connect", "Create": "Create", - "Create Account": "Create Account", "Create Asset": "Create Asset", "Create Blockchain": "Create Blockchain", "Create Chain": "Create Chain", @@ -232,6 +234,7 @@ "Currency": "Currency", "Current network is different from this network": "Current network is different from this network", "Current signature": "Current signature", + "Currently using {{walletName}}": "Currently using {{walletName}}", "Custom": "Custom", "Custom Network Deleted!": "Custom Network Deleted!", "Custom Network Edited!": "Custom Network Edited!", @@ -255,6 +258,7 @@ "Delete Contact?": "Delete Contact?", "Delete Network?": "Delete Network?", "Deposit": "Deposit", + "Derivation Path": "Derivation Path", "Derived Addresses": "Derived Addresses", "Description": "Description", "Details": "Details", @@ -278,6 +282,7 @@ "Edit Network": "Edit Network", "Edit Network Fee": "Edit Network Fee", "Edit Spending Limit": "Edit Spending Limit", + "Edit name": "Edit name", "End Date": "End Date", "End Date:": "End Date:", "English": "English", @@ -406,6 +411,7 @@ "Import with Fireblocks": "Import with Fireblocks", "Import with Wallet Connect": "Import with Wallet Connect", "Imported": "Imported", + "Imported Private Key": "Imported Private Key", "In order for this network to be fully functional, you need to provide your Glacier API key. You will be prompted to do so upon approval.": "In order for this network to be fully functional, you need to provide your Glacier API key. You will be prompted to do so upon approval.", "Incoming": "Incoming", "Incompatible Wallet": "Incompatible Wallet", @@ -446,6 +452,7 @@ "It will never be higher than Max Base Fee * Gas Limit.": "It will never be higher than Max Base Fee * Gas Limit.", "It will take 2 days to retrieve your recovery phrase. You will only have 48 hours to copy your recovery phrase once the 2 day waiting period is over.": "It will take 2 days to retrieve your recovery phrase. You will only have 48 hours to copy your recovery phrase once the 2 day waiting period is over.", "Japanese": "Japanese", + "Keystone": "Keystone", "Keystone Support": "Keystone Support", "Keystone {{number}}": "Keystone {{number}}", "Korean": "Korean", @@ -476,10 +483,8 @@ "Logo URL (Optional)": "Logo URL (Optional)", "Looks like you got here by accident.": "Looks like you got here by accident.", "MFA configuration is required for your account.": "MFA configuration is required for your account.", - "Main": "Main", "Malicious Application": "Malicious Application", "Manage": "Manage", - "Manage Accounts": "Manage Accounts", "Manage Collectibles": "Manage Collectibles", "Manage Networks": "Manage Networks", "Manage Tokens": "Manage Tokens", @@ -531,7 +536,6 @@ "New Contact": "New Contact", "New Password": "New Password", "New Wallet Name": "New Wallet Name", - "New Wallet Name is Required": "New Wallet Name is Required", "New name": "New name", "New name is required": "New name is required", "New!": "New!", @@ -568,6 +572,7 @@ "Only connect to sites that you trust.": "Only connect to sites that you trust.", "Only keystore files exported from the Avalanche Wallet are supported.": "Only keystore files exported from the Avalanche Wallet are supported.", "Only the last account and secondary wallets can be deleted. First account cannot be deleted (delete the wallet instead).": "Only the last account and secondary wallets can be deleted. First account cannot be deleted (delete the wallet instead).", + "Only the last account of the wallet can be removed": "Only the last account of the wallet can be removed", "Ooops... It seems you don't have internet connection": "Ooops... It seems you don't have internet connection", "Open Core in your browser.": "Open Core in your browser.", "Open any authenticator app and enter the code found below.": "Open any authenticator app and enter the code found below.", @@ -635,7 +640,6 @@ "Pound Sterling": "Pound Sterling", "Powered by": "Powered by", "Pressing yes will terminate this session. Without your recovery phrase or methods you will not be able to recover this wallet.": "Pressing yes will terminate this session. Without your recovery phrase or methods you will not be able to recover this wallet.", - "Primary": "Primary", "Primary Network": "Primary Network", "Privacy Policy": "Privacy Policy", "Private Key": "Private Key", @@ -679,15 +683,18 @@ "Reject Connection": "Reject Connection", "Reject Transaction": "Reject Transaction", "Remove": "Remove", + "Remove Account": "Remove Account", "Remove Authenticator?": "Remove Authenticator?", "Remove Subnet Validator": "Remove Subnet Validator", "Remove This Method?": "Remove This Method?", "Removing the account will delete all local account information stored on this computer. Your assets on chain will remain on chain.": "Removing the account will delete all local account information stored on this computer. Your assets on chain will remain on chain.", "Removing the accounts will delete all local accounts information stored on this computer. Your assets on chain will remain on chain.": "Removing the accounts will delete all local accounts information stored on this computer. Your assets on chain will remain on chain.", "Removing the last account is not possible.": "Removing the last account is not possible.", + "Rename": "Rename", + "Rename Account": "Rename Account", + "Rename Wallet": "Rename Wallet", "Rename Wallet?": "Rename Wallet?", "Rename account?": "Rename account?", - "Renaming Failed": "Renaming Failed", "Renaming failed": "Renaming failed", "Render Error": "Render Error", "Report a Bug": "Report a Bug", @@ -728,6 +735,8 @@ "Scroll the message contents above to the very bottom to be able to continue": "Scroll the message contents above to the very bottom to be able to continue", "Search": "Search", "Security & Privacy": "Security & Privacy", + "Seedless": "Seedless", + "Seedless ({{provider}})": "Seedless ({{provider}})", "Seedless login error": "Seedless login error", "Seedless {{number}}": "Seedless {{number}}", "Select": "Select", @@ -742,6 +751,7 @@ "Select the first word": "Select the first word", "Select the word that comes after": "Select the word that comes after", "Select the words below to verify your secret recovery phrase.": "Select the words below to verify your secret recovery phrase.", + "Select this wallet": "Select this wallet", "Selected fee is too high": "Selected fee is too high", "Send": "Send", "Send Feedback": "Send Feedback", @@ -755,6 +765,7 @@ "Show Private Key": "Show Private Key", "Show Recovery Phrase": "Show Recovery Phrase", "Show Tokens Without Balance": "Show Tokens Without Balance", + "Show private key": "Show private key", "Sign": "Sign", "Sign Message": "Sign Message", "Signature Threshold": "Signature Threshold", @@ -798,6 +809,7 @@ "Subnet Details": "Subnet Details", "Subnet ID": "Subnet ID", "Success!": "Success!", + "Successfully deleted {{number}} account(s)": "Successfully deleted {{number}} account(s)", "Successfully imported the keystore file.": "Successfully imported the keystore file.", "Successfully reconnected!": "Successfully reconnected!", "Successfully swapped {{srcAmount}} {{srcToken}} to {{destAmount}} {{destToken}}": "Successfully swapped {{srcAmount}} {{srcToken}} to {{destAmount}} {{destToken}}", @@ -866,6 +878,7 @@ "This transaction requires multiple approvals.": "This transaction requires multiple approvals.", "This transaction requires two approvals": "This transaction requires two approvals", "This wallet already exists": "This wallet already exists", + "This wallet cannot be renamed": "This wallet cannot be renamed", "This website": "This website", "Threshold": "Threshold", "Time Elapsed": "Time Elapsed", @@ -917,6 +930,7 @@ "Try again": "Try again", "Try typing the information again or go back to the account manager.": "Try typing the information again or go back to the account manager.", "Turkish": "Turkish", + "Type": "Type", "URI": "URI", "USDC is routed through Circle's Cross-Chain Transfer Protocol. Bridge FAQs": "USDC is routed through Circle's Cross-Chain Transfer Protocol. Bridge FAQs", "Unable to connect. View the troubleshoot guide here": "Unable to connect. View the troubleshoot guide here", @@ -975,6 +989,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", @@ -991,7 +1006,8 @@ "Wallet Connect Approval": "Wallet Connect Approval", "Wallet Details": "Wallet Details", "Wallet Name": "Wallet Name", - "Wallet Renamed": "Wallet Renamed", + "Wallet renamed": "Wallet renamed", + "WalletConnect": "WalletConnect", "Warning": "Warning", "Warning: Verify Message Content": "Warning: Verify Message Content", "We encountered an unexpected issue.": "We encountered an unexpected issue.", @@ -1039,6 +1055,7 @@ "Yubikey": "Yubikey", "Yubikey Name": "Yubikey Name", "Yubikey Setup": "Yubikey Setup", + "an imported account": "an imported account", "creating...": "creating...", "paraswap error message while get rate: {{message}}": "paraswap error message while get rate: {{message}}", "removing": "removing", diff --git a/src/pages/Accounts/AccountBalance.tsx b/src/pages/Accounts/AccountBalance.tsx index 8719da738..e97356537 100644 --- a/src/pages/Accounts/AccountBalance.tsx +++ b/src/pages/Accounts/AccountBalance.tsx @@ -14,12 +14,15 @@ import { useTranslation } from 'react-i18next'; import { useSettingsContext } from '@src/contexts/SettingsProvider'; import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; import { AccountType } from '@src/background/services/accounts/models'; +import { useAccountManager } from './providers/AccountManagerProvider'; interface AccountBalanceProps { refreshBalance: () => void; balanceTotalUSD: number | null; isBalanceLoading: boolean; accountType: AccountType; + isActive: boolean; + isHovered: boolean; } const AnimatedRefreshIcon = styled(RefreshIcon, { @@ -52,8 +55,11 @@ export function AccountBalance({ balanceTotalUSD, isBalanceLoading, accountType, + isActive, + isHovered, }: AccountBalanceProps) { const { t } = useTranslation(); + const { isManageMode } = useAccountManager(); const { currency, currencyFormatter } = useSettingsContext(); const [skeletonWidth, setSkeletonWidth] = useState(30); const balanceTextRef = useRef(); @@ -143,7 +149,7 @@ export function AccountBalance({ @@ -163,8 +169,12 @@ export function AccountBalance({ mountOnEnter unmountOnExit > - - {currencyFormatter(balanceTotalUSD || 0).replace(currency, '')} + + {currencyFormatter(balanceTotalUSD ?? 0).replace(currency, '')} diff --git a/src/pages/Accounts/AccountDetailsView.tsx b/src/pages/Accounts/AccountDetailsView.tsx index a55f6c8d1..dee8d4b2f 100644 --- a/src/pages/Accounts/AccountDetailsView.tsx +++ b/src/pages/Accounts/AccountDetailsView.tsx @@ -1,5 +1,5 @@ import { useHistory, useParams } from 'react-router-dom'; -import { ChangeEvent, KeyboardEvent, useCallback, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AvalancheColorIcon, @@ -7,60 +7,61 @@ import { Button, Card, CardContent, + ChevronLeftIcon, ChevronRightIcon, - InfoCircleIcon, + ClickAwayListener, + Divider, + Grow, + IconButton, + MenuItem, + MenuList, + MoreVerticalIcon, + Popper, Stack, Tooltip, Typography, - toast, + XAndPChainsIcon, + useTheme, } from '@avalabs/core-k2-components'; -import { PageTitle } from '@src/components/common/PageTitle'; +import { AccountType } from '@src/background/services/accounts/models'; +import { FeatureGates } from '@src/background/services/featureFlags/models'; +import { useScopedToast } from '@src/hooks/useScopedToast'; +import { isPrimaryAccount } from '@src/background/services/accounts/utils/typeGuards'; import { useWalletContext } from '@src/contexts/WalletProvider'; -import { isBitcoinNetwork } from '@src/background/services/network/utils/isBitcoinNetwork'; -import { useNetworkContext } from '@src/contexts/NetworkProvider'; import { useAccountsContext } from '@src/contexts/AccountsProvider'; import { stripAddressPrefix } from '@src/utils/stripAddressPrefix'; +import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; +import { useFeatureFlagContext } from '@src/contexts/FeatureFlagsProvider'; -import { XPChainIcon } from './components/XPChainIcon'; -import { usePrivateKeyExport } from './hooks/usePrivateKeyExport'; -import { NoAccountsFound, Origin } from './components/NoAccountsFound'; import { useAccountManager } from './providers/AccountManagerProvider'; -import { ConfirmAccountRemovalDialog } from './components/ConfirmAccountRemovalDialog'; +import { OverflowingTypography } from './components/OverflowingTypography'; +import { NoAccountsFound, Origin } from './components/NoAccountsFound'; import { AccountDetailsAddressRow } from './components/AccountDetailsAddressRow'; -import { CurrentAddressSneakPeek } from './components/CurrentAddressSneakPeek'; -import { AccountNameInput } from './components/AccountNameInput'; -import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; -import { useFeatureFlagContext } from '@src/contexts/FeatureFlagsProvider'; -import { FeatureGates } from '@src/background/services/featureFlags/models'; -import { AccountType } from '@src/background/services/accounts/models'; -import { isPrimaryAccount } from '@src/background/services/accounts/utils/typeGuards'; -import { WalletChip } from '@src/components/common/WalletChip'; +import { useAccountRename } from './hooks/useAccountRename'; +import { useAccountRemoval } from './hooks/useAccountRemoval'; +import { usePrivateKeyExport } from './hooks/usePrivateKeyExport'; +import { WalletTypeIcon } from './components/WalletTypeIcon'; +import { useWalletTypeName } from './hooks/useWalletTypeName'; export const AccountDetailsView = () => { const { t } = useTranslation(); + const toast = useScopedToast('account-switcher'); + const theme = useTheme(); const { isAccountSelectable } = useAccountManager(); const { accountId } = useParams<{ accountId: string }>(); const { getAccountById } = useAccountsContext(); const { getWallet } = useWalletContext(); const account = getAccountById(accountId); - const [isEditing, setIsEditing] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [newName, setNewName] = useState(account?.name ?? ''); - const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); - const { network } = useNetworkContext(); const history = useHistory(); const { capture } = useAnalyticsContext(); - const { deleteAccounts, renameAccount } = useAccountsContext(); const walletDetails = isPrimaryAccount(account) ? getWallet(account.walletId) - : null; + : getWallet(account?.id ?? ''); const { isPrivateKeyAvailable, showPrivateKey } = usePrivateKeyExport( account, walletDetails?.type ); - const [, setErrorToastId] = useState(''); const { featureFlags } = useFeatureFlagContext(); const canPrimaryAccountsBeRemoved = featureFlags[FeatureGates.PRIMARY_ACCOUNT_REMOVAL]; @@ -68,139 +69,157 @@ export const AccountDetailsView = () => { const onAddressCopy = useCallback( (addressToCopy: string, eventName: string) => () => { navigator.clipboard.writeText(stripAddressPrefix(addressToCopy)); - toast.success(t('Copied!')); + toast.success(t('Copied!'), { duration: 1000 }); capture(eventName, { type: account?.type }); }, - [t, account?.type, capture] + [t, account?.type, capture, toast] ); - const onBackClick = useCallback(() => { - if (history.length <= 2) { - history.replace('/accounts'); - } else { - history.goBack(); - } - }, [history]); - - const onEditClick = useCallback(() => { - if (account?.name) { - setNewName(account.name); - } - setIsEditing((editing) => !editing); - }, [account?.name]); + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const contextMenuRef = useRef(null); - const onSaveClick = useCallback(() => { - if (newName === account?.name) { - setIsEditing(false); - return; - } - - if (newName.trim().length === 0) { - setErrorToastId((prevToastId) => { - if (prevToastId) { - toast.dismiss(prevToastId); - } - return toast.error(t('New name is required'), { duration: 2000 }); - }); - return; - } - - setIsSaving(true); - renameAccount(accountId, newName.trim()) - .then(() => { - setIsEditing(false); - toast.success(t('Account renamed')); - }) - .catch(() => toast.error(t('Renaming failed'))) - .finally(() => { - setIsSaving(false); - }); - }, [account?.name, renameAccount, newName, accountId, t]); + const toBeRemoved = useMemo( + () => (account?.id ? [account.id] : []), + [account?.id] + ); + const { prompt: promptRename, renderDialog: renameDialog } = + useAccountRename(account); + const { prompt: promptRemove, renderDialog: removeDialog } = + useAccountRemoval(toBeRemoved); - const onDeleteClick = useCallback(async () => { - setIsDeleting(true); - deleteAccounts([accountId]).finally(() => { - setIsDeleting(false); - onBackClick(); - }); - }, [accountId, deleteAccounts, onBackClick]); + const getWalletType = useWalletTypeName(walletDetails, account); if (!account) { return ; } - const isDeletable = isAccountSelectable(account); - const address = - network && isBitcoinNetwork(network) - ? account?.addressBTC ?? account?.addressC - : account?.addressC; + const isDeletable = + isAccountSelectable(account) && + (account.type !== AccountType.PRIMARY || canPrimaryAccountsBeRemoved); return ( - - - - - {isEditing ? ( - ) => - setNewName(e.target.value) - } - onKeyDown={(e: KeyboardEvent) => { - if (e.key === 'Enter') { - onSaveClick(); - } else if (e.key === 'Escape') { - setNewName(account.name); - setIsEditing(false); - } + + + + {account.name} + + setIsContextMenuOpen(false)} + > + { + e.stopPropagation(); + setIsContextMenuOpen((open) => !open); }} - autoFocus - /> - ) : ( - - {account.name} - - )} + + + {({ TransitionProps }) => ( + + + + + {t('Rename')} + + - {walletDetails && } - + + + + {t('Delete Account')} + + + + + + )} + + + - - - - + + + + }> } - label={t('C-Chain')} + icon={} + label={t('Avalanche C-Chain')} address={account.addressC} copyHandler={onAddressCopy( account.addressC, @@ -210,8 +229,8 @@ export const AccountDetailsView = () => { {account.addressPVM && ( } - label={t('X/P-Chain')} + icon={} + label={t('Avalanche X/P-Chain')} address={account.addressPVM} copyHandler={onAddressCopy( account.addressPVM, @@ -222,7 +241,7 @@ export const AccountDetailsView = () => { {account.addressBTC && ( } + icon={} label={t('Bitcoin')} address={account.addressBTC} copyHandler={onAddressCopy( @@ -231,6 +250,42 @@ export const AccountDetailsView = () => { )} /> )} + + + + + + }> + {walletDetails && ( + + + + + {getWalletType()} + + + + )} + {walletDetails?.derivationPath && ( + + + {walletDetails.derivationPath.toUpperCase() ?? '-'} + + + )} + {walletDetails && ( + + + {walletDetails.name} + + + )} {isPrivateKeyAvailable && ( - - )} - - setIsConfirmDialogOpen(false)} - onConfirm={onDeleteClick} - isMultiple={false} - isDeleting={isDeleting} - /> + {t( + 'A private key is like a password for this specific account. Keep it secure, anyone with this private key can access your funds.' + )} + + )} + + {renameDialog()} + {removeDialog()} ); }; + +const DetailsRow = ({ label, children }) => ( + + + {label} + + {children} + +); diff --git a/src/pages/Accounts/Accounts.tsx b/src/pages/Accounts/Accounts.tsx index 022254583..5b7118803 100644 --- a/src/pages/Accounts/Accounts.tsx +++ b/src/pages/Accounts/Accounts.tsx @@ -1,15 +1,14 @@ -import { useCallback, useState, useEffect } from 'react'; +import { useState, useMemo } from 'react'; import { - Box, Button, - GearIcon, + ChevronLeftIcon, + Divider, + IconButton, + LoadingDotsIcon, + Scrollbars, Stack, - Tab, - TabPanel, - Tabs, TrashIcon, - XIcon, - toast, + Typography, useTheme, } from '@avalabs/core-k2-components'; import { t } from 'i18next'; @@ -19,131 +18,82 @@ import { useAccountsContext } from '@src/contexts/AccountsProvider'; import { useLedgerContext } from '@src/contexts/LedgerProvider'; import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; import { LedgerApprovalDialog } from '@src/pages/SignTransaction/components/LedgerApprovalDialog'; -import { PageTitle } from '@src/components/common/PageTitle'; -import { Overlay } from '@src/components/common/Overlay'; -import { useTabFromParams } from '@src/hooks/useTabFromParams'; -import { AccountsActionButton } from './components/AccountsActionButton'; -import { AddAccountError } from './AddAccountError'; -import { ConfirmAccountRemovalDialog } from './components/ConfirmAccountRemovalDialog'; +import { AccountType } from '@src/background/services/accounts/models'; +import { useScopedToast } from '@src/hooks/useScopedToast'; +import { NetworkSwitcher } from '@src/components/common/header/NetworkSwitcher'; +import { Overlay } from '@src/components/common/Overlay'; +import { isPrimaryAccount } from '@src/background/services/accounts/utils/typeGuards'; import { useWalletContext } from '@src/contexts/WalletProvider'; -import { Flipper } from '@src/components/common/Flipper'; +import { useBalancesContext } from '@src/contexts/BalancesProvider'; +import { useSettingsContext } from '@src/contexts/SettingsProvider'; + import { useAccountManager } from './providers/AccountManagerProvider'; -import { AccountList, SelectionMode } from './components/AccountList'; -import { useFeatureFlagContext } from '@src/contexts/FeatureFlagsProvider'; -import { FeatureGates } from '@src/background/services/featureFlags/models'; -import { AccountType } from '@src/background/services/accounts/models'; -import { SecretType } from '@src/background/services/secrets/models'; +import { useAccountRemoval } from './hooks/useAccountRemoval'; import { AccountListPrimary } from './components/AccountListPrimary'; - -export enum AccountsTab { - Primary, - Imported, -} - -const isKnownTab = (tab: number): tab is AccountsTab => - Object.values(AccountsTab).includes(tab); +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 { selectAccount, addAccount, - deleteAccounts, accounts: { imported: importedAccounts, primary: primaryAccounts, active }, } = useAccountsContext(); - const { exitManageMode, isManageMode, toggleManageMode, selectedAccounts } = + const { isManageMode, toggleManageMode, selectedAccounts } = useAccountManager(); - const { activeTab: tabFromUrl } = useTabFromParams(); - const activeTab = isKnownTab(parseInt(tabFromUrl)) - ? parseInt(tabFromUrl) - : AccountsTab.Primary; + const toast = useScopedToast('account-switcher'); - const [hasError, setHasError] = useState(false); const [addAccountLoading, setAddAccountLoading] = useState(false); const { hasLedgerTransport } = useLedgerContext(); const { capture } = useAnalyticsContext(); const theme = useTheme(); - const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); const history = useHistory(); const { walletDetails } = useWalletContext(); - const { featureFlags } = useFeatureFlagContext(); - const canPrimaryAccountsBeRemoved = - featureFlags[FeatureGates.PRIMARY_ACCOUNT_REMOVAL]; + const { isLoading, totalBalanceInCurrency: activeWalletTotalBalance } = + useWalletTotalBalance( + isPrimaryAccount(active) ? active.walletId : undefined + ); + const { fetchBalanceForWallet } = useWalletTotalBalanceContext(); - const canCreateAccount = active?.type !== AccountType.PRIMARY; - - const setActiveTab = useCallback( - (tab: AccountsTab) => { - // Avoid unnecessary re-renders - if (tab === parseInt(tabFromUrl)) { - return; - } + const canCreateAccount = active?.type === AccountType.PRIMARY; + const { getTotalBalance } = useBalancesContext(); - history.replace( - `/accounts?activeTab=${isKnownTab(tab) ? tab : AccountsTab.Primary}` - ); - }, - [history, tabFromUrl] + const activeAccountBalance = useMemo( + () => (active?.addressC ? getTotalBalance(active.addressC) : null), + [active?.addressC, getTotalBalance] ); const addAccountAndFocus = async () => { setAddAccountLoading(true); try { - setHasError(false); const id = await addAccount(); capture('CreatedANewAccountSuccessfully', { walletType: walletDetails?.type, }); await selectAccount(id); - // Make sure we land on the Primary accounts list, since the account - // creation can be triggered from Imported accounts list as well. - // - // IMPORTANT: - // The switch needs to happen AFTER the account was created. - // Otherwise it will trigger the useIsIncorrectDevice() hook - // which will then block the transport for addAccount() call and - // cause account creation to break for Ledger wallets. - setActiveTab(AccountsTab.Primary); + // Refresh total balance of the wallet after adding an account + if (walletDetails?.id) { + fetchBalanceForWallet(walletDetails.id); + } } catch (e) { - setHasError(true); + toast.error(t('An error occurred, please try again later')); } setAddAccountLoading(false); }; - const onAccountDeleteSuccess = async () => { - capture('AccountDeleteSucceeded'); - toast.success(t('Account(s) Deleted!'), { duration: 2000 }); - }; - - const onAccountDelete = async () => { - setIsDeleting(true); - try { - await deleteAccounts(Array.from(selectedAccounts)); - onAccountDeleteSuccess(); - } catch (e) { - toast.error(t('Account(s) removal has failed!'), { duration: 2000 }); - capture('AccountDeleteFailed'); - } finally { - exitManageMode(); - setIsConfirmDialogOpen(false); - setIsDeleting(false); - } - }; - const hasImportedAccounts = Object.keys(importedAccounts).length > 0; - const hasAnyAccounts = Object.values(primaryAccounts).length > 0; - - useEffect(() => { - if (hasAnyAccounts && !hasImportedAccounts) { - setActiveTab(AccountsTab.Primary); - } - }, [hasAnyAccounts, hasImportedAccounts, setActiveTab]); + const { currencyFormatter } = useSettingsContext(); + const { prompt: promptRemoval, renderDialog: confirmRemovalDialog } = + useAccountRemoval(selectedAccounts); return ( )} - setIsConfirmDialogOpen(false)} - onConfirm={onAccountDelete} - isMultiple={selectedAccounts.length > 1} - isDeleting={isDeleting} - /> + {confirmRemovalDialog()} - history.replace('/home')}> - {isManageMode ? t('Manage Accounts') : t('Account Manager')} - - {(canPrimaryAccountsBeRemoved || - activeTab === AccountsTab.Imported) && ( - - )} - - - {hasError && } - - {hasImportedAccounts && ( - { - capture( - tab === AccountsTab.Primary - ? 'MainAccountPageClicked' - : 'ImportedAccountPageClicked' - ); - exitManageMode(); - setActiveTab(tab); - }} + history.replace('/home')} sx={{ - ml: 2, - my: 2, - minHeight: '24px', - height: '24px', + padding: 0.25, + '> svg': { + transition: 'color .15s ease-in-out, transform .15s ease-in-out', + }, + ':hover svg': { + color: theme.palette.secondary.lighter, + transform: 'translateX(-2px)', + }, }} + disableRipple > - - - - )} - + + + + - - - - + {t('Currently using {{walletName}}', { + walletName: isPrimaryAccount(active) + ? walletDetails?.name + : t('an imported account'), + })} + + {isPrimaryAccount(active) && ( + + {isLoading ? ( + + ) : typeof activeWalletTotalBalance === 'number' ? ( + currencyFormatter(activeWalletTotalBalance) + ) : null} + + )} + + - - - + + {active?.name} + + + {activeAccountBalance?.sum + ? currencyFormatter(activeAccountBalance.sum) + : '...'} + + + + + + + + + + + + + {hasImportedAccounts && ( + + )} + { capture('ImportedAccountDeleteClicked'); - setIsConfirmDialogOpen(true); + promptRemoval(); }} > @@ -302,12 +244,12 @@ export function Accounts() { )} {!isManageMode && ( )} diff --git a/src/pages/Accounts/AddWalletWithKeystoreFile.tsx b/src/pages/Accounts/AddWalletWithKeystoreFile.tsx index dc632010e..92dcb9601 100644 --- a/src/pages/Accounts/AddWalletWithKeystoreFile.tsx +++ b/src/pages/Accounts/AddWalletWithKeystoreFile.tsx @@ -160,7 +160,7 @@ export function AddWalletWithKeystoreFile() { const keyboardHandlers = useKeyboardShortcuts({ Enter: readKeystoreFile, - Esc: restart, + Escape: restart, }); return ( diff --git a/src/pages/Accounts/components/AccountDetailsAddressRow.tsx b/src/pages/Accounts/components/AccountDetailsAddressRow.tsx index ec9086f32..2d8914840 100644 --- a/src/pages/Accounts/components/AccountDetailsAddressRow.tsx +++ b/src/pages/Accounts/components/AccountDetailsAddressRow.tsx @@ -1,11 +1,8 @@ -import { - CopyIcon, - IconButton, - Stack, - Typography, -} from '@avalabs/core-k2-components'; +import { useTranslation } from 'react-i18next'; +import { Button, Stack, Typography } from '@avalabs/core-k2-components'; import { stripAddressPrefix } from '@src/utils/stripAddressPrefix'; +import { truncateAddress } from '@src/utils/truncateAddress'; export const AccountDetailsAddressRow = ({ icon, @@ -13,34 +10,37 @@ export const AccountDetailsAddressRow = ({ address, copyHandler, ...props -}) => ( - - - {icon} - { + const { t } = useTranslation(); + + return ( + + + {icon} + + + {label} + + + {truncateAddress(stripAddressPrefix(address), 16)} + + + + - - {stripAddressPrefix(address)} - - - - - -); + ); +}; diff --git a/src/pages/Accounts/components/AccountItem.tsx b/src/pages/Accounts/components/AccountItem.tsx index 4642a8bfb..a6484011b 100644 --- a/src/pages/Accounts/components/AccountItem.tsx +++ b/src/pages/Accounts/components/AccountItem.tsx @@ -1,40 +1,41 @@ import { - Card, - CardContent, Checkbox, Collapse, Stack, Tooltip, + Typography, useTheme, } from '@avalabs/core-k2-components'; -import { useHistory } from 'react-router-dom'; import { ForwardedRef, - MouseEvent, forwardRef, useCallback, useMemo, + useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; -import { SimpleAddress } from '@src/components/common/SimpleAddress'; import { useBalancesContext } from '@src/contexts/BalancesProvider'; import { useAccountsContext } from '@src/contexts/AccountsProvider'; import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; import { Account, AccountType } from '@src/background/services/accounts/models'; import { useBalanceTotalInCurrency } from '@src/hooks/useBalanceTotalInCurrency'; - -import { SelectionMode } from './AccountList'; -import { AccountItemMenu } from './AccountItemMenu'; -import { AccountItemChip } from '../AccountItemChip'; -import { useAccountManager } from '../providers/AccountManagerProvider'; -import { AccountBalance } from '../AccountBalance'; import { useNetworkContext } from '@src/contexts/NetworkProvider'; -import { isBitcoinNetwork } from '@src/background/services/network/utils/isBitcoinNetwork'; import { SecretType } from '@src/background/services/secrets/models'; import { getAddressForChain } from '@src/utils/getAddressForChain'; -import AccountName from './AccountName'; +import { truncateAddress } from '@src/utils/truncateAddress'; +import { useScopedToast } from '@src/hooks/useScopedToast'; + +import { useAccountRename } from '../hooks/useAccountRename'; +import { + SelectionMode, + useAccountManager, +} from '../providers/AccountManagerProvider'; +import { AccountBalance } from '../AccountBalance'; +import { AccountItemMenu } from './AccountItemMenu'; +import AccountNameNew from './AccountName'; +import { useAccountRemoval } from '../hooks/useAccountRemoval'; type AccountItemProps = { account: Account; @@ -48,6 +49,7 @@ export const AccountItem = forwardRef( ref: ForwardedRef ) => { const { t } = useTranslation(); + const toast = useScopedToast('account-switcher'); const theme = useTheme(); const { isManageMode, @@ -58,15 +60,10 @@ export const AccountItem = forwardRef( } = useAccountManager(); const { isActiveAccount, selectAccount: activateAccount } = useAccountsContext(); - const history = useHistory(); const { capture } = useAnalyticsContext(); const { network } = useNetworkContext(); - const { updateBalanceOnNetworks } = useBalancesContext(); - const [isBalanceLoading, setIsBalanceLoading] = useState(false); const isActive = isActiveAccount(account.id); - - const isImportedAccount = account.type !== AccountType.PRIMARY; const isSelected = selectedAccounts.includes(account.id); const isSelectable = @@ -75,9 +72,9 @@ export const AccountItem = forwardRef( : isManageMode && isAccountSelectable(account); const balanceTotalUSD = useBalanceTotalInCurrency(account); const totalBalance = (balanceTotalUSD && balanceTotalUSD.sum) ?? null; - const isBitcoinActive = network && isBitcoinNetwork(network); const address = network ? getAddressForChain(network.chainId, account) : ''; const [cardHovered, setCardHovered] = useState(false); + const itemRef = useRef(null); const toggle = useCallback( (accountId: string) => { @@ -93,31 +90,33 @@ export const AccountItem = forwardRef( [deselectAccount, isSelected, selectAccount, selectionMode] ); - const handleAccountClick = useCallback( - async (e: MouseEvent) => { - e.stopPropagation(); - - if (isSelectable) { - toggle(account.id); - } else if (!isManageMode) { - await activateAccount(account.id); - history.replace('/home'); - await capture('AccountSelectorAccountSwitched', { - type: account.type, - }); - } - }, - [ - account.id, - account.type, - activateAccount, - capture, - history, - isManageMode, - isSelectable, - toggle, - ] - ); + const handleAccountClick = useCallback(async () => { + if (isSelectable) { + toggle(account.id); + } else if (!isManageMode) { + await activateAccount(account.id); + toast.success( + t(`Account "{{accountName}}" is now active`, { + accountName: account.name, + }), + { duration: 1000 } + ); + await capture('AccountSelectorAccountSwitched', { + type: account.type, + }); + } + }, [ + account.id, + account.type, + account.name, + activateAccount, + capture, + isManageMode, + isSelectable, + t, + toggle, + toast, + ]); const nonSelectableHint = useMemo(() => { if (isSelectable) { @@ -141,134 +140,160 @@ export const AccountItem = forwardRef( ); }, [account, isSelectable, t, walletType]); + const [isBalanceLoading, setIsBalanceLoading] = useState(false); + const { updateBalanceOnNetworks } = useBalancesContext(); + const getBalance = useCallback(async () => { setIsBalanceLoading(true); await updateBalanceOnNetworks([account]); setIsBalanceLoading(false); }, [account, updateBalanceOnNetworks]); + const toBeRemoved = useMemo(() => [account.id], [account.id]); + const { prompt: promptRename, renderDialog: renameDialog } = + useAccountRename(account); + const { prompt: promptRemove, renderDialog: removeDialog } = + useAccountRemoval(toBeRemoved); + return ( - + setCardHovered(true)} - onMouseLeave={() => setCardHovered(false)} - > - + transition: theme.transitions.create('opacity'), + ':hover': { + opacity: isManageMode ? (isSelectable ? 1 : 0.6) : 1, + }, + }} + onClick={isManageMode ? undefined : handleAccountClick} + onClickCapture={isManageMode ? handleAccountClick : undefined} + data-testid={`account-li-item-${account.id}`} + onMouseEnter={() => setCardHovered(true)} + onMouseLeave={() => setCardHovered(false)} + > + + + + toggle(account.id)} + checked={selectedAccounts.includes(account.id)} + /> + + + - - toggle(account.id)} - checked={selectedAccounts.includes(account.id)} - /> - - - - - - - - - - + {address && ( + + + + {truncateAddress(address)} + + + + )} - {address && ( - - { - const eventName = isBitcoinActive - ? 'AccountSelectorBtcAddressCopied' - : 'AccountSelectorEthAddressCopied'; - - capture(eventName, { - type: account.type, - }); - }} - /> - - )} - {!isManageMode && ( + + - )} + - - {isImportedAccount && ( - - - - )} - - - + + + {renameDialog()} + {removeDialog()} + ); } ); diff --git a/src/pages/Accounts/components/AccountItemMenu.tsx b/src/pages/Accounts/components/AccountItemMenu.tsx index c0f779722..de83d5bfe 100644 --- a/src/pages/Accounts/components/AccountItemMenu.tsx +++ b/src/pages/Accounts/components/AccountItemMenu.tsx @@ -1,51 +1,53 @@ +import { RefObject, useCallback, useState } from 'react'; import { - CheckIcon, - ChevronRightIcon, ClickAwayListener, - ConfigureIcon, Grow, IconButton, - KeyIcon, - ListItemIcon, ListItemText, MenuItem, MenuList, - MoreHorizontalIcon, + MoreVerticalIcon, Popper, - useTheme, + Tooltip, } from '@avalabs/core-k2-components'; import { useHistory } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { MouseEvent, useCallback, useRef, useState } from 'react'; import { Account } from '@src/background/services/accounts/models'; +import { SecretType } from '@src/background/services/secrets/models'; import { usePrivateKeyExport } from '../hooks/usePrivateKeyExport'; -import { SecretType } from '@src/background/services/secrets/models'; +import { useAccountManager } from '../providers/AccountManagerProvider'; type AccountItemMenuProps = { account: Account; isActive: boolean; - activateAccount(e: MouseEvent): void; + activateAccount(): Promise; + promptRename(): void; + handleRemove(): void; walletType?: SecretType; + menuAnchor: RefObject; }; export const AccountItemMenu = ({ account, activateAccount, + promptRename, + handleRemove, isActive, walletType, + menuAnchor, }: AccountItemMenuProps) => { + const { isAccountSelectable } = useAccountManager(); const { t } = useTranslation(); const history = useHistory(); - const theme = useTheme(); const [isOpen, setIsOpen] = useState(false); - const ref = useRef(null); const { isPrivateKeyAvailable, showPrivateKey } = usePrivateKeyExport( account, walletType ); + const isDeletable = isAccountSelectable(account); const goToDetails = useCallback( (e: Event) => { @@ -61,69 +63,102 @@ export const AccountItemMenu = ({ onClickAway={() => setIsOpen(false)} > { e.stopPropagation(); setIsOpen((open) => !open); }} > - {isActive ? ( - - ) : ( - - )} + {({ TransitionProps }) => ( - - - {t('View Details')} {!isActive && ( { + e.stopPropagation(); + activateAccount(); + }} data-testid="activate-account-button" + sx={{ + borderTop: '1px solid rgba(255,255,255,0.1)', + minHeight: '40px', + }} > - - - - {t('Select Account')} + {t('Select this wallet')} )} {isPrivateKeyAvailable && ( - - - - {t('Show Private Key')} + {t('Show private key')} )} + + {t('Edit name')} + + + + + {t('Remove Account')} + + + )} diff --git a/src/pages/Accounts/components/AccountList.tsx b/src/pages/Accounts/components/AccountList.tsx deleted file mode 100644 index 1f35cb3ee..000000000 --- a/src/pages/Accounts/components/AccountList.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { - Scrollbars, - ScrollbarsRef, - Stack, - SxProps, -} from '@avalabs/core-k2-components'; -import { useEffect, useRef } from 'react'; - -import { Account } from '@src/background/services/accounts/models'; -import { useAccountsContext } from '@src/contexts/AccountsProvider'; - -import { AccountItem } from './AccountItem'; -import { SecretType } from '@src/background/services/secrets/models'; - -export enum SelectionMode { - None, // Reserved for Seedless - Any, - Consecutive, -} - -type AccountListProps = { - accounts: Account[]; - selectionMode: SelectionMode; - walletType?: SecretType; - sx?: SxProps; -}; - -export const AccountList = ({ - accounts, - selectionMode, - sx, - walletType, -}: AccountListProps) => { - const { - accounts: { active }, - } = useAccountsContext(); - const scrollbarsRef = useRef(null); - const activeAccountRef = useRef(null); - - useEffect(() => { - // Make sure the active account element is visible after switching tabs - // or active account. - if (scrollbarsRef.current && activeAccountRef.current) { - const containerTop = scrollbarsRef.current.getScrollTop(); - const containerBottom = - containerTop + scrollbarsRef.current.getClientHeight(); - - const { offsetTop: elementTop, clientHeight: elementHeight } = - activeAccountRef.current; - const elementBottom = elementTop + elementHeight; - - if (elementTop < containerTop || elementBottom > containerBottom) { - activeAccountRef.current.scrollIntoView({ block: 'center' }); - } - } - }, [active?.id]); - - return ( - - - {accounts.map((account) => ( - - ))} - - - ); -}; diff --git a/src/pages/Accounts/components/AccountListImported.tsx b/src/pages/Accounts/components/AccountListImported.tsx new file mode 100644 index 000000000..daa70db41 --- /dev/null +++ b/src/pages/Accounts/components/AccountListImported.tsx @@ -0,0 +1,51 @@ +import { t } from 'i18next'; +import { useState } from 'react'; +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'; + +type AccountListProps = { + accounts: Account[]; +}; + +export const AccountListImported = ({ accounts }: AccountListProps) => { + const { + accounts: { active }, + } = useAccountsContext(); + const [isExpanded, setIsExpanded] = useState(true); + const { isLoading, hasErrorOccurred, totalBalanceInCurrency } = + useWalletTotalBalance(IMPORTED_ACCOUNTS_WALLET_ID); + + return ( + + setIsExpanded((e) => !e)} + /> + + + {accounts.map((account) => ( + + ))} + + + + ); +}; diff --git a/src/pages/Accounts/components/AccountListPrimary.tsx b/src/pages/Accounts/components/AccountListPrimary.tsx index 66df8994d..534a05aac 100644 --- a/src/pages/Accounts/components/AccountListPrimary.tsx +++ b/src/pages/Accounts/components/AccountListPrimary.tsx @@ -1,95 +1,47 @@ -import { - Scrollbars, - ScrollbarsRef, - Stack, - SxProps, -} from '@avalabs/core-k2-components'; -import { useEffect, useRef } from 'react'; +import { Stack, SxProps } from '@avalabs/core-k2-components'; import { PrimaryAccount, WalletId, } from '@src/background/services/accounts/models'; -import { useAccountsContext } from '@src/contexts/AccountsProvider'; - -import { AccountItem } from './AccountItem'; -import { SelectionMode } from './AccountList'; import { useWalletContext } from '@src/contexts/WalletProvider'; -import WalletHeader from './WalletHeader'; + +import { WalletContainer } from './WalletContainer'; type AccountListProps = { - primaryAccount: Record; - selectionMode: SelectionMode; + primaryAccounts: Record; sx?: SxProps; }; export const AccountListPrimary = ({ - primaryAccount, - selectionMode, + primaryAccounts, sx, }: AccountListProps) => { - const { - accounts: { active }, - } = useAccountsContext(); const { walletDetails: activeWalletDetails, wallets } = useWalletContext(); - const scrollbarsRef = useRef(null); - const activeAccountRef = useRef(null); - - useEffect(() => { - // Make sure the active account element is visible after switching tabs - // or active account. - if (scrollbarsRef.current && activeAccountRef.current) { - const containerTop = scrollbarsRef.current.getScrollTop(); - const containerBottom = - containerTop + scrollbarsRef.current.getClientHeight(); - - const { offsetTop: elementTop, clientHeight: elementHeight } = - activeAccountRef.current; - const elementBottom = elementTop + elementHeight; - - if (elementTop < containerTop || elementBottom > containerBottom) { - activeAccountRef.current.scrollIntoView({ block: 'center' }); - } - } - }, [active?.id]); - return ( - - - {Object.keys(primaryAccount).map((walletId) => { - const walletAccounts = primaryAccount[walletId]; - const walletDetails = wallets.find( - (wallet) => wallet.id === walletId + + {Object.keys(primaryAccounts).map((walletId) => { + const walletAccounts = primaryAccounts[walletId]; + const walletDetails = wallets.find((wallet) => wallet.id === walletId); + + if (!walletDetails) { + return; + } + + const isActive = activeWalletDetails?.id === walletId; + + if (walletAccounts && walletAccounts.length > 0) { + return ( + ); - - if (!walletDetails) { - return; - } - - const isActive = activeWalletDetails?.id === walletId; - - if (walletAccounts && walletAccounts.length > 0) { - return ( - - - {walletAccounts.map((account) => ( - - ))} - - ); - } - })} - - + } + })} + ); }; diff --git a/src/pages/Accounts/components/AccountName.tsx b/src/pages/Accounts/components/AccountName.tsx index 7496b125f..5c9307248 100644 --- a/src/pages/Accounts/components/AccountName.tsx +++ b/src/pages/Accounts/components/AccountName.tsx @@ -1,20 +1,18 @@ -import { ChangeEvent, KeyboardEvent, useCallback, useState } from 'react'; -import { AccountNameInput } from './AccountNameInput'; import { - ClickAwayListener, - EditIcon, - Slide, + Box, + Grow, + IconButton, + PencilRoundIcon, Stack, - toast, - Typography, } from '@avalabs/core-k2-components'; -import { useTranslation } from 'react-i18next'; -import { useAccountsContext } from '@src/contexts/AccountsProvider'; + +import { useAccountManager } from '../providers/AccountManagerProvider'; +import { OverflowingTypography } from './OverflowingTypography'; interface AccountNameProps { - accountId: string; accountName: string; cardHovered: boolean; + promptRename(): void; isActive?: boolean; } @@ -26,95 +24,54 @@ const commonTransitionProps = { export default function AccountName({ accountName, - accountId, cardHovered, isActive, + promptRename, }: AccountNameProps) { - const { t } = useTranslation(); - const { renameAccount } = useAccountsContext(); - - const [isAccountNameEditing, setIsAccountNameEditing] = useState(false); - const [newName, setNewName] = useState(accountName); - const [, setErrorToastId] = useState(''); - - const onSave = useCallback(() => { - if (newName === accountName) { - setIsAccountNameEditing(false); - return; - } - - if (newName.trim().length === 0) { - setErrorToastId((prevToastId) => { - if (prevToastId) { - toast.dismiss(prevToastId); - } - setIsAccountNameEditing(false); - return toast.error(t('New name is required'), { duration: 2000 }); - }); - return; - } - - renameAccount(accountId, newName.trim()) - .then(() => { - toast.success(t('Account renamed')); - }) - .catch(() => toast.error(t('Renaming failed'))) - .finally(() => { - setIsAccountNameEditing(false); - }); - }, [accountId, accountName, newName, renameAccount, t]); + const { isManageMode } = useAccountManager(); return ( - setIsAccountNameEditing(false)} + - - {!isAccountNameEditing && ( - - {accountName} - - )} - {isAccountNameEditing && ( - ) => { - setNewName(e.target.value); - }} - onKeyDown={(e: KeyboardEvent) => { - if (e.key === 'Enter') { - onSave(); - } else if (e.key === 'Escape') { - e.preventDefault(); - setIsAccountNameEditing(false); - } - }} - onClick={(e) => e.stopPropagation()} - typography="h6" - align="left" - autoFocus - isActive={isActive} - /> - )} - {cardHovered && !isAccountNameEditing && ( - - - { - e.stopPropagation(); - setIsAccountNameEditing(true); - }} - /> - - - )} - - + + + {accountName} + + + { + e.stopPropagation(); + promptRename(); + }} + > + + + + ); } diff --git a/src/pages/Accounts/components/AccountNameInput.tsx b/src/pages/Accounts/components/AccountNameInput.tsx deleted file mode 100644 index 064790f99..000000000 --- a/src/pages/Accounts/components/AccountNameInput.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { TextFieldProps, useTheme } from '@avalabs/core-k2-components'; -import { TextField } from '@mui/material'; - -export const AccountNameInput = ( - props: TextFieldProps & { - typography?: 'h4' | 'button' | 'h6'; - align?: 'left' | 'center'; - isActive?: boolean; - } -) => { - const theme = useTheme(); - const typographyType = props.typography ?? 'h4'; - return ( - - ); -}; diff --git a/src/pages/Accounts/components/AccountsActionButton.tsx b/src/pages/Accounts/components/AccountsActionButton.tsx index 55c1d9ac7..ac29e6587 100644 --- a/src/pages/Accounts/components/AccountsActionButton.tsx +++ b/src/pages/Accounts/components/AccountsActionButton.tsx @@ -15,6 +15,7 @@ import { ListIcon, Typography, TypographyProps, + PlusIcon, } from '@avalabs/core-k2-components'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,10 +30,10 @@ import { ChainId } from '@avalabs/core-chains-sdk'; import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; type AccountsActionButtonProps = { - disabled?: boolean; - isButtonDisabled?: boolean; + isLoading: boolean; + canCreateAccount: boolean; onAddNewAccount: () => void; - disabledButtonTooltipText?: string; + createAccountTooltip?: string; }; const StyledMenuItem = styled(MenuItem)` @@ -42,6 +43,29 @@ const StyledMenuItem = styled(MenuItem)` } `; +const RoundedButtonGroup = styled(ButtonGroup)` + & > .MuiButtonGroup-grouped { + border-radius: 0; + height: 40px; + + &:not(:last-of-type) { + margin-right: 1px; + + &.Mui-disabled { + margin-right: 1px; + } + } + + &:first-of-type { + border-radius: 24px 0 0 24px; + } + + &:last-of-type { + border-radius: 0 24px 24px 0; + } + } +`; + const MenuSubheader = (props: TypographyProps) => ( { const [isMenuOpen, setIsMenuOpen] = useState(false); const history = useHistory(); @@ -123,27 +147,27 @@ export const AccountsActionButton = ({ ); return ( - @@ -151,11 +175,13 @@ export const AccountsActionButton = ({ - + ); }; diff --git a/src/pages/Accounts/components/ConfirmAccountRemovalDialog.tsx b/src/pages/Accounts/components/ConfirmAccountRemovalDialog.tsx index ec1fe69d6..413eff333 100644 --- a/src/pages/Accounts/components/ConfirmAccountRemovalDialog.tsx +++ b/src/pages/Accounts/components/ConfirmAccountRemovalDialog.tsx @@ -50,7 +50,10 @@ export const ConfirmAccountRemovalDialog = ({ + + + + ); +}; diff --git a/src/pages/Accounts/components/WalletContainer.tsx b/src/pages/Accounts/components/WalletContainer.tsx new file mode 100644 index 000000000..739ce5ea4 --- /dev/null +++ b/src/pages/Accounts/components/WalletContainer.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +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 { useNetworkContext } from '@src/contexts/NetworkProvider'; + +import { useWalletTotalBalance } from '../hooks/useWalletTotalBalance'; +import { SelectionMode } from '../providers/AccountManagerProvider'; +import { AccountItem } from './AccountItem'; +import WalletHeader from './WalletHeader'; + +export const WalletContainer = ({ + walletDetails, + isActive, + accounts, +}: { + activeAccountId?: string; + walletDetails: WalletDetails; + isActive: boolean; + accounts: PrimaryAccount[]; +}) => { + const { t } = useTranslation(); + const { isDeveloperMode } = useNetworkContext(); + const [isExpanded, setIsExpanded] = useState(true); + const { + isLoading, + hasErrorOccurred, + totalBalanceInCurrency, + hasBalanceOnUnderivedAccounts, + } = useWalletTotalBalance(walletDetails.id); + + return ( + + setIsExpanded((e) => !e)} + /> + + + {accounts.map((account) => ( + + ))} + + + + + + + + + ); +}; diff --git a/src/pages/Accounts/components/WalletHeader.tsx b/src/pages/Accounts/components/WalletHeader.tsx index bd678c4b4..1cf32ec90 100644 --- a/src/pages/Accounts/components/WalletHeader.tsx +++ b/src/pages/Accounts/components/WalletHeader.tsx @@ -1,136 +1,139 @@ +import { useState } from 'react'; import { + ChevronUpIcon, Chip, - ClickAwayListener, - EditIcon, + Grow, + IconButton, LedgerIcon, - Slide, + LoadingDotsIcon, + PencilRoundIcon, Stack, - toast, Typography, - useTheme, } 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 { AccountNameInput } from './AccountNameInput'; -import { ChangeEvent, KeyboardEvent, useCallback, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useWalletContext } from '@src/contexts/WalletProvider'; + +import { useAccountManager } from '../providers/AccountManagerProvider'; +import { OverflowingTypography } from './OverflowingTypography'; +import { useWalletRename } from '../hooks/useWalletRename'; +import { useSettingsContext } from '@src/contexts/SettingsProvider'; const commonTransitionProps = { timeout: 200, easing: 'ease-in-out', appear: true, }; -interface WalletHeaderProps { - walletDetails: WalletDetails; +type WalletHeaderProps = { isActive: boolean; -} + isExpanded: boolean; + isLoading: boolean; + totalBalance?: number; + hasBalanceError: boolean; + toggle: () => void; +} & ( + | { + walletDetails: WalletDetails; + name?: never; + } + | { name: string; walletDetails?: never } +); export default function WalletHeader({ walletDetails, + name, isActive, + isExpanded, + isLoading, + totalBalance, + toggle, }: WalletHeaderProps) { - const [isWalletNameEditing, setIsWalletNameEditing] = useState(false); - const [newWalletName, setNewWalletName] = useState(walletDetails?.name); const { t } = useTranslation(); - const theme = useTheme(); - const { renameWallet } = useWalletContext(); - const [cardHovered, setCardHovered] = useState(false); - const [, setErrorToastId] = useState(''); + const { isManageMode } = useAccountManager(); + const { currencyFormatter } = useSettingsContext(); + const [isHovered, setIsHovered] = useState(false); - const onSave = useCallback(() => { - setIsWalletNameEditing(false); - - if (!newWalletName || newWalletName.trim().length === 0) { - setErrorToastId((prevToastId) => { - if (prevToastId) { - toast.dismiss(prevToastId); - } - setIsWalletNameEditing(false); - return toast.error(t('New Wallet Name is Required'), { - duration: 2000, - }); - }); - return; - } - renameWallet(walletDetails.id, newWalletName.trim()) - .then(() => { - toast.success(t('Wallet Renamed')); - }) - .catch(() => { - toast.error(t('Renaming Failed')); - }) - .finally(() => { - setIsWalletNameEditing(false); - }); - }, [newWalletName, renameWallet, t, walletDetails]); + const { prompt: promptRename, renderDialog: renameDialog } = + useWalletRename(walletDetails); return ( - setIsWalletNameEditing(false)} + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > setCardHovered(true)} - onMouseLeave={() => setCardHovered(false)} - onClick={() => setIsWalletNameEditing(true)} > {(walletDetails?.type === SecretType.Ledger || walletDetails?.type == SecretType.LedgerLive) && ( - + )} - {!isWalletNameEditing && ( - {walletDetails?.name} + + {walletDetails?.name ?? name} + + + + + {/* Section for the imported accounts has no WalletDetails, therefore cannot be renamed */} + {walletDetails && ( + + + + + )} - {isWalletNameEditing && ( - ) => { - setNewWalletName(e.target.value); - }} - onKeyDown={(e: KeyboardEvent) => { - if (e.key === 'Enter') { - onSave(); - } else if (e.key === 'Escape') { - e.preventDefault(); - setIsWalletNameEditing(false); - } - }} - typography="button" - align="left" - autoFocus + + + + + {isLoading ? ( + + ) : typeof totalBalance === 'number' ? ( + currencyFormatter(totalBalance) + ) : null} + + + - )} - {isActive && !isWalletNameEditing && ( - - )} - {(cardHovered || isWalletNameEditing) && ( - - - setIsWalletNameEditing(true)} - /> - - - )} + - + {walletDetails && renameDialog()} + ); } diff --git a/src/pages/Accounts/components/XPChainIcon.tsx b/src/pages/Accounts/components/XPChainIcon.tsx deleted file mode 100644 index 5ae536899..000000000 --- a/src/pages/Accounts/components/XPChainIcon.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - AvalancheColorIcon, - Avatar, - Badge, - Stack, -} from '@avalabs/core-k2-components'; - -export const XPChainIcon = () => ( - - - X - - - P - - - } - > - - -); diff --git a/src/pages/Accounts/hooks/useAccountRemoval.tsx b/src/pages/Accounts/hooks/useAccountRemoval.tsx new file mode 100644 index 000000000..394c79bd2 --- /dev/null +++ b/src/pages/Accounts/hooks/useAccountRemoval.tsx @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; + +import { useScopedToast } from '@src/hooks/useScopedToast'; +import { useAccountsContext } from '@src/contexts/AccountsProvider'; + +import { ConfirmAccountRemovalDialog } from '../components/ConfirmAccountRemovalDialog'; +import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; +import { useAccountManager } from '../providers/AccountManagerProvider'; + +export const useAccountRemoval = (accountIds: string[]) => { + const [isPrompted, setIsPrompted] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const { t } = useTranslation(); + const { exitManageMode } = useAccountManager(); + const { deleteAccounts } = useAccountsContext(); + const { capture } = useAnalyticsContext(); + const toast = useScopedToast('account-switcher'); + const history = useHistory(); + + const confirm = useCallback(async () => { + setIsDeleting(true); + deleteAccounts(accountIds) + .then(() => { + capture('AccountDeleteSucceeded'); + setIsPrompted(false); + exitManageMode(); + history.replace('/accounts'); + toast.success( + t('Successfully deleted {{number}} account(s)', { + number: accountIds.length, + }) + ); + }) + .catch(() => { + toast.error(t('Account(s) removal has failed!'), { duration: 2000 }); + capture('AccountDeleteFailed'); + }) + .finally(() => { + setIsDeleting(false); + }); + }, [accountIds, capture, deleteAccounts, exitManageMode, history, t, toast]); + + const prompt = useCallback(() => setIsPrompted(true), []); + const cancel = useCallback(() => setIsPrompted(false), []); + + const renderDialog = useCallback( + () => ( + 1} + onConfirm={confirm} + onClose={cancel} + /> + ), + [isDeleting, isPrompted, accountIds, cancel, confirm] + ); + + return { + prompt, + renderDialog, + }; +}; diff --git a/src/pages/Accounts/hooks/useAccountRename.tsx b/src/pages/Accounts/hooks/useAccountRename.tsx new file mode 100644 index 000000000..282f29e22 --- /dev/null +++ b/src/pages/Accounts/hooks/useAccountRename.tsx @@ -0,0 +1,36 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useScopedToast } from '@src/hooks/useScopedToast'; +import { useAccountsContext } from '@src/contexts/AccountsProvider'; +import { Account } from '@src/background/services/accounts/models'; + +import { useEntityRename } from './useEntityRename'; + +export const useAccountRename = (account?: Account) => { + const { t } = useTranslation(); + const { renameAccount } = useAccountsContext(); + const toast = useScopedToast('account-switcher'); + + const onFailure = useCallback( + () => toast.success(t('Renaming failed'), { duration: 1000 }), + [toast, t] + ); + const onSuccess = useCallback( + () => toast.success(t('Account renamed'), { duration: 1000 }), + [toast, t] + ); + const updateFn = useCallback( + (newName: string) => + account?.id ? renameAccount(account.id, newName.trim()) : undefined, + [renameAccount, account?.id] + ); + + return useEntityRename({ + currentName: account?.name ?? '', + dialogTitle: t('Rename Account'), + updateFn, + onFailure, + onSuccess, + }); +}; diff --git a/src/pages/Accounts/hooks/useEntityRename.tsx b/src/pages/Accounts/hooks/useEntityRename.tsx new file mode 100644 index 000000000..c04d65e9c --- /dev/null +++ b/src/pages/Accounts/hooks/useEntityRename.tsx @@ -0,0 +1,65 @@ +import { useScopedToast } from '@src/hooks/useScopedToast'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RenameDialog } from '../components/RenameDialog'; + +export const useEntityRename = ({ + updateFn, + currentName, + dialogTitle, + onSuccess, + onFailure, +}) => { + const [isSaving, setIsSaving] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + + const { t } = useTranslation(); + const toast = useScopedToast('account-switcher'); + + const prompt = useCallback(() => setIsRenaming(true), []); + const cancel = useCallback(() => setIsRenaming(false), []); + const confirm = useCallback( + (newName: string) => { + if (newName === currentName) { + setIsRenaming(false); + return; + } + + if (newName.trim().length === 0) { + toast.error(t('New name is required'), { duration: 1000 }); + return; + } + + setIsSaving(true); + updateFn(newName.trim()) + .then(() => { + setIsRenaming(false); + onSuccess(); + }) + .catch(onFailure) + .finally(() => { + setIsSaving(false); + }); + }, + [updateFn, currentName, onSuccess, onFailure, t, toast] + ); + + const renderDialog = useCallback( + () => ( + + ), + [currentName, dialogTitle, cancel, confirm, isRenaming, isSaving] + ); + + return { + prompt, + renderDialog, + }; +}; diff --git a/src/pages/Accounts/hooks/useWalletRename.tsx b/src/pages/Accounts/hooks/useWalletRename.tsx new file mode 100644 index 000000000..c41940908 --- /dev/null +++ b/src/pages/Accounts/hooks/useWalletRename.tsx @@ -0,0 +1,42 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useScopedToast } from '@src/hooks/useScopedToast'; +import { useWalletContext } from '@src/contexts/WalletProvider'; +import { WalletDetails } from '@src/background/services/wallet/models'; + +import { useEntityRename } from './useEntityRename'; + +export const useWalletRename = (wallet?: WalletDetails) => { + const { t } = useTranslation(); + const { renameWallet } = useWalletContext(); + const toast = useScopedToast('account-switcher'); + + const onFailure = useCallback( + () => toast.success(t('Renaming failed'), { duration: 1000 }), + [toast, t] + ); + const onSuccess = useCallback( + () => toast.success(t('Wallet renamed'), { duration: 1000 }), + [toast, t] + ); + const updateFn = useCallback( + (newName: string) => { + if (!wallet?.id) { + toast.error(t('This wallet cannot be renamed'), { duration: 1000 }); + return; + } + + return renameWallet(wallet.id, newName.trim()); + }, + [renameWallet, wallet?.id, t, toast] + ); + + return useEntityRename({ + currentName: wallet?.name ?? '', + dialogTitle: t('Rename Wallet'), + updateFn, + onFailure, + onSuccess, + }); +}; diff --git a/src/pages/Accounts/hooks/useWalletTotalBalance.ts b/src/pages/Accounts/hooks/useWalletTotalBalance.ts new file mode 100644 index 000000000..c5d31130f --- /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/useWalletTypeName.tsx b/src/pages/Accounts/hooks/useWalletTypeName.tsx new file mode 100644 index 000000000..7388260f7 --- /dev/null +++ b/src/pages/Accounts/hooks/useWalletTypeName.tsx @@ -0,0 +1,57 @@ +import { Account, AccountType } from '@src/background/services/accounts/models'; +import { SecretType } from '@src/background/services/secrets/models'; +import { WalletDetails } from '@src/background/services/wallet/models'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const useWalletTypeName = ( + walletDetails?: WalletDetails, + account?: Account +) => { + const { t } = useTranslation(); + + const getWalletType = useCallback(() => { + switch (walletDetails?.type) { + case SecretType.Ledger: + case SecretType.LedgerLive: + return t('Ledger'); + + case SecretType.Mnemonic: + return t('Recovery Phrase'); + + case SecretType.PrivateKey: + return t('Imported Private Key'); + + case SecretType.Fireblocks: + return t('Fireblocks'); + + case SecretType.WalletConnect: + return t('WalletConnect'); + + case SecretType.Keystone: + return t('Keystone'); + + case SecretType.Seedless: + return walletDetails.authProvider + ? t('Seedless ({{provider}})', { + provider: walletDetails.authProvider, + }) + : t('Seedless'); + } + + switch (account?.type) { + case AccountType.IMPORTED: + return t('Imported Private Key'); + + case AccountType.FIREBLOCKS: + return t('Fireblocks'); + + case AccountType.WALLET_CONNECT: + return t('WalletConnect'); + } + + return t('Unknown'); + }, [account?.type, t, walletDetails?.type, walletDetails?.authProvider]); + + return getWalletType; +}; diff --git a/src/pages/Accounts/providers/AccountManagerProvider.tsx b/src/pages/Accounts/providers/AccountManagerProvider.tsx index b8c30937b..1aab4ca55 100644 --- a/src/pages/Accounts/providers/AccountManagerProvider.tsx +++ b/src/pages/Accounts/providers/AccountManagerProvider.tsx @@ -36,6 +36,12 @@ export const AccountManagerContext = createContext<{ toggleManageMode() {}, }); +export enum SelectionMode { + None, // Reserved for Seedless + Any, + Consecutive, +} + export const AccountManagerProvider = ({ children, }: AccountManagerContextProps) => { diff --git a/src/pages/Accounts/providers/WalletTotalBalanceProvider.tsx b/src/pages/Accounts/providers/WalletTotalBalanceProvider.tsx new file mode 100644 index 000000000..d00af5610 --- /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/pages/Fireblocks/ConnectBitcoinWallet.tsx b/src/pages/Fireblocks/ConnectBitcoinWallet.tsx index ba52d0faf..4a751cc8b 100644 --- a/src/pages/Fireblocks/ConnectBitcoinWallet.tsx +++ b/src/pages/Fireblocks/ConnectBitcoinWallet.tsx @@ -15,7 +15,6 @@ import { FireblocksAvatar } from './components/FireblocksAvatar'; import { ExtensionRequest } from '@src/background/connections/extensionConnection/models'; import { useConnectionContext } from '@src/contexts/ConnectionProvider'; import { useCallback, useState } from 'react'; -import { AccountsTab } from '../Accounts/Accounts'; import { TextFieldLabel } from '@src/components/common/TextFieldLabel'; import { LoadingOverlay } from '@src/components/common/LoadingOverlay'; import { FireblocksUpdateApiCredentialsHandler } from '@src/background/services/fireblocks/handlers/fireblocksUpdateApiCredentials'; @@ -39,7 +38,7 @@ export default function ConnectBitcoinWallet() { const onNextStep = useCallback(() => { toast.success(t('New Account Added!'), { duration: 2000 }); - history.push(`/accounts?activeTab=${AccountsTab.Imported}`); + history.push('/accounts'); }, [history, t]); const onConnect = useCallback(() => { diff --git a/src/pages/Fireblocks/components/FireblocksBitcoinDialog.tsx b/src/pages/Fireblocks/components/FireblocksBitcoinDialog.tsx index a6551bd5a..68b323474 100644 --- a/src/pages/Fireblocks/components/FireblocksBitcoinDialog.tsx +++ b/src/pages/Fireblocks/components/FireblocksBitcoinDialog.tsx @@ -8,7 +8,6 @@ import { toast, } from '@avalabs/core-k2-components'; import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; -import { AccountsTab } from '@src/pages/Accounts/Accounts'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; @@ -50,7 +49,7 @@ export const FireblocksBitcoinDialog = ({ onClick={() => { capture('ImportWithFireblocks_BTC_Skipped'); toast.success(t('New Account Added!'), { duration: 2000 }); - history.push(`/accounts?activeTab=${AccountsTab.Imported}`); + history.push('/accounts'); }} variant="text" > diff --git a/src/pages/Home/components/Portfolio/NetworkWidget/PchainActiveNetworkWidgetContent.tsx b/src/pages/Home/components/Portfolio/NetworkWidget/PchainActiveNetworkWidgetContent.tsx index 56d4e74d9..21cda2207 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 diff --git a/src/pages/ImportPrivateKey/ImportPrivateKey.tsx b/src/pages/ImportPrivateKey/ImportPrivateKey.tsx index a124e5fee..6842b0acd 100644 --- a/src/pages/ImportPrivateKey/ImportPrivateKey.tsx +++ b/src/pages/ImportPrivateKey/ImportPrivateKey.tsx @@ -5,7 +5,6 @@ import { Stack, TextField, Typography, - toast, useTheme, } from '@avalabs/core-k2-components'; import { @@ -24,10 +23,10 @@ import { networks } from 'bitcoinjs-lib'; import { t } from 'i18next'; import { KeyboardEvent, useCallback, useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; -import { AccountsTab } from '../Accounts/Accounts'; import { DerivedAddress, NetworkType } from './components/DerivedAddress'; import { utils } from '@avalabs/avalanchejs'; import { usePrivateKeyImport } from '../Accounts/hooks/usePrivateKeyImport'; +import { useScopedToast } from '@src/hooks/useScopedToast'; import { useAccountsContext } from '@src/contexts/AccountsProvider'; import { DuplicatedAccountDialog } from './components/DuplicatedAccountDialog'; @@ -42,6 +41,7 @@ export function ImportPrivateKey() { const { network } = useNetworkContext(); const { capture } = useAnalyticsContext(); const theme = useTheme(); + const toast = useScopedToast('account-switcher'); const [hasFocus, setHasFocus] = useState(false); const [privateKey, setPrivateKey] = useState(''); const [derivedAddresses, setDerivedAddresses] = useState(); @@ -81,11 +81,12 @@ export function ImportPrivateKey() { try { const importedAccountId = await importPrivateKey(privateKey); await selectAccount(importedAccountId); - toast.success(t('Private Key Imported'), { duration: 2000 }); + toast.success(t('Private Key Imported'), { duration: 1000 }); capture('ImportPrivateKeySucceeded'); - history.replace(`/accounts?activeTab=${AccountsTab.Imported}`); + history.replace(`/accounts`); } catch (err) { - toast.error(t('Private Key Import Failed'), { duration: 2000 }); + toast.error(t('Private Key Import Failed'), { duration: 1000 }); + console.error(err); } }; diff --git a/src/pages/ImportWithWalletConnect/ImportWithWalletConnect.tsx b/src/pages/ImportWithWalletConnect/ImportWithWalletConnect.tsx index d9b395de9..8a393fe7a 100644 --- a/src/pages/ImportWithWalletConnect/ImportWithWalletConnect.tsx +++ b/src/pages/ImportWithWalletConnect/ImportWithWalletConnect.tsx @@ -7,7 +7,6 @@ import { PageTitle } from '@src/components/common/PageTitle'; import { WalletConnectCircledIcon } from './components/WalletConnectCircledIcon'; import WalletConnectConnector from './components/WalletConnectConnector'; -import { AccountsTab } from '../Accounts/Accounts'; import { OnConnectCallback } from '@src/contexts/WalletConnectContextProvider/models'; import { useAnalyticsContext } from '@src/contexts/AnalyticsProvider'; @@ -31,7 +30,7 @@ export default function ImportWithWalletConnect({ onConnect(result); } else { capture('ImportWithWalletConnect_Success'); - replace(`/accounts?activeTab=${AccountsTab.Imported}`); + replace('/accounts'); } }, [replace, onConnect, capture] diff --git a/src/pages/Network/AddCustomNetworkPopup.tsx b/src/pages/Network/AddCustomNetworkPopup.tsx index 89cf81026..1dbf3828a 100644 --- a/src/pages/Network/AddCustomNetworkPopup.tsx +++ b/src/pages/Network/AddCustomNetworkPopup.tsx @@ -92,7 +92,7 @@ export function AddCustomNetworkPopup() { const keyboardShortcuts = useKeyboardShortcuts({ Enter: saveApiKey, - Esc: () => setIsApiModalVisible(false), + Escape: () => setIsApiModalVisible(false), }); if (!action || !action.displayData) { diff --git a/src/popup/AppRoutes.tsx b/src/popup/AppRoutes.tsx index d23e41d4a..e32e516ef 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 cd54ebe18..0eae74001 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 b634cdb0f..e72fe9bf4 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 b8c21e65d..96720bee4 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 2f7dd4417..8e15d8a55 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/getDefaultChainIds.ts b/src/utils/getDefaultChainIds.ts new file mode 100644 index 000000000..489bede2a --- /dev/null +++ b/src/utils/getDefaultChainIds.ts @@ -0,0 +1,15 @@ +import { ChainId } from '@avalabs/core-chains-sdk'; + +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]; +} + +export function getDefaultChainIds(isMainnet: boolean) { + return [ + isMainnet ? ChainId.AVALANCHE_MAINNET_ID : ChainId.AVALANCHE_TESTNET_ID, + ...getXPChainIds(isMainnet), + ]; +} diff --git a/src/utils/hasAccountBalances.ts b/src/utils/hasAccountBalances.ts index 3f94d1c77..9c03282dc 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); diff --git a/yarn.lock b/yarn.lock index cf6ae84b3..1fa112ced 100644 --- a/yarn.lock +++ b/yarn.lock @@ -125,10 +125,10 @@ dependencies: "@avalabs/core-utils-sdk" "3.1.0-alpha.19" -"@avalabs/core-k2-components@4.18.0-alpha.47": - version "4.18.0-alpha.47" - resolved "https://registry.yarnpkg.com/@avalabs/core-k2-components/-/core-k2-components-4.18.0-alpha.47.tgz#94d588cf109350fe57d246dbf36bc127a1fc0584" - integrity sha512-eXgd3mgHJKHyQRGHxcQV4MJBzJT0Orr+w2xrWZ1M9dGcQBTGbOxQ5h0Y/TPRkUXrP8SVhonEM2ACX8YTSTbKDA== +"@avalabs/core-k2-components@4.18.0-alpha.50": + version "4.18.0-alpha.50" + resolved "https://registry.yarnpkg.com/@avalabs/core-k2-components/-/core-k2-components-4.18.0-alpha.50.tgz#129b2615fa170a0373b82557c1d829fbb4c31867" + integrity sha512-iG5xsZBC9LuLzWihV/JjbtWwA9zVQyKxmabpKhvNp8Ywaq6h2o7kmMUNT8DfGnaUgDePfINJ57DZsH3oBnNcCg== dependencies: "@emotion/react" "11.11.1" "@emotion/styled" "11.11.0"