From bedd0c75752fdefa7b37ffb59ce32786e067b363 Mon Sep 17 00:00:00 2001 From: jinoosss <jwchoi@onbloc.xyz> Date: Wed, 13 Dec 2023 21:57:44 +0900 Subject: [PATCH] feat: [GSW-468] Implements Remove Position --- .../my-liquidity-header/MyLiquidityHeader.tsx | 7 +- .../pool/my-liquidity/MyLiquidity.tsx | 5 +- .../RemoveLiquiditySelectListItem.tsx | 126 +++++++++--------- .../RemoveLiquiditySelectList.tsx | 50 +++---- .../RemoveLiquiditySelectResult.tsx | 108 +++------------ .../remove-liquidity/RemoveLiquidity.tsx | 61 ++++----- .../RemovePositionModal.styles.ts | 10 +- .../RemovePositionModal.tsx | 118 +++++++--------- .../SelectLiquidityItem.tsx | 4 +- .../MyLiquidityContainer.tsx | 26 ++-- .../RemoveLiquidityContainer.tsx | 88 +++++++----- .../RemovePositionModalContainer.tsx | 35 ++++- .../hooks/earn/use-remove-position-modal.tsx | 16 ++- .../web/src/hooks/stake/use-remove-data.ts | 98 ++++++++++++++ .../position/position-repository-impl.ts | 39 +++--- .../position/position-repository.ts | 6 +- .../request/decrease-liquidity-request.ts | 2 +- .../request/remove-liquidity-request.ts | 5 + 18 files changed, 438 insertions(+), 366 deletions(-) create mode 100644 packages/web/src/hooks/stake/use-remove-data.ts create mode 100644 packages/web/src/repositories/position/request/remove-liquidity-request.ts diff --git a/packages/web/src/components/pool/my-liquidity-header/MyLiquidityHeader.tsx b/packages/web/src/components/pool/my-liquidity-header/MyLiquidityHeader.tsx index 63ebf7724..869b4935b 100644 --- a/packages/web/src/components/pool/my-liquidity-header/MyLiquidityHeader.tsx +++ b/packages/web/src/components/pool/my-liquidity-header/MyLiquidityHeader.tsx @@ -5,19 +5,18 @@ import React from "react"; import { HeaderWrapper } from "./MyLiquidityHeader.styles"; interface MyLiquidityHeaderProps { - connected: boolean; - isSwitchNetwork: boolean; + availableRemovePosition: boolean; handleClickAddPosition: () => void; handleClickRemovePosition: () => void; } -const MyLiquidityHeader: React.FC<MyLiquidityHeaderProps> = ({ connected, isSwitchNetwork, handleClickAddPosition, handleClickRemovePosition }) => { +const MyLiquidityHeader: React.FC<MyLiquidityHeaderProps> = ({ availableRemovePosition, handleClickAddPosition, handleClickRemovePosition }) => { return ( <HeaderWrapper> <h2>My Positions</h2> <div className="button-wrap"> <Button - disabled={!connected || isSwitchNetwork} + disabled={!availableRemovePosition} text="Remove Position" onClick={handleClickRemovePosition} style={{ diff --git a/packages/web/src/components/pool/my-liquidity/MyLiquidity.tsx b/packages/web/src/components/pool/my-liquidity/MyLiquidity.tsx index 047e204bd..4336d867c 100644 --- a/packages/web/src/components/pool/my-liquidity/MyLiquidity.tsx +++ b/packages/web/src/components/pool/my-liquidity/MyLiquidity.tsx @@ -17,6 +17,7 @@ interface MyLiquidityProps { onScroll: () => void; currentIndex: number; claimAll: () => void; + availableRemovePosition: boolean; } const MyLiquidity: React.FC<MyLiquidityProps> = ({ @@ -30,13 +31,13 @@ const MyLiquidity: React.FC<MyLiquidityProps> = ({ onScroll, currentIndex, claimAll, + availableRemovePosition, }) => { return ( <MyLiquidityWrapper> <div className="liquidity-wrap"> <MyLiquidityHeader - connected={connected} - isSwitchNetwork={isSwitchNetwork} + availableRemovePosition={availableRemovePosition} handleClickAddPosition={handleClickAddPosition} handleClickRemovePosition={handleClickRemovePosition} /> diff --git a/packages/web/src/components/remove/remove-liquidity-select-list-item/RemoveLiquiditySelectListItem.tsx b/packages/web/src/components/remove/remove-liquidity-select-list-item/RemoveLiquiditySelectListItem.tsx index 27768afc8..e7c8ff02a 100644 --- a/packages/web/src/components/remove/remove-liquidity-select-list-item/RemoveLiquiditySelectListItem.tsx +++ b/packages/web/src/components/remove/remove-liquidity-select-list-item/RemoveLiquiditySelectListItem.tsx @@ -1,107 +1,107 @@ import DoubleLogo from "@components/common/double-logo/DoubleLogo"; import Tooltip from "@components/common/tooltip/Tooltip"; -import React, { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import React, { useMemo } from "react"; import { RemoveLiquiditySelectListItemWrapper, TooltipWrapperContent } from "./RemoveLiquiditySelectListItem.styles"; import Badge, { BADGE_TYPE } from "@components/common/badge/Badge"; -import { convertLiquidity } from "@utils/stake-position-utils"; import { PoolPositionModel } from "@models/position/pool-position-model"; +import { tooltipWrapper } from "@components/stake/select-lilquidity-list-item/SelectLiquidityListItem.styles"; +import { makeDisplayTokenAmount } from "@utils/token-utils"; +import { numberToUSD } from "@utils/number-utils"; +import { SwapFeeTierInfoMap } from "@constants/option.constant"; +import { makeSwapFeeTier } from "@utils/swap-utils"; interface RemoveLiquiditySelectListItemProps { - selected: boolean; position: PoolPositionModel; - select: (id: string) => void; - width: number; + checkedList: string[]; + onCheckedItem: (checked: boolean, path: string) => void; + disabled?: boolean; } interface TooltipProps { - selectable: boolean; + position: PoolPositionModel; + disabled: boolean; } -const TooltipContent: React.FC<TooltipProps> = ({ selectable }) => { +const TooltipContent: React.FC<TooltipProps> = ({ position, disabled }) => { return ( <TooltipWrapperContent> - <div> - <div className="title">Token ID</div> - <div className="title">#982932</div> - </div> - <div> - <div className="value"> - <img src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39/logo.png" /> - GNS + <div css={tooltipWrapper()}> + <div> + <div className="title">Token ID</div> + <div className="title">#{position.id}</div> </div> - <div className="value">50.05881</div> - </div> - <div> - <div className="value"> - <img src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39/logo.png" /> - GNS + <div> + <div className="value"> + <img src={position.pool.tokenA.logoURI} alt="token logo" /> + {position.pool.tokenA.symbol} + </div> + <div className="value">{makeDisplayTokenAmount(position.pool.tokenA, position.token0Balance)}</div> + </div> + <div> + <div className="value"> + <img src={position.pool.tokenB.logoURI} alt="token logo" /> + {position.pool.tokenB.symbol} + </div> + <div className="value">{makeDisplayTokenAmount(position.pool.tokenB, position.token1Balance)}</div> </div> - <div className="value">50.05881</div> </div> - {selectable && <div className="divider"></div>} - {selectable && <div className="unstake-description"> - *You need to unstake your position first. - </div>} + {!disabled && <div className="divider"></div>} + {!disabled && ( + <div className="unstake-description"> + *You need to unstake your position first. + </div> + )} </TooltipWrapperContent> ); }; const RemoveLiquiditySelectListItem: React.FC<RemoveLiquiditySelectListItemProps> = ({ - selected, position, - select, - width, + checkedList, + onCheckedItem, + disabled = false, }) => { - const [checkWidth, setIsCheckWidth] = useState(true); - const leftDivRef = useRef<HTMLDivElement>(null); - const liquidityRef = useRef<HTMLDivElement>(null); + const checked = useMemo(() => { + return checkedList.includes(position.id); + }, [checkedList, position.id]); - const selectable = useMemo(() => { - return position.unclaimedFee0Amount + position.unclaimedFee1Amount > 0; - }, [position]); + const tokenA = useMemo(() => { + return position.pool.tokenA; + }, [position.pool.tokenA]); - const doubleLogo = useMemo(() => { - const { tokenA, tokenB } = position.pool; - return { - left: tokenA.logoURI, - right: tokenB.logoURI, - }; - }, [position]); + const tokenB = useMemo(() => { + return position.pool.tokenB; + }, [position.pool.tokenB]); - const onChangeCheckbox = useCallback(() => { - select(position.id); - }, [position.id, select]); + const liquidityUSD = useMemo(() => { + return numberToUSD(Number(position.positionUsdValue)); + }, [position.positionUsdValue]); - useEffect(() => { - if (typeof window !== "undefined") { - const windowWidth = Math.min(width, 500); - const totalWidth = (leftDivRef?.current?.offsetWidth || 0) + (liquidityRef?.current?.offsetWidth || 0) + 100; - setIsCheckWidth(windowWidth > totalWidth); - } - }, [liquidityRef.current, leftDivRef.current, width]); + const feeStr = useMemo(() => { + return SwapFeeTierInfoMap[makeSwapFeeTier(position.pool.fee)].rateStr; + }, [position]); return ( - <RemoveLiquiditySelectListItemWrapper selected={selected}> - <div className="left-content" ref={leftDivRef}> + <RemoveLiquiditySelectListItemWrapper selected={checked}> + <div className="left-content" > <input id={`checkbox-item-${position.id}`} type="checkbox" - disabled={!selectable} - checked={selected} - onChange={onChangeCheckbox} + disabled={disabled} + checked={checked} + onChange={e => onCheckedItem(e.target.checked, position.id)} /> <label htmlFor={`checkbox-item-${position.id}`} /> - <DoubleLogo {...doubleLogo} size={24} /> + <DoubleLogo left={tokenA.logoURI} right={tokenB.logoURI} size={24} /> <Tooltip placement="top" - FloatingContent={<TooltipContent selectable={!selectable} />} + FloatingContent={<TooltipContent position={position} disabled={disabled} />} > - <span className="token-id">GNS/GNOT</span> + <span className="token-id">{`${tokenA.symbol}/${tokenB.symbol}`}</span> </Tooltip> - <Badge text="0.3%" type={BADGE_TYPE.DARK_DEFAULT} /> + <Badge text={feeStr} type={BADGE_TYPE.DARK_DEFAULT} /> </div> - <span className="liquidity-value-fake" ref={liquidityRef}>${position.liquidity.toLocaleString()}</span> - <span className="liquidity-value" >${!checkWidth ? convertLiquidity(position.liquidity.toString()) : position.liquidity.toLocaleString()}</span> + <span className="liquidity-value" >{liquidityUSD}</span> </RemoveLiquiditySelectListItemWrapper> ); }; diff --git a/packages/web/src/components/remove/remove-liquidity-select-list/RemoveLiquiditySelectList.tsx b/packages/web/src/components/remove/remove-liquidity-select-list/RemoveLiquiditySelectList.tsx index d3d46dcbb..896d2b4db 100644 --- a/packages/web/src/components/remove/remove-liquidity-select-list/RemoveLiquiditySelectList.tsx +++ b/packages/web/src/components/remove/remove-liquidity-select-list/RemoveLiquiditySelectList.tsx @@ -1,30 +1,26 @@ -import React, { useCallback } from "react"; +import React from "react"; import { RemoveLiquiditySelectListWrapper } from "./RemoveLiquiditySelectList.styles"; import RemoveLiquiditySelectListItem from "../remove-liquidity-select-list-item/RemoveLiquiditySelectListItem"; import { PoolPositionModel } from "@models/position/pool-position-model"; interface RemoveLiquiditySelectListProps { - selectedAll: boolean; - positions: PoolPositionModel[]; - selectedIds: string[]; - select: (id: string) => void; - selectAll: () => void; - width: number; + stakedPositions: PoolPositionModel[]; + unstakedPositions: PoolPositionModel[]; + checkedList: string[]; + onCheckedItem: (checked: boolean, path: string) => void; + onCheckedAll: (checked: boolean) => void; + checkedAll: boolean; } const RemoveLiquiditySelectList: React.FC<RemoveLiquiditySelectListProps> = ({ - selectedAll, - positions, - selectedIds, - select, - selectAll, - width, + stakedPositions, + unstakedPositions, + checkedList, + onCheckedItem, + onCheckedAll, + checkedAll, }) => { - const isSelectLiquidity = useCallback((position: PoolPositionModel) => { - return selectedIds.findIndex(id => id === position.id) > -1; - }, [selectedIds]); - return ( <RemoveLiquiditySelectListWrapper> <div className="checked-all-wrap"> @@ -32,8 +28,8 @@ const RemoveLiquiditySelectList: React.FC<RemoveLiquiditySelectListProps> = ({ <input id="checkbox-all" type="checkbox" - checked={selectedAll} - onChange={selectAll} + checked={checkedAll} + onChange={e => onCheckedAll(e.target.checked)} /> <label htmlFor="checkbox-all" /> <span className="custom-label">Select All</span> @@ -41,13 +37,21 @@ const RemoveLiquiditySelectList: React.FC<RemoveLiquiditySelectListProps> = ({ <span>Liquidity</span> </div> <ul> - {positions.map((position, index) => ( + {unstakedPositions.map((position, index) => ( <RemoveLiquiditySelectListItem + position={position} + checkedList={checkedList} + onCheckedItem={onCheckedItem} key={index} + /> + ))} + {stakedPositions.map((position, index) => ( + <RemoveLiquiditySelectListItem position={position} - selected={isSelectLiquidity(position)} - select={select} - width={width} + checkedList={checkedList} + onCheckedItem={onCheckedItem} + key={index} + disabled /> ))} </ul> diff --git a/packages/web/src/components/remove/remove-liquidity-select-result/RemoveLiquiditySelectResult.tsx b/packages/web/src/components/remove/remove-liquidity-select-result/RemoveLiquiditySelectResult.tsx index 6217a3d04..bbe8ad3c7 100644 --- a/packages/web/src/components/remove/remove-liquidity-select-result/RemoveLiquiditySelectResult.tsx +++ b/packages/web/src/components/remove/remove-liquidity-select-result/RemoveLiquiditySelectResult.tsx @@ -1,116 +1,48 @@ -import React, { useMemo } from "react"; +import React from "react"; import { RemoveLiquiditySelectResultWrapper } from "./RemoveLiquiditySelectResult.styles"; -import BigNumber from "bignumber.js"; -import { TokenPairAmountInfo } from "@models/token/token-pair-amount-info"; -import { PositionMapper } from "@models/position/mapper/position-mapper"; import { PoolPositionModel } from "@models/position/pool-position-model"; +import { useRemoveData } from "@hooks/stake/use-remove-data"; interface RemoveLiquiditySelectResultProps { - selectedLiquidities: PoolPositionModel[]; -} - -function mappedTokenPairAmountMap(tokenPairAmounts: TokenPairAmountInfo[]) { - const initTokenMap: { [key in string]: { - symbol: string; - amount: string; - price: string; - logoURI: string; - } } = {}; - const tokenPairMap = tokenPairAmounts.reduce((acc, current) => { - const tokenA = current.tokenA; - const tokenB = current.tokenB; - const tokenAAmount = current.tokenAAmount; - const tokenBAmount = current.tokenBAmount; - if (!acc[tokenA.path]) { - acc[tokenA.path] = { - symbol: tokenA.symbol, - logoURI: tokenA.logoURI, - amount: "0", - price: "0", - }; - } - if (!acc[tokenB.path]) { - acc[tokenB.path] = { - symbol: tokenB.symbol, - logoURI: tokenB.logoURI, - amount: "0", - price: "0", - }; - } - const token0Amount = BigNumber(acc[tokenA.path].amount).plus(tokenAAmount.amount || "0"); - acc[tokenA.path] = { - ...acc[tokenA.path], - amount: token0Amount.toString(), - }; - const token1Amount = BigNumber(acc[tokenB.path].amount).plus(tokenBAmount.amount || "0"); - acc[tokenB.path] = { - ...acc[tokenB.path], - amount: token1Amount.toString(), - }; - return acc; - }, initTokenMap); - return tokenPairMap; + positions: PoolPositionModel[]; } const RemoveLiquiditySelectResult: React.FC< RemoveLiquiditySelectResultProps > = ({ - selectedLiquidities + positions }) => { - const pooledTokenMap = useMemo(() => { - const tokenPairAmounts = selectedLiquidities.map(position => PositionMapper.toTokenPairAmount(position)); - return mappedTokenPairAmountMap(tokenPairAmounts); - }, [selectedLiquidities]); - - const unclaimedTokenMap = useMemo(() => { - const tokenPairAmounts = selectedLiquidities.map(position => PositionMapper.toTokenPairAmount(position)); - return mappedTokenPairAmountMap(tokenPairAmounts); - }, [selectedLiquidities]); + const { pooledTokenInfos, unclaimedRewards, totalLiquidityUSD } = useRemoveData({ positions }); - const totalAmount = useMemo(() => { - const pooledAmount = Object.values(pooledTokenMap) - .map(token => token.amount) - .reduce((acc, current) => - BigNumber(acc).plus(current).toNumber(), 0); - const unclaimedAmount = Object.values(unclaimedTokenMap) - .map(token => token.amount) - .reduce((acc, current) => - BigNumber(acc).plus(current).toNumber(), 0); - return BigNumber(pooledAmount + unclaimedAmount).toFormat(); - }, [pooledTokenMap, unclaimedTokenMap]); - - if (selectedLiquidities.length === 0) { - return <></>; - } + if (positions.length === 0) return <></>; return ( <RemoveLiquiditySelectResultWrapper> - <ul> - {Object.keys(pooledTokenMap).map((path, index) => ( - <li key={index} className="pooled-tokenA"> + <ul className="pooled-section"> + {pooledTokenInfos.map((pooledTokenInfo, index) => ( + <li key={index}> <div className="main-info"> - <img src={pooledTokenMap[path].logoURI} alt="pooled tokenA logo" /> - <p>{`Pooled ${pooledTokenMap[path].symbol}`}</p> - <strong>{Number(pooledTokenMap[path].amount).toFixed(2)}</strong> + <img src={pooledTokenInfo.token.logoURI} alt="pooled token logo" /> + <p>Pooled {pooledTokenInfo.token.symbol}</p> + <strong>{pooledTokenInfo.amount}</strong> </div> - <span className="dallor">{`$${Number(pooledTokenMap[path].amount).toFixed(2)}`}</span> + <span className="dallor">{pooledTokenInfo.amountUSD}</span> </li> ))} - - {Object.keys(unclaimedTokenMap).map((path, index) => ( - <li key={index} className="pooled-tokenA"> + {unclaimedRewards.map((pooledTokenInfo, index) => ( + <li key={index}> <div className="main-info"> - <img src={unclaimedTokenMap[path].logoURI} alt="pooled tokenA logo" /> - <p>{`Unclaimed ${unclaimedTokenMap[path].symbol} Fees`}</p> - <strong>{Number(unclaimedTokenMap[path].amount).toFixed(2)}</strong> + <img src={pooledTokenInfo.token.logoURI} alt="pooled token logo" /> + <p>Unclaimed {pooledTokenInfo.token.symbol}</p> + <strong>{pooledTokenInfo.amount}</strong> </div> - <span className="dallor">{`$${Number(unclaimedTokenMap[path].amount).toFixed(2)}`}</span> + <span className="dallor">{pooledTokenInfo.amountUSD}</span> </li> ))} </ul> <div className="total-section"> <h5>Total Amount</h5> - <span className="total-value">{`$${Number(totalAmount.replace(/,/g, "") || 0).toFixed(2)}`}</span> + <span className="total-value">{totalLiquidityUSD}</span> </div> </RemoveLiquiditySelectResultWrapper> ); diff --git a/packages/web/src/components/remove/remove-liquidity/RemoveLiquidity.tsx b/packages/web/src/components/remove/remove-liquidity/RemoveLiquidity.tsx index 652caefac..957b52a27 100644 --- a/packages/web/src/components/remove/remove-liquidity/RemoveLiquidity.tsx +++ b/packages/web/src/components/remove/remove-liquidity/RemoveLiquidity.tsx @@ -1,62 +1,59 @@ import Button, { ButtonHierarchy } from "@components/common/button/Button"; -import React, { useCallback, useMemo } from "react"; +import React, { useMemo } from "react"; import { wrapper } from "./RemoveLiquidity.styles"; import RemoveLiquiditySelectList from "../remove-liquidity-select-list/RemoveLiquiditySelectList"; import RemoveLiquiditySelectResult from "../remove-liquidity-select-result/RemoveLiquiditySelectResult"; import { PoolPositionModel } from "@models/position/pool-position-model"; interface RemoveLiquidityProps { - selectedAll: boolean; - positions: PoolPositionModel[]; - selectedIds: string[]; - select: (id: string) => void; - selectAll: () => void; + stakedPositions: PoolPositionModel[]; + unstakedPositions: PoolPositionModel[]; + checkedList: string[]; + onCheckedItem: (checked: boolean, path: string) => void; + onCheckedAll: (checked: boolean) => void; + checkedAll: boolean; removeLiquidity: () => void; - width: number; } const RemoveLiquidity: React.FC<RemoveLiquidityProps> = ({ - selectedAll, - positions, - selectedIds, - select, - selectAll, + stakedPositions, + unstakedPositions, + checkedList, + onCheckedItem, + onCheckedAll, + checkedAll, removeLiquidity, - width, }) => { - const selectedLiquidites = useMemo(() => { - return positions.filter(lpPosition => selectedIds.includes(lpPosition.id)); - }, [selectedIds, positions]); - const disabledConfirm = useMemo(() => { - return selectedLiquidites.length === 0; - }, [selectedLiquidites.length]); + const disabledRemoveLiquidity = useMemo(() => { + return checkedList.length === 0; + }, [checkedList.length]); - const onClickRemoveLiquidity = useCallback(() => { - removeLiquidity(); - }, [removeLiquidity]); + const selectedPositions = useMemo(() => { + return stakedPositions.filter(position => checkedList.includes(position.id)); + }, [checkedList, stakedPositions]); return ( <div css={wrapper}> <h3 className="title">Remove Position</h3> <RemoveLiquiditySelectList - positions={positions} - selectedIds={selectedIds} - selectedAll={selectedAll} - select={select} - selectAll={selectAll} - width={width} + stakedPositions={stakedPositions} + unstakedPositions={unstakedPositions} + checkedList={checkedList} + onCheckedItem={onCheckedItem} + onCheckedAll={onCheckedAll} + checkedAll={checkedAll} /> - <RemoveLiquiditySelectResult selectedLiquidities={selectedLiquidites} /> + <RemoveLiquiditySelectResult positions={selectedPositions} /> <Button - text={disabledConfirm ? "Select Position" : "Remove Position"} - disabled={disabledConfirm} + text={disabledRemoveLiquidity ? "Select Position" : "Remove Position"} + disabled={disabledRemoveLiquidity} style={{ hierarchy: ButtonHierarchy.Primary, fullWidth: true, }} className="button-submit" - onClick={onClickRemoveLiquidity} + onClick={removeLiquidity} /> </div> ); diff --git a/packages/web/src/components/remove/remove-position-modal/RemovePositionModal.styles.ts b/packages/web/src/components/remove/remove-position-modal/RemovePositionModal.styles.ts index 3916b565c..bb2efe4a8 100644 --- a/packages/web/src/components/remove/remove-position-modal/RemovePositionModal.styles.ts +++ b/packages/web/src/components/remove/remove-position-modal/RemovePositionModal.styles.ts @@ -51,7 +51,7 @@ export const RemovePositionModalWrapper = styled.div` .box-item { width: 100%; ${mixins.flexbox("column", "flex-start", "flex-start")}; - gap: 8px; + gap: 16px; h4 { ${fonts.body12} @@ -150,7 +150,7 @@ export const RemovePositionModalWrapper = styled.div` > div { width: 100%; .button-confirm { - gap: 8px; + gap: 16px; height: 57px; span { ${fonts.body7} @@ -183,8 +183,12 @@ export const RemovePositionModalWrapper = styled.div` } } } -`; + .button-wrapper { + display: flex; + width: 100%; + } +`; export const Divider = styled.div` width: 100%; diff --git a/packages/web/src/components/remove/remove-position-modal/RemovePositionModal.tsx b/packages/web/src/components/remove/remove-position-modal/RemovePositionModal.tsx index 93809c56a..2090a5e5f 100644 --- a/packages/web/src/components/remove/remove-position-modal/RemovePositionModal.tsx +++ b/packages/web/src/components/remove/remove-position-modal/RemovePositionModal.tsx @@ -2,15 +2,20 @@ import Badge, { BADGE_TYPE } from "@components/common/badge/Badge"; import Button, { ButtonHierarchy } from "@components/common/button/Button"; import DoubleLogo from "@components/common/double-logo/DoubleLogo"; import IconClose from "@components/common/icons/IconCancel"; +import { useRemoveData } from "@hooks/stake/use-remove-data"; +import { PoolPositionModel } from "@models/position/pool-position-model"; +import { numberToUSD } from "@utils/number-utils"; import React, { useCallback } from "react"; import { Divider, RemovePositionModalWrapper } from "./RemovePositionModal.styles"; interface Props { + positions: PoolPositionModel[]; close: () => void; onSubmit: () => void; } -const RemovePositionModal: React.FC<Props> = ({ close, onSubmit }) => { +const RemovePositionModal: React.FC<Props> = ({ positions, close, onSubmit }) => { + const { unclaimedRewards, totalLiquidityUSD } = useRemoveData({ positions }); const onClickClose = useCallback(() => { close(); }, [close]); @@ -28,84 +33,61 @@ const RemovePositionModal: React.FC<Props> = ({ close, onSubmit }) => { <div className="box-item"> <h4>Positions</h4> <div className="item-content"> - <div> - <div className="label-logo"> - <DoubleLogo - left="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39/logo.png" - right="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" - size={24} - /> - <div>GNS/GNOT</div> - <Badge className="position-bar" text="0.3%" type={BADGE_TYPE.DARK_DEFAULT} /> - </div> - <div className="value">$145,541.10</div> - </div> - <div> - <div className="label-logo"> - <DoubleLogo - left="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39/logo.png" - right="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" - size={24} - /> - <div>GNS/GNOT</div> - <Badge className="position-bar" text="0.3%" type={BADGE_TYPE.DARK_DEFAULT} /> - </div> - <div className="value">$145,541.10</div> - </div> - </div> - </div> - <div className="box-item box-item-unclaim"> - <h4>Unclaimed Fees</h4> - <div className="item-content"> - <div> - <div> + {positions.map((position, index) => ( + <div key={index}> <div className="label-logo"> - <img className="image-logo" src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" alt="logo" /> - <div>GNS</div> + <DoubleLogo + left={position.pool.tokenA.logoURI} + right={position.pool.tokenB.logoURI} + size={24} + /> + <div>{`${position.pool.tokenA.symbol}/${position.pool.tokenB.symbol}`}</div> + <Badge className="position-bar" text="0.3%" type={BADGE_TYPE.DARK_DEFAULT} /> </div> - <div className="value">15,000.005</div> - </div> - <div className="sub-value"> - $15,000.01 + <div className="value">{numberToUSD(Number(position.positionUsdValue))}</div> </div> + ))} + </div> + <div className="box-item box-item-unclaim"> + <h4>Unclaimed Fees</h4> + <div className="item-content"> + {unclaimedRewards.map((rewardInfo, index) => ( + <div key={index}> + <div> + <div className="label-logo"> + <img className="image-logo" src={rewardInfo.token.logoURI} alt="logo" /> + <div>{rewardInfo.token.symbol}</div> + </div> + <div className="value">{rewardInfo.amount}</div> + </div> + <div className="sub-value">{rewardInfo.amountUSD}</div> + </div> + ))} </div> - <div> + </div> + <Divider /> + <div className="box-item"> + <div className="item-content"> <div> - <div className="label-logo"> - <img className="image-logo" src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" alt="logo" /> - <div>GNS</div> + <div className="label-large"> + Total Amount </div> - <div className="value">15,000.005</div> - </div> - <div className="sub-value"> - $15,000.01 + <div className="value-large">{totalLiquidityUSD}</div> </div> </div> - </div> - </div> - <Divider /> - <div className="box-item"> - <div className="item-content"> - <div> - <div className="label-large"> - Total Amount - </div> - <div className="value-large">$291,082.2</div> - </div> + <div className="button-wrapper"> + <Button + text="Confirm Remove Position" + style={{ + hierarchy: ButtonHierarchy.Primary, + fullWidth: true, + }} + className="button-confirm" + onClick={onSubmit} + /> </div> </div> - <div> - <Button - text="Confirm Remove Position" - style={{ - hierarchy: ButtonHierarchy.Primary, - fullWidth: true, - }} - className="button-confirm" - onClick={onSubmit} - /> - </div> </div> </div> </RemovePositionModalWrapper> diff --git a/packages/web/src/components/unstake/select-liquidity-item/SelectLiquidityItem.tsx b/packages/web/src/components/unstake/select-liquidity-item/SelectLiquidityItem.tsx index 19dfca7bb..e09fe1c89 100644 --- a/packages/web/src/components/unstake/select-liquidity-item/SelectLiquidityItem.tsx +++ b/packages/web/src/components/unstake/select-liquidity-item/SelectLiquidityItem.tsx @@ -58,8 +58,8 @@ const SelectLiquidityItem: React.FC<SelectLiquidityItemProps> = ({ }, [position.pool.tokenB]); const liquidityUSD = useMemo(() => { - return numberToUSD(Number(position.liquidity)); - }, [position.liquidity]); + return numberToUSD(Number(position.positionUsdValue)); + }, [position.positionUsdValue]); return ( <li css={wrapper(checked)}> diff --git a/packages/web/src/containers/my-liquidity-container/MyLiquidityContainer.tsx b/packages/web/src/containers/my-liquidity-container/MyLiquidityContainer.tsx index 34749b9da..8450c5b1e 100644 --- a/packages/web/src/containers/my-liquidity-container/MyLiquidityContainer.tsx +++ b/packages/web/src/containers/my-liquidity-container/MyLiquidityContainer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import MyLiquidity from "@components/pool/my-liquidity/MyLiquidity"; import { useWindowSize } from "@hooks/common/use-window-size"; import { useWallet } from "@hooks/wallet/use-wallet"; @@ -19,15 +19,12 @@ const MyLiquidityContainer: React.FC = () => { const { getPositionsByPoolId } = usePositionData(); const { claimAll } = usePosition(positions); - useEffect(() => { - const poolPath = router.query["pool-path"] as string; - if (!poolPath) { - return; + const availableRemovePosition = useMemo(() => { + if (!connectedWallet || isSwitchNetwork) { + return false; } - if (account?.address) { - getPositionsByPoolId(poolPath).then(setPositions); - } - }, [account?.address, getPositionsByPoolId, router.query]); + return positions.length > 0; + }, [connectedWallet, isSwitchNetwork, positions.length]); const handleClickAddPosition = useCallback(() => { router.push(`${router.asPath}/add`); @@ -52,6 +49,16 @@ const MyLiquidityContainer: React.FC = () => { }); }, [claimAll, router]); + useEffect(() => { + const poolPath = router.query["pool-path"] as string; + if (!poolPath) { + return; + } + if (account?.address) { + getPositionsByPoolId(poolPath).then(setPositions); + } + }, [account?.address, getPositionsByPoolId, router.query]); + return ( <MyLiquidity positions={positions} @@ -64,6 +71,7 @@ const MyLiquidityContainer: React.FC = () => { onScroll={handleScroll} currentIndex={currentIndex} claimAll={claimAllReward} + availableRemovePosition={availableRemovePosition} /> ); }; diff --git a/packages/web/src/containers/remove-liquidity-container/RemoveLiquidityContainer.tsx b/packages/web/src/containers/remove-liquidity-container/RemoveLiquidityContainer.tsx index dfc14a9fe..82892a95b 100644 --- a/packages/web/src/containers/remove-liquidity-container/RemoveLiquidityContainer.tsx +++ b/packages/web/src/containers/remove-liquidity-container/RemoveLiquidityContainer.tsx @@ -1,59 +1,81 @@ import RemoveLiquidity from "@components/remove/remove-liquidity/RemoveLiquidity"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useRemovePositionModal } from "@hooks/earn/use-remove-position-modal"; -import { useWindowSize } from "@hooks/common/use-window-size"; import { PoolPositionModel } from "@models/position/pool-position-model"; import { usePositionData } from "@hooks/common/use-position-data"; +import { useRouter } from "next/router"; +import { useWallet } from "@hooks/wallet/use-wallet"; const RemoveLiquidityContainer: React.FC = () => { - const [selectedIds, setSelectedIds] = useState<string[]>([]); - const { width } = useWindowSize(); - const { openModal } = useRemovePositionModal(); + const router = useRouter(); + const { account } = useWallet(); const [positions, setPositions] = useState<PoolPositionModel[]>([]); - const { getPositions } = usePositionData(); + const [checkedList, setCheckedList] = useState<string[]>([]); + const { getPositionsByPoolId } = usePositionData(); + const { openModal } = useRemovePositionModal({ + positions, + selectedIds: checkedList, + }); - useEffect(() => { - getPositions().then(setPositions); - }, [getPositions]); - - const unstakedLiquidities = useMemo(() => { - return positions.filter(item => item.unclaimedFee0Amount + item.unclaimedFee1Amount > 0); + const stakedPositions = useMemo(() => { + return positions.filter(position => position.staked); }, [positions]); - const selectedAll = useMemo(() => { - return unstakedLiquidities.length === selectedIds.length; - }, [selectedIds.length, unstakedLiquidities.length]); + const unstakedPositions = useMemo(() => { + return positions.filter(position => !position.staked); + }, [positions]); - const selectAll = useCallback(() => { - if (selectedAll) { - setSelectedIds([]); - return; + const checkedAll = useMemo(() => { + if (unstakedPositions.length === 0) { + return false; } - const selectedIds = unstakedLiquidities.map(liquidity => liquidity.id); - setSelectedIds(selectedIds); - }, [selectedAll, unstakedLiquidities]); + return unstakedPositions.length === checkedList.length; + }, [unstakedPositions, checkedList]); + + const onCheckedItem = useCallback( + (isChecked: boolean, path: string) => { + if (isChecked) { + return setCheckedList((prev: string[]) => [...prev, path]); + } + if (!isChecked && checkedList.includes(path)) { + return setCheckedList(checkedList.filter(el => el !== path)); + } + }, + [checkedList], + ); - const select = useCallback((id: string) => { - if (selectedIds.includes(id)) { - setSelectedIds(selectedIds.filter((selectedId => selectedId !== id))); + const onCheckedAll = useCallback(() => { + if (checkedAll) { + setCheckedList([]); return; } - setSelectedIds([...selectedIds, id]); - }, [selectedIds]); + const checkedList = unstakedPositions.map(position => position.id); + setCheckedList(checkedList); + }, [checkedAll, unstakedPositions]); const removeLiquidity = useCallback(() => { openModal(); - }, []); + }, [openModal]); + + useEffect(() => { + const poolPath = router.query["pool-path"] as string; + if (!poolPath) { + return; + } + if (account?.address) { + getPositionsByPoolId(poolPath).then(setPositions); + } + }, [account?.address, getPositionsByPoolId, router.query]); return ( <RemoveLiquidity - positions={positions} - selectedAll={selectedAll} - selectedIds={selectedIds} - select={select} - selectAll={selectAll} + stakedPositions={stakedPositions} + unstakedPositions={unstakedPositions} + checkedList={checkedList} + onCheckedItem={onCheckedItem} + onCheckedAll={onCheckedAll} + checkedAll={checkedAll} removeLiquidity={removeLiquidity} - width={width} /> ); }; diff --git a/packages/web/src/containers/remove-position-modal-container/RemovePositionModalContainer.tsx b/packages/web/src/containers/remove-position-modal-container/RemovePositionModalContainer.tsx index 48410a063..b60a05ae7 100644 --- a/packages/web/src/containers/remove-position-modal-container/RemovePositionModalContainer.tsx +++ b/packages/web/src/containers/remove-position-modal-container/RemovePositionModalContainer.tsx @@ -1,19 +1,44 @@ import RemovePositionModal from "@components/remove/remove-position-modal/RemovePositionModal"; import { useClearModal } from "@hooks/common/use-clear-modal"; +import { useGnoswapContext } from "@hooks/common/use-gnoswap-context"; +import { useWallet } from "@hooks/wallet/use-wallet"; +import { PoolPositionModel } from "@models/position/pool-position-model"; +import { useRouter } from "next/router"; import React, { useCallback } from "react"; -const RemovePositionModalContainer = () => { +interface RemovePositionModalContainerProps { + positions: PoolPositionModel[]; +} + +const RemovePositionModalContainer = ({ + positions, +}: RemovePositionModalContainerProps) => { + const { account } = useWallet(); + const { positionRepository } = useGnoswapContext(); + const router = useRouter(); const clearModal = useClearModal(); const close = useCallback(() => { clearModal(); }, [clearModal]); - const onSubmit = useCallback(() => { - clearModal(); - }, [clearModal]); + const onSubmit = useCallback(async () => { + const address = account?.address; + if (!address) { + return null; + } + const lpTokenIds = positions.map(position => position.id); + const result = await positionRepository.removeLiquidity({ + lpTokenIds, + caller: address + }).catch(() => null); + if (result) { + clearModal(); + router.back(); + } + }, [account?.address, clearModal, positionRepository, positions, router]); - return <RemovePositionModal close={close} onSubmit={onSubmit}/>; + return <RemovePositionModal positions={positions} close={close} onSubmit={onSubmit} />; }; export default RemovePositionModalContainer; diff --git a/packages/web/src/hooks/earn/use-remove-position-modal.tsx b/packages/web/src/hooks/earn/use-remove-position-modal.tsx index 63dfd46ea..853cbdfae 100644 --- a/packages/web/src/hooks/earn/use-remove-position-modal.tsx +++ b/packages/web/src/hooks/earn/use-remove-position-modal.tsx @@ -1,20 +1,26 @@ import RemovePositionModalContainer from "@containers/remove-position-modal-container/RemovePositionModalContainer"; +import { PoolPositionModel } from "@models/position/pool-position-model"; import { CommonState } from "@states/index"; import { useAtom } from "jotai"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; export interface Props { - openModal: () => void; + positions: PoolPositionModel[]; + selectedIds: string[]; } -export const useRemovePositionModal = (): Props => { +export const useRemovePositionModal = ({ positions, selectedIds }: Props) => { const [, setOpenedModal] = useAtom(CommonState.openedModal); const [, setModalContent] = useAtom(CommonState.modalContent); + const selectedPositions = useMemo(() => { + return positions.filter(position => selectedIds.includes(position.id)); + }, [positions, selectedIds]); + const openModal = useCallback(() => { setOpenedModal(true); - setModalContent(<RemovePositionModalContainer />); - }, [setModalContent, setOpenedModal]); + setModalContent(<RemovePositionModalContainer positions={selectedPositions} />); + }, [selectedPositions, setModalContent, setOpenedModal]); return { openModal, diff --git a/packages/web/src/hooks/stake/use-remove-data.ts b/packages/web/src/hooks/stake/use-remove-data.ts new file mode 100644 index 000000000..f46f9be34 --- /dev/null +++ b/packages/web/src/hooks/stake/use-remove-data.ts @@ -0,0 +1,98 @@ +import { useTokenData } from "@hooks/token/use-token-data"; +import { PoolPositionModel } from "@models/position/pool-position-model"; +import { numberToUSD } from "@utils/number-utils"; +import { makeDisplayTokenAmount } from "@utils/token-utils"; +import { useMemo } from "react"; + +export interface RemoveDataProps { + positions: PoolPositionModel[]; +} + +export const useRemoveData = ({ positions }: RemoveDataProps) => { + const { tokenPrices } = useTokenData(); + + const pooledTokenInfos = useMemo(() => { + if (positions.length === 0) { + return []; + } + const tokenA = positions[0].pool.tokenA; + const tokenB = positions[0].pool.tokenB; + const pooledTokenAAmount = positions.reduce( + (accum, position) => accum + position.token0Balance, + 0n, + ); + const pooledTokenBAmount = positions.reduce( + (accum, position) => accum + position.token1Balance, + 0n, + ); + const tokenAPrice = tokenPrices[tokenA.priceId]?.usd || 0; + const tokenBPrice = tokenPrices[tokenB.priceId]?.usd || 0; + const tokenAAmount = + makeDisplayTokenAmount(tokenA, Number(pooledTokenAAmount)) || 0; + const tokenBAmount = + makeDisplayTokenAmount(tokenB, Number(pooledTokenBAmount)) || 0; + return [ + { + token: tokenA, + amount: tokenAAmount, + amountUSD: numberToUSD(tokenAAmount * Number(tokenAPrice)), + }, + { + token: tokenB, + amount: tokenBAmount, + amountUSD: numberToUSD(tokenBAmount * Number(tokenBPrice)), + }, + ]; + }, [positions, tokenPrices]); + + const unclaimedRewards = useMemo(() => { + if (positions.length === 0) { + return []; + } + const tokenA = positions[0].pool.tokenA; + const tokenB = positions[0].pool.tokenB; + const pooledTokenAAmount = positions.reduce( + (accum, position) => accum + position.unclaimedFee0Amount, + 0n, + ); + const pooledTokenBAmount = positions.reduce( + (accum, position) => accum + position.unclaimedFee1Amount, + 0n, + ); + const tokenAPrice = tokenPrices[tokenA.priceId]?.usd || 0; + const tokenBPrice = tokenPrices[tokenB.priceId]?.usd || 0; + const tokenAAmount = + makeDisplayTokenAmount(tokenA, Number(pooledTokenAAmount)) || 0; + const tokenBAmount = + makeDisplayTokenAmount(tokenB, Number(pooledTokenBAmount)) || 0; + return [ + { + token: tokenA, + amount: tokenAAmount, + amountUSD: numberToUSD(tokenAAmount * Number(tokenAPrice)), + }, + { + token: tokenB, + amount: tokenBAmount, + amountUSD: numberToUSD(tokenBAmount * Number(tokenBPrice)), + }, + ]; + }, [positions, tokenPrices]); + + const totalLiquidityUSD = useMemo(() => { + if (positions.length === 0) { + return "-"; + } + const totalUSDValue = positions.reduce( + (accum, position) => accum + Number(position.positionUsdValue), + 0, + ); + return numberToUSD(totalUSDValue); + }, [positions]); + + return { + pooledTokenInfos, + unclaimedRewards, + totalLiquidityUSD, + }; +}; diff --git a/packages/web/src/repositories/position/position-repository-impl.ts b/packages/web/src/repositories/position/position-repository-impl.ts index f1208d871..4b898c338 100644 --- a/packages/web/src/repositories/position/position-repository-impl.ts +++ b/packages/web/src/repositories/position/position-repository-impl.ts @@ -8,12 +8,12 @@ import { DEFAULT_TRANSACTION_DEADLINE, } from "@common/values"; import { GnoProvider } from "@gnolang/gno-js-client"; -import { MAX_INT64 } from "@gnoswap-labs/swap-router"; import { PositionMapper } from "@models/position/mapper/position-mapper"; import { PositionModel } from "@models/position/position-model"; +import { MAX_INT256 } from "@utils/math.utils"; import { PositionRepository } from "./position-repository"; import { ClaimAllRequest } from "./request/claim-all-request"; -import { DecreaseLiquidityReqeust } from "./request/decrease-liquidity-request"; +import { RemoveLiquidityReqeust } from "./request/remove-liquidity-request"; import { StakePositionsRequest } from "./request/stake-positions-request"; import { UnstakePositionsRequest } from "./request/unstake-positions-request"; import { PositionListResponse } from "./response"; @@ -124,28 +124,18 @@ export class PositionRepositoryImpl implements PositionRepository { return hash; }; - decreaseLiquidity = async ( - request: DecreaseLiquidityReqeust, + removeLiquidity = async ( + request: RemoveLiquidityReqeust, ): Promise<string | null> => { if (this.walletClient === null) { throw new CommonError("FAILED_INITIALIZE_WALLET"); } - const { - lpTokenId, - liquidity, - amountAMin, - amountBMax, - caller, - deadline = DEFAULT_TRANSACTION_DEADLINE, - } = request; - const messages = []; - messages.push( + const { lpTokenIds, caller } = request; + const messages = lpTokenIds.map(lpTokenId => PositionRepositoryImpl.makeDecreaseLiquidityMessage( lpTokenId, - liquidity, - amountAMin, - amountBMax, - deadline, + MAX_INT256.toString(), + DEFAULT_TRANSACTION_DEADLINE, caller, ), ); @@ -167,7 +157,12 @@ export class PositionRepositoryImpl implements PositionRepository { send: "", pkg_path: POSITION_PATH, func: "Collect", - args: [lpTokenId, receipient, MAX_INT64.toString(), MAX_INT64.toString()], + args: [ + lpTokenId, + receipient, + MAX_INT256.toString(), + MAX_INT256.toString(), + ], }; } @@ -216,8 +211,6 @@ export class PositionRepositoryImpl implements PositionRepository { private static makeDecreaseLiquidityMessage( lpTokenId: string, liquidity: string, - amountAMin: string, - amountBMin: string, deadeline: string, caller: string, ) { @@ -226,7 +219,7 @@ export class PositionRepositoryImpl implements PositionRepository { send: "", pkg_path: POSITION_PATH, func: "DecreaseLiquidity", - args: [lpTokenId, liquidity, amountAMin, amountBMin, deadeline], + args: [lpTokenId, liquidity, deadeline], }; } @@ -237,8 +230,6 @@ export class PositionRepositoryImpl implements PositionRepository { return this.makeDecreaseLiquidityMessage( lpTokenId, "0", - "0", - "0", DEFAULT_TRANSACTION_DEADLINE, caller, ); diff --git a/packages/web/src/repositories/position/position-repository.ts b/packages/web/src/repositories/position/position-repository.ts index e8bb5e812..57cd27c27 100644 --- a/packages/web/src/repositories/position/position-repository.ts +++ b/packages/web/src/repositories/position/position-repository.ts @@ -1,6 +1,6 @@ import { PositionModel } from "@models/position/position-model"; import { ClaimAllRequest } from "./request/claim-all-request"; -import { DecreaseLiquidityReqeust } from "./request/decrease-liquidity-request"; +import { RemoveLiquidityReqeust } from "./request/remove-liquidity-request"; import { StakePositionsRequest } from "./request/stake-positions-request"; import { UnstakePositionsRequest } from "./request/unstake-positions-request"; @@ -15,7 +15,5 @@ export interface PositionRepository { request: UnstakePositionsRequest, ) => Promise<string | null>; - decreaseLiquidity: ( - request: DecreaseLiquidityReqeust, - ) => Promise<string | null>; + removeLiquidity: (request: RemoveLiquidityReqeust) => Promise<string | null>; } diff --git a/packages/web/src/repositories/position/request/decrease-liquidity-request.ts b/packages/web/src/repositories/position/request/decrease-liquidity-request.ts index 69df49754..a243ca9bf 100644 --- a/packages/web/src/repositories/position/request/decrease-liquidity-request.ts +++ b/packages/web/src/repositories/position/request/decrease-liquidity-request.ts @@ -5,7 +5,7 @@ export interface DecreaseLiquidityReqeust { amountAMin: string; - amountBMax: string; + amountBMin: string; deadline?: string; diff --git a/packages/web/src/repositories/position/request/remove-liquidity-request.ts b/packages/web/src/repositories/position/request/remove-liquidity-request.ts new file mode 100644 index 000000000..c73d077a1 --- /dev/null +++ b/packages/web/src/repositories/position/request/remove-liquidity-request.ts @@ -0,0 +1,5 @@ +export interface RemoveLiquidityReqeust { + lpTokenIds: string[]; + + caller: string; +}