diff --git a/src/lib/market-service/utils.ts b/packages/utils/src/createThrottle.ts similarity index 100% rename from src/lib/market-service/utils.ts rename to packages/utils/src/createThrottle.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8f20df69e14..08dc654bf5e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,6 +12,7 @@ export * from './baseUnits/baseUnits' export * from './promises' export * from './treasury' export * from './timeout' +export * from './createThrottle' export const isSome = (option: T | null | undefined): option is T => !isUndefined(option) && !isNull(option) diff --git a/src/lib/market-service/portals/portals.ts b/src/lib/market-service/portals/portals.ts index 57d938c0135..3bff0762fff 100644 --- a/src/lib/market-service/portals/portals.ts +++ b/src/lib/market-service/portals/portals.ts @@ -10,6 +10,7 @@ import type { PriceHistoryArgs, } from '@shapeshiftoss/types' import { HistoryTimeframe } from '@shapeshiftoss/types' +import { createThrottle } from '@shapeshiftoss/utils' import Axios from 'axios' import { setupCache } from 'axios-cache-interceptor' import { getConfig } from 'config' @@ -22,7 +23,6 @@ import { assertUnreachable, getTimeFrameBounds, isToken } from 'lib/utils' import generatedAssetData from '../../asset-service/service/generatedAssetData.json' import type { MarketService } from '../api' import { DEFAULT_CACHE_TTL_MS } from '../config' -import { createThrottle } from '../utils' import { isValidDate } from '../utils/isValidDate' import { CHAIN_ID_TO_PORTALS_NETWORK } from './constants' import type { GetTokensResponse, HistoryResponse } from './types' diff --git a/src/lib/market-service/portals/types.ts b/src/lib/market-service/portals/types.ts index 240c385eecf..8914de3ebda 100644 --- a/src/lib/market-service/portals/types.ts +++ b/src/lib/market-service/portals/types.ts @@ -29,6 +29,11 @@ export type PlatformsById = Record export type GetPlatformsResponse = Platform[] +export type GetBalancesResponse = { + // Not strictly true, this has additional fields, but we're only interested in the token info part + balances: TokenInfo[] +} + export type GetTokensResponse = { totalItems: number pageItems: number diff --git a/src/lib/market-service/zerion/zerion.ts b/src/lib/market-service/zerion/zerion.ts index 82ff1ce3fd8..e35198bdc1b 100644 --- a/src/lib/market-service/zerion/zerion.ts +++ b/src/lib/market-service/zerion/zerion.ts @@ -16,6 +16,7 @@ import { type PriceHistoryArgs, ZERION_CHAINS_MAP, } from '@shapeshiftoss/types' +import { createThrottle } from '@shapeshiftoss/utils' import Axios from 'axios' import { setupCache } from 'axios-cache-interceptor' import { getConfig } from 'config' @@ -25,7 +26,6 @@ import { assertUnreachable, isToken } from 'lib/utils' import type { MarketService } from '../api' import { DEFAULT_CACHE_TTL_MS } from '../config' -import { createThrottle } from '../utils' import type { ListFungiblesResponse, ZerionChartResponse, diff --git a/src/lib/portals/utils.ts b/src/lib/portals/utils.ts new file mode 100644 index 00000000000..f7c8cbaadd8 --- /dev/null +++ b/src/lib/portals/utils.ts @@ -0,0 +1,83 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { ASSET_NAMESPACE, bscChainId, toAssetId } from '@shapeshiftoss/caip' +import axios from 'axios' +import { getConfig } from 'config' +import { CHAIN_ID_TO_PORTALS_NETWORK } from 'lib/market-service/portals/constants' +import type { + GetBalancesResponse, + GetPlatformsResponse, + PlatformsById, + TokenInfo, +} from 'lib/market-service/portals/types' + +export const fetchPortalsPlatforms = async (): Promise => { + const url = `${getConfig().REACT_APP_PORTALS_BASE_URL}/v2/platforms` + + try { + const { data: platforms } = await axios.get(url, { + headers: { + Authorization: `Bearer ${getConfig().REACT_APP_PORTALS_API_KEY}`, + }, + }) + + const byId = platforms.reduce((acc, platform) => { + acc[platform.platform] = platform + return acc + }, {}) + + return byId + } catch (error) { + if (axios.isAxiosError(error)) { + console.error(`Failed to fetch Portals platforms: ${error.message}`) + } + console.error(`Failed to fetch Portals platforms: ${error}`) + + return {} + } +} + +export const fetchPortalsAccount = async ( + chainId: ChainId, + owner: string, +): Promise> => { + const url = `${getConfig().REACT_APP_PORTALS_BASE_URL}/v2/account` + + const network = CHAIN_ID_TO_PORTALS_NETWORK[chainId] + + if (!network) throw new Error(`Unsupported chainId: ${chainId}`) + + try { + const { data } = await axios.get(url, { + params: { + networks: [network], + owner, + }, + headers: { + Authorization: `Bearer ${getConfig().REACT_APP_PORTALS_API_KEY}`, + }, + }) + + return data.balances.reduce>((acc, token) => { + const assetId = toAssetId({ + chainId, + assetNamespace: chainId === bscChainId ? ASSET_NAMESPACE.bep20 : ASSET_NAMESPACE.erc20, + assetReference: token.address, + }) + acc[assetId] = token + return acc + }, {}) + } catch (error) { + if (axios.isAxiosError(error)) { + console.error(`Failed to fetch Portals account: ${error.message}`) + } else { + console.error(error) + } + return {} + } +} + +export const maybeTokenImage = (image: string | undefined) => { + if (!image) return + if (image === 'missing_large.png') return + return image +} diff --git a/src/state/migrations/index.ts b/src/state/migrations/index.ts index 5a650962683..f2e7fa4d6cd 100644 --- a/src/state/migrations/index.ts +++ b/src/state/migrations/index.ts @@ -115,4 +115,5 @@ export const migrations = { 104: clearTxHistory, 105: clearPortfolio, 106: clearOpportunities, + 107: clearAssets, } diff --git a/src/state/slices/portfolioSlice/portfolioSlice.ts b/src/state/slices/portfolioSlice/portfolioSlice.ts index 4ade02ed43d..512b667413a 100644 --- a/src/state/slices/portfolioSlice/portfolioSlice.ts +++ b/src/state/slices/portfolioSlice/portfolioSlice.ts @@ -1,10 +1,11 @@ import { createSlice, prepareAutoBatched } from '@reduxjs/toolkit' import { createApi } from '@reduxjs/toolkit/query/react' import type { AccountId, ChainId } from '@shapeshiftoss/caip' -import { fromAccountId, isNft } from '@shapeshiftoss/caip' +import { ASSET_NAMESPACE, bscChainId, fromAccountId, isNft, toAssetId } from '@shapeshiftoss/caip' import type { Account } from '@shapeshiftoss/chain-adapters' import { evmChainIds } from '@shapeshiftoss/chain-adapters' import type { AccountMetadataById, EvmChainId } from '@shapeshiftoss/types' +import type { MinimalAsset } from '@shapeshiftoss/utils' import { makeAsset } from '@shapeshiftoss/utils' import cloneDeep from 'lodash/cloneDeep' import merge from 'lodash/merge' @@ -13,6 +14,7 @@ import { PURGE } from 'redux-persist' import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton' import { getMixPanel } from 'lib/mixpanel/mixPanelSingleton' import { MixPanelEvent } from 'lib/mixpanel/types' +import { fetchPortalsAccount, fetchPortalsPlatforms, maybeTokenImage } from 'lib/portals/utils' import { BASE_RTK_CREATE_API_CONFIG } from 'state/apis/const' import { isSpammyNftText, isSpammyTokenText } from 'state/apis/nft/constants' import { selectNftCollections } from 'state/apis/nft/selectors' @@ -197,9 +199,11 @@ export const portfolioApi = createApi({ const portfolioAccounts = { [pubkey]: await adapter.getAccount(pubkey) } const nftCollectionsById = selectNftCollections(state) - const data = ((): Portfolio => { + const data = await (async (): Promise => { // add placeholder non spam assets for evm chains if (evmChainIds.includes(chainId as EvmChainId)) { + const maybePortalsAccounts = await fetchPortalsAccount(chainId, pubkey) + const maybePortalsPlatforms = await fetchPortalsPlatforms() const account = portfolioAccounts[pubkey] as Account const assets = (account.chainSpecific.tokens ?? []).reduce( @@ -209,7 +213,103 @@ export const portfolioApi = createApi({ return isSpammyTokenText(text) }) if (state.assets.byId[token.assetId] || isSpam) return prev - prev.byId[token.assetId] = makeAsset(state.assets.byId, { ...token }) + let minimalAsset: MinimalAsset = token + const maybePortalsAsset = maybePortalsAccounts[token.assetId] + if (maybePortalsAsset) { + const isPool = Boolean( + maybePortalsAsset.platform && maybePortalsAsset.tokens?.length, + ) + const platform = maybePortalsPlatforms[maybePortalsAsset.platform] + + const name = (() => { + // For single assets, just use the token name + if (!isPool) return maybePortalsAsset.name + // For pools, create a name in the format of " Pool" + // e.g "UniswapV2 ETH/FOX Pool" + const assetSymbols = + maybePortalsAsset.tokens?.map(underlyingToken => { + const assetId = toAssetId({ + chainId, + assetNamespace: + chainId === bscChainId + ? ASSET_NAMESPACE.bep20 + : ASSET_NAMESPACE.erc20, + assetReference: underlyingToken, + }) + const underlyingAsset = state.assets.byId[assetId] + if (!underlyingAsset) return undefined + + // This doesn't generalize, but this'll do, this is only a visual hack to display native asset instead of wrapped + // We could potentially use related assets for this and use primary implementation, though we'd have to remove BTC from there as WBTC and BTC are very + // much different assets on diff networks, i.e can't deposit BTC instead of WBTC automagically like you would with ETH instead of WETH + switch (underlyingAsset.symbol) { + case 'WETH': + return 'ETH' + case 'WBNB': + return 'BNB' + case 'WMATIC': + return 'MATIC' + case 'WAVAX': + return 'AVAX' + default: + return underlyingAsset.symbol + } + }) ?? [] + + // Our best effort to contruct sane name using the native asset -> asset naming hack failed, but thankfully, upstream name is very close e.g + // for "UniswapV2 LP TRUST/WETH", we just have to append "Pool" to that and we're gucci + if (assetSymbols.some(symbol => !symbol)) return `${token.name} Pool` + return `${platform.name} ${assetSymbols.join('/')} Pool` + })() + + const images = maybePortalsAsset.images ?? [] + const [, ...underlyingAssetsImages] = images + const iconOrIcons = (() => { + // There are no underlying tokens' images, return asset icon if it exists + if (!underlyingAssetsImages?.length) + return { icon: state.assets.byId[token.assetId]?.icon } + + if (underlyingAssetsImages.length === 1) { + return { + icon: maybeTokenImage( + maybePortalsAsset.image || underlyingAssetsImages[0], + ), + } + } + // This is a multiple assets pool, populate icons array + if (underlyingAssetsImages.length > 1) + return { + icons: underlyingAssetsImages.map((underlyingAssetsImage, i) => { + // No token at that index, but this isn't reliable as we've found out, it may be missing in tokens but present in images + // However, this has to be an early return and we can't use our own flavour of that asset... because we have no idea which asset it is. + if (!maybePortalsAsset.tokens[i]) + return maybeTokenImage(underlyingAssetsImage) + + const underlyingAssetId = toAssetId({ + chainId, + assetNamespace: + chainId === bscChainId + ? ASSET_NAMESPACE.bep20 + : ASSET_NAMESPACE.erc20, + assetReference: maybePortalsAsset.tokens[i], + }) + const underlyingAsset = state.assets.byId[underlyingAssetId] + // Prioritise our own flavour of icons for that asset if available, else use upstream if present + return underlyingAsset?.icon || maybeTokenImage(underlyingAssetsImage) + }), + icon: undefined, + } + })() + + // @ts-ignore this is the best we can do, some icons *may* be missing + minimalAsset = { + ...minimalAsset, + name, + isPool, + ...iconOrIcons, + } + } + prev.byId[token.assetId] = makeAsset(state.assets.byId, minimalAsset) prev.ids.push(token.assetId) return prev }, diff --git a/src/state/slices/portfolioSlice/utils.test.ts b/src/state/slices/portfolioSlice/utils/index.test.ts similarity index 98% rename from src/state/slices/portfolioSlice/utils.test.ts rename to src/state/slices/portfolioSlice/utils/index.test.ts index b77f6d90e8d..1f654ddb9ac 100644 --- a/src/state/slices/portfolioSlice/utils.test.ts +++ b/src/state/slices/portfolioSlice/utils/index.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it, vi } from 'vitest' import { trimWithEndEllipsis } from 'lib/utils' import { accountIdToFeeAssetId } from 'lib/utils/accounts' -import { accountIdToLabel, findAccountsByAssetId } from './utils' +import { accountIdToLabel, findAccountsByAssetId } from '.' vi.mock('context/PluginProvider/chainAdapterSingleton', () => ({ getChainAdapterManager: () => mockChainAdapters, diff --git a/src/state/slices/portfolioSlice/utils.ts b/src/state/slices/portfolioSlice/utils/index.ts similarity index 99% rename from src/state/slices/portfolioSlice/utils.ts rename to src/state/slices/portfolioSlice/utils/index.ts index f8cd68cc689..46acaaa1fd7 100644 --- a/src/state/slices/portfolioSlice/utils.ts +++ b/src/state/slices/portfolioSlice/utils/index.ts @@ -53,8 +53,8 @@ import type { Portfolio, PortfolioAccountBalancesById, PortfolioAccounts as PortfolioSliceAccounts, -} from './portfolioSliceCommon' -import { initialState } from './portfolioSliceCommon' +} from '../portfolioSliceCommon' +import { initialState } from '../portfolioSliceCommon' // note - this isn't a selector, just a pure utility function export const accountIdToLabel = (accountId: AccountId): string => {