Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: augment runtime LP tokens metadata from Portals #7675

Merged
merged 17 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './baseUnits/baseUnits'
export * from './promises'
export * from './treasury'
export * from './timeout'
export * from './createThrottle'

export const isSome = <T>(option: T | null | undefined): option is T =>
!isUndefined(option) && !isNull(option)
2 changes: 1 addition & 1 deletion src/lib/market-service/portals/portals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
5 changes: 5 additions & 0 deletions src/lib/market-service/portals/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export type PlatformsById = Record<string, Platform>

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
Expand Down
2 changes: 1 addition & 1 deletion src/lib/market-service/zerion/zerion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions src/lib/portals/utils.ts
Original file line number Diff line number Diff line change
@@ -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<PlatformsById> => {
const url = `${getConfig().REACT_APP_PORTALS_BASE_URL}/v2/platforms`

try {
const { data: platforms } = await axios.get<GetPlatformsResponse>(url, {
headers: {
Authorization: `Bearer ${getConfig().REACT_APP_PORTALS_API_KEY}`,
},
})

const byId = platforms.reduce<PlatformsById>((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<Record<AssetId, TokenInfo>> => {
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<GetBalancesResponse>(url, {
params: {
networks: [network],
owner,
},
headers: {
Authorization: `Bearer ${getConfig().REACT_APP_PORTALS_API_KEY}`,
},
})

return data.balances.reduce<Record<AssetId, TokenInfo>>((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
}
1 change: 1 addition & 0 deletions src/state/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,5 @@ export const migrations = {
104: clearTxHistory,
105: clearPortfolio,
106: clearOpportunities,
107: clearAssets,
}
106 changes: 103 additions & 3 deletions src/state/slices/portfolioSlice/portfolioSlice.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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<Portfolio> => {
// 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<EvmChainId>

const assets = (account.chainSpecific.tokens ?? []).reduce<UpsertAssetsPayload>(
Expand All @@ -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) {
kaladinlight marked this conversation as resolved.
Show resolved Hide resolved
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 "<platform> <assets> 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
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
kaladinlight marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Loading