diff --git a/src/components/tokensOverlap/TokensOverlap.tsx b/src/components/tokensOverlap/TokensOverlap.tsx index 39c5b2135..1544e64d9 100644 --- a/src/components/tokensOverlap/TokensOverlap.tsx +++ b/src/components/tokensOverlap/TokensOverlap.tsx @@ -1,4 +1,4 @@ -import { Token } from 'services/observables/tokens'; +import { Token, TokenMinimal } from 'services/observables/tokens'; import { Reserve } from 'services/observables/pools'; import { Image } from 'components/image/Image'; @@ -6,7 +6,7 @@ export const TokensOverlap = ({ tokens, maxLogos = 4, }: { - tokens: Token[] | Reserve[]; + tokens: Token[] | Reserve[] | TokenMinimal[]; maxLogos?: number; }) => { const tokenCount = tokens.length; diff --git a/src/elements/earn/portfolio/v3/V3Portfolio.tsx b/src/elements/earn/portfolio/v3/V3Portfolio.tsx index dda9c3845..6a6191888 100644 --- a/src/elements/earn/portfolio/v3/V3Portfolio.tsx +++ b/src/elements/earn/portfolio/v3/V3Portfolio.tsx @@ -11,6 +11,7 @@ import { ReactComponent as HoldingsLight } from 'assets/holdingsLight.svg'; import { ReactComponent as HoldingsDark } from 'assets/holdingsDark.svg'; import { V3Holdings } from 'elements/earn/portfolio/v3/holdings/V3Holdings'; import { useWalletConnect } from 'elements/walletConnect/useWalletConnect'; +import V3ExternalHoldings from 'elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldings'; const V3Portfolio = () => { const account = useAppSelector((state) => state.user.account); @@ -55,6 +56,13 @@ const V3Portfolio = () => { + +
+

+ External Holdings +

+ +
) : ( diff --git a/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldings.tsx b/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldings.tsx index 38998854e..597c93303 100644 --- a/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldings.tsx +++ b/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldings.tsx @@ -1,9 +1,10 @@ -import { memo, useState } from 'react'; +import { memo } from 'react'; import V3ExternalHoldingsItem from 'elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsItem'; import { Navigation } from 'swiper'; import { Swiper, SwiperSlide } from 'swiper/react/swiper-react'; import { NavigationOptions } from 'swiper/types'; import { useExternalHoldings } from 'elements/earn/portfolio/v3/externalHoldings/useExternalHoldings'; +import { ReactComponent as IconArrow } from 'assets/icons/arrow.svg'; const navOptions: NavigationOptions = { nextEl: '.external-holding-swiper-next-btn', @@ -13,13 +14,15 @@ const navOptions: NavigationOptions = { }; const V3ExternalHoldings = () => { - const [activeIndex, setActiveIndex] = useState(1); const { positions } = useExternalHoldings(); return positions.length ? (
-

External Holdings at risk

-

+

+ External Holdings at risk{' '} + ({positions.length}) +

+

Your holdings on other platforms are vulnerable to impermanent loss

{ spaceBetween={20} slidesPerView={1} grabCursor - onActiveIndexChange={({ activeIndex }) => - setActiveIndex(activeIndex + 1) - } navigation={navOptions} > {positions.map((pos, i) => ( @@ -39,17 +39,16 @@ const V3ExternalHoldings = () => { ))} -
- - -
- {activeIndex} of {positions.length} + {positions.length > 1 && ( +
+ +
-
+ )}
) : (
diff --git a/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsItem.tsx b/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsItem.tsx index a03df9200..11e03ac06 100644 --- a/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsItem.tsx +++ b/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsItem.tsx @@ -4,6 +4,8 @@ import { Button, ButtonSize, ButtonVariant } from 'components/button/Button'; import { ExternalHolding } from 'elements/earn/portfolio/v3/externalHoldings/externalHoldings.types'; import { V3ExternalHoldingsModal } from 'elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsModal'; import { TokensOverlap } from 'components/tokensOverlap/TokensOverlap'; +import { useAppSelector } from 'store/index'; +import { utils } from 'ethers'; interface Props { position: ExternalHolding; @@ -11,11 +13,29 @@ interface Props { const V3ExternalHoldingsItem = ({ position }: Props) => { const [isOpen, setIsOpen] = useState(false); + const allTokenListTokens = useAppSelector( + (state) => state.bancor.allTokenListTokens + ); + + const nonBancorToken = + position.nonBancorToken !== undefined + ? allTokenListTokens.find( + (t) => + utils.getAddress(t.address) === + utils.getAddress(position.nonBancorToken?.tokenAddress ?? '') + ) + : undefined; return ( -
+
- +
{position.ammName}:
@@ -23,7 +43,10 @@ const V3ExternalHoldingsItem = ({ position }: Props) => {
Rekt Status:
-
{position.rektStatus}
+
+ {position.rektStatus !== 'At risk' && '-'} + {position.rektStatus} +
); diff --git a/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsModal.tsx b/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsModal.tsx index b68c9f8ba..f220bc3a3 100644 --- a/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsModal.tsx +++ b/src/elements/earn/portfolio/v3/externalHoldings/V3ExternalHoldingsModal.tsx @@ -11,48 +11,46 @@ import { useApproveModal } from 'hooks/useApproveModal'; import { mockToken } from 'utils/mocked'; import { getMigrateFnByAmmProvider } from 'elements/earn/portfolio/v3/externalHoldings/externalHoldings'; import { shrinkToken } from 'utils/formulas'; +import { ProtectedSettingsV3 } from 'components/protectedSettingsV3/ProtectedSettingsV3'; +import { prettifyNumber, toBigNumber } from 'utils/helperFunctions'; +import { TokenMinimal } from 'services/observables/tokens'; +import { Image } from 'components/image/Image'; +import { + confirmMigrateExtHoldingNotification, + failedNotification, + rejectNotification, +} from 'services/notifications/notifications'; +import { ErrorCode } from 'services/web3/types'; interface Props { position: ExternalHolding; isOpen: boolean; setIsOpen: (isOpen: boolean) => void; + nonBancorToken?: TokenMinimal; } export const V3ExternalHoldingsModal = ({ position, isOpen, setIsOpen, + nonBancorToken, }: Props) => { const [txBusy, setTxBusy] = useState(false); const account = useAppSelector((state) => state.user.account); const dispatch = useDispatch(); - const { withdrawalFee, lockDuration } = useAppSelector( - (state) => state.v3Portfolio.withdrawalSettings - ); - - const lockDurationInDays = useMemo( - () => lockDuration / 60 / 60 / 24, - [lockDuration] - ); - - const withdrawalFeeInPercent = useMemo( - () => (withdrawalFee * 100).toFixed(2), - [withdrawalFee] - ); - const tokensToApprove = useMemo( () => [ { token: { ...mockToken, address: position.poolTokenAddress, - symbol: 'lpTKN', + symbol: position.name, }, amount: shrinkToken(position.poolTokenBalanceWei, 18), }, ], - [position.poolTokenAddress, position.poolTokenBalanceWei] + [position.name, position.poolTokenAddress, position.poolTokenBalanceWei] ); const migrate = async () => { @@ -68,16 +66,23 @@ export const V3ExternalHoldingsModal = ({ } try { - const res = await migrateFn( + const tx = await migrateFn( position.tokens[0].address, - position.tokens[1].address, + position.tokens[1]?.address ?? position.nonBancorToken?.tokenAddress, position.poolTokenBalanceWei ); - await res.wait(); setIsOpen(false); + confirmMigrateExtHoldingNotification(dispatch, tx.hash, position.name); + await tx.wait(); await updatePortfolioData(dispatch); - } catch (e) { - console.error(e); + } catch (e: any) { + console.error('failed to migrate position', e); + if (e.code === ErrorCode.DeniedTx) { + rejectNotification(dispatch); + } else { + failedNotification(dispatch, 'Migration Failed'); + } + setIsOpen(false); } finally { setTxBusy(false); } @@ -96,19 +101,21 @@ export const V3ExternalHoldingsModal = ({ return ( -
-

- Protect this {position.ammName} holding from impermanent loss -

- -

- {position.rektStatus === 'At risk' - ? 'Your position is at risk of impermanent loss' - : `You’ve lost ${position.rektStatus} in impermanent loss so far`} - , get 100% protected on Bancor. -

- -

Moving to Bancor

+
+
+

+ Protect this {position.ammName} holding from impermanent loss +

+ +

+ {position.rektStatus === 'At risk' + ? 'Your position is at risk of impermanent loss' + : `You’ve lost ${position.rektStatus} in impermanent loss so far`} + , get 100% protected on Bancor. +

+ +

Moving to Bancor

+
{position.tokens.map((t) => ( @@ -122,20 +129,46 @@ export const V3ExternalHoldingsModal = ({
))}
+ {!!position.nonBancorToken && ( +
+

Exit risky position

+
+
+ {'Token + {prettifyNumber(position.nonBancorToken.tokenCurrentBalance)}{' '} + {position.nonBancorToken.tokenName} +
+
HODL in your wallet
+
+
+ {prettifyNumber( + toBigNumber(position.nonBancorToken.tokenCurrentPrice).times( + position.nonBancorToken.tokenCurrentBalance + ), + true + )} +
+
+ )} + +
+ Amounts might vary on execution +
-

- 100% Protected • {lockDurationInDays} day cooldown •{' '} - {withdrawalFeeInPercent}% withdrawal fee -

+ {ApproveModal}
diff --git a/src/elements/earn/portfolio/v3/externalHoldings/externalHoldings.ts b/src/elements/earn/portfolio/v3/externalHoldings/externalHoldings.ts index 85696e397..a8813a455 100644 --- a/src/elements/earn/portfolio/v3/externalHoldings/externalHoldings.ts +++ b/src/elements/earn/portfolio/v3/externalHoldings/externalHoldings.ts @@ -5,6 +5,7 @@ import { prettifyNumber } from 'utils/helperFunctions'; import { ApyVisionData, ApyVisionNonUniPosition, + ApyVisionNonUniPositionToken, ApyVisionNonUniResponse, ApyVisionUniPosition, ExternalHolding, @@ -33,7 +34,7 @@ const fetchApyVisionUniswap = async ( const fetchApyVisionNonUniswap = async ( user: string ): Promise => { - const url = `https://api.apy.vision/portfolio/1/core/${user}?accessToken=${process.env.REACT_APP_APY_VISION_TOKEN}`; + const url = `https://api.apy.vision/portfolio/1/core/${user}?accessToken=${process.env.REACT_APP_APY_VISION_TOKEN}&isInWallet=true`; try { const { data } = await axios.get(url); return data.userPools; @@ -115,6 +116,7 @@ export const getExternalHoldingsUni = ( // TODO add poolTokenAddress poolTokenAddress: '', poolTokenBalanceWei: '', + name: '', }; return externalHolding; }) @@ -130,12 +132,15 @@ export const getExternalHoldingsNonUni = ( // TODO Remove this filter once we support more than 2 reseves .filter((pos) => pos.tokens.length === 2) .map((pos) => { + let nonBancorToken: ApyVisionNonUniPositionToken | undefined = + undefined; const tokens = pos.tokens .map((token) => { const address = utils.getAddress(token.tokenAddress); const isETH = address === utils.getAddress(wethToken); const tkn = tokensMap.get(isETH ? ethToken : address); if (!tkn) { + nonBancorToken = token; return undefined; } if (isETH) { @@ -152,8 +157,7 @@ export const getExternalHoldingsNonUni = ( }) .filter((t) => !!t) as Token[]; - // TODO once we support pools with more than 2 reserve tokens we need to update this - if (tokens.length !== 2) { + if (tokens.length === 0) { return undefined; } @@ -172,10 +176,12 @@ export const getExternalHoldingsNonUni = ( ammKey: pos.poolProviderKey, ammName, tokens, + nonBancorToken, rektStatus, usdValue, poolTokenAddress: pos.address, poolTokenBalanceWei, + name: pos.name, }; return newPos; }) diff --git a/src/elements/earn/portfolio/v3/externalHoldings/externalHoldings.types.ts b/src/elements/earn/portfolio/v3/externalHoldings/externalHoldings.types.ts index 68c172e79..f32f969b5 100644 --- a/src/elements/earn/portfolio/v3/externalHoldings/externalHoldings.types.ts +++ b/src/elements/earn/portfolio/v3/externalHoldings/externalHoldings.types.ts @@ -54,6 +54,17 @@ export interface ApyVisionUniPosition { }; } +export interface ApyVisionNonUniPositionToken { + tokenAddress: string; + tokenName: string; + tokenStartingBalance: number; + tokenCurrentBalance: number; + tokenCurrentPrice: number; + tokenUsdGain: number; + weight: number; + averageWeightedExecutedPrice: number; +} + export interface ApyVisionNonUniPosition { poolProviderKey: AMMProvider; networkId: number; @@ -71,16 +82,7 @@ export interface ApyVisionNonUniPosition { initialCapitalValueUsd: number; totalFeeUsd: number; hasPartialSessions: boolean; - tokens: { - tokenAddress: string; - tokenName: string; - tokenStartingBalance: number; - tokenCurrentBalance: number; - tokenCurrentPrice: number; - tokenUsdGain: number; - weight: number; - averageWeightedExecutedPrice: number; - }[]; + tokens: ApyVisionNonUniPositionToken[]; netGainUsd: number; netGainPct: number; } @@ -114,10 +116,12 @@ export interface ExternalHolding { ammKey: AMMProvider; ammName: string; tokens: Token[]; + nonBancorToken?: ApyVisionNonUniPositionToken; usdValue: number; rektStatus: string; poolTokenAddress: string; poolTokenBalanceWei: string; + name: string; } export interface ApyVisionData { diff --git a/src/elements/earn/portfolio/v3/externalHoldings/useExternalHoldings.ts b/src/elements/earn/portfolio/v3/externalHoldings/useExternalHoldings.ts index 515999eb9..d4d606d93 100644 --- a/src/elements/earn/portfolio/v3/externalHoldings/useExternalHoldings.ts +++ b/src/elements/earn/portfolio/v3/externalHoldings/useExternalHoldings.ts @@ -12,6 +12,7 @@ import { ExternalHolding, } from 'elements/earn/portfolio/v3/externalHoldings/externalHoldings.types'; import { getV3Tokens } from 'store/bancor/token'; +import { orderBy } from 'lodash'; const initialApyVisionData: ApyVisionData = { positionsUni: [], @@ -40,7 +41,7 @@ export const useExternalHoldings = () => { ); const positions: ExternalHolding[] = useMemo( - () => [...positionsUni, ...positionsNonUni], + () => orderBy([...positionsUni, ...positionsNonUni], 'usdValue', 'desc'), [positionsUni, positionsNonUni] ); diff --git a/src/services/notifications/notifications.ts b/src/services/notifications/notifications.ts index 1bf0552b6..784cda053 100644 --- a/src/services/notifications/notifications.ts +++ b/src/services/notifications/notifications.ts @@ -661,3 +661,38 @@ export const rewardsClaimedNotification = ( }, dispatch ); + +export const confirmMigrateExtHoldingNotification = ( + dispatch: any, + txHash: string, + name: string +) => + showNotification( + { + type: NotificationType.pending, + title: 'Pending Confirmation', + msg: `${name} migration is pending confirmation`, + txHash, + updatedInfo: { + successTitle: 'Success', + successMsg: `Your ${name} migration has been successfully completed.`, + errorTitle: 'Transaction Failed', + errorMsg: `Your ${name} migration has failed.`, + }, + }, + dispatch + ); + +export const failedNotification = ( + dispatch: any, + title = 'Unknown Error', + msg = `Something went wrong. Please try again or contact support.` +) => + showNotification( + { + type: NotificationType.error, + title, + msg, + }, + dispatch + );