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