diff --git a/package.json b/package.json index 1c22b3e57..e5753d6b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kwenta", - "version": "7.9.5", + "version": "7.9.6", "description": "Kwenta", "main": "index.js", "scripts": { diff --git a/packages/app/package.json b/packages/app/package.json index 92b7b946b..41d959bad 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@kwenta/app", - "version": "7.9.5", + "version": "7.9.6", "scripts": { "dev": "next", "build": "next build", diff --git a/packages/app/src/components/Table/Pagination.tsx b/packages/app/src/components/Table/Pagination.tsx index 1cf29a306..6e92f2df9 100644 --- a/packages/app/src/components/Table/Pagination.tsx +++ b/packages/app/src/components/Table/Pagination.tsx @@ -15,7 +15,7 @@ export type PaginationProps = { pageCount: number canNextPage: boolean canPreviousPage: boolean - compact: boolean + size: 'xs' | 'sm' | 'md' setPage: (page: number) => void previousPage: () => void nextPage: () => void @@ -28,7 +28,7 @@ const Pagination: FC = React.memo( pageCount, canNextPage = true, canPreviousPage = true, - compact = false, + size = 'md', setPage, nextPage, previousPage, @@ -41,7 +41,7 @@ const Pagination: FC = React.memo( return ( <> - + @@ -77,11 +77,12 @@ const PageInfo = styled.span` ` const PaginationContainer = styled(GridDivCenteredCol)<{ - $compact: boolean + $size: 'xs' | 'sm' | 'md' $bottomBorder: boolean }>` grid-template-columns: auto 1fr auto; - padding: ${(props) => (props.$compact ? '10px' : '15px')} 12px; + padding: ${(props) => (props.$size === 'xs' ? '5px' : props.$size === 'sm' ? '10px' : '15px')} + 12px; border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; justify-items: center; diff --git a/packages/app/src/components/Table/Table.tsx b/packages/app/src/components/Table/Table.tsx index 683be86da..d76a05972 100644 --- a/packages/app/src/components/Table/Table.tsx +++ b/packages/app/src/components/Table/Table.tsx @@ -73,7 +73,8 @@ type TableProps = { sortBy?: SortingState showShortList?: boolean lastRef?: any - compactPagination?: boolean + paginationSize?: 'xs' | 'sm' | 'md' + noResultsContainerPadding?: string rounded?: boolean noBottom?: boolean columnVisibility?: VisibilityState @@ -98,7 +99,8 @@ const Table = ({ showShortList, sortBy = [], lastRef = null, - compactPagination = false, + paginationSize = 'md', + noResultsContainerPadding = '', rounded = true, noBottom = false, columnVisibility, @@ -196,7 +198,9 @@ const Table = ({ ) : !!noResultsMessage && data.length === 0 ? ( - {noResultsMessage} + + {noResultsMessage} + ) : ( {table.getRowModel().rows.map((row, i) => { @@ -217,7 +221,7 @@ const Table = ({ )} {(shouldShowPagination || paginationExtra) && !CustomPagination ? ( ({ {CustomPagination && ( ` + padding: ${(props) => (props.$padding ? props.$padding : '50px')} 0; ` const LoadingContainer = styled.div` diff --git a/packages/app/src/pages/dashboard/staking.tsx b/packages/app/src/pages/dashboard/staking.tsx index 640bb616d..a3745aaf4 100644 --- a/packages/app/src/pages/dashboard/staking.tsx +++ b/packages/app/src/pages/dashboard/staking.tsx @@ -9,10 +9,10 @@ import { FlexDivCol } from 'components/layout/flex' import { NO_VALUE } from 'constants/placeholder' import DashboardLayout from 'sections/dashboard/DashboardLayout' import EscrowTable from 'sections/dashboard/Stake/EscrowTable' -import StakingPortfolio, { StakeTab } from 'sections/dashboard/Stake/StakingPortfolio' +import StakingPortfolio from 'sections/dashboard/Stake/StakingPortfolio' import StakingTab from 'sections/dashboard/Stake/StakingTab' import StakingTabs from 'sections/dashboard/Stake/StakingTabs' -import { StakingCards } from 'sections/dashboard/Stake/types' +import { StakeTab, StakingCards } from 'sections/dashboard/Stake/types' import { useFetchStakeMigrateData } from 'state/futures/hooks' import { useAppSelector } from 'state/hooks' import { diff --git a/packages/app/src/sections/dashboard/Stake/DelegationInput.tsx b/packages/app/src/sections/dashboard/Stake/DelegationInput.tsx new file mode 100644 index 000000000..68cb0cc6c --- /dev/null +++ b/packages/app/src/sections/dashboard/Stake/DelegationInput.tsx @@ -0,0 +1,110 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import Button from 'components/Button' +import Input from 'components/Input/Input' +import { FlexDivCol } from 'components/layout/flex' +import { Body, Heading } from 'components/Text' +import { useAppDispatch, useAppSelector } from 'state/hooks' +import { approveOperator } from 'state/staking/actions' +import { selectIsApprovingOperator } from 'state/staking/selectors' +import { media } from 'styles/media' + +import { StakingCard } from './card' + +const DelegationInput = memo(() => { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const isApprovingOperator = useAppSelector(selectIsApprovingOperator) + const [delegatedAddress, setDelegatedAddress] = useState('') + + const onInputChange = useCallback((text: string) => { + setDelegatedAddress(text.toLowerCase().trim()) + }, []) + + const handleApproveOperator = useCallback( + (delegatedAddress: string) => { + dispatch(approveOperator({ delegatedAddress, isApproval: true })) + setDelegatedAddress('') + }, + [dispatch] + ) + + return ( + + + {t('dashboard.stake.tabs.delegate.title')} + {t('dashboard.stake.tabs.delegate.copy')} + + + {t('dashboard.stake.tabs.delegate.address')} + + + onInputChange(e.target.value)} + placeholder="" + /> + + + + + + ) +}) + +const AddressInput = styled(Input)` + position: relative; + height: 38px; + border-radius: 8px; + padding: 10px 0px; + font-size: 14px; + background: ${(props) => props.theme.colors.selectedTheme.input.background}; + font-family: ${(props) => props.theme.fonts.mono}; + border: none; + ${media.lessThan('sm')` + font-size: 12px; + `} +` + +const InputBar = styled.div` + width: 100%; + overflow-x: auto; + position: relative; + display: flex; + align-items: center; + padding-left: 8px; + background: ${(props) => props.theme.colors.selectedTheme.input.background}; + border-radius: 8px; + border: ${(props) => props.theme.colors.selectedTheme.input.border}; +` + +const InputBarContainer = styled.div` + display: flex; + height: 100%; + width: 100%; +` + +const StyledHeading = styled(Heading)` + font-weight: 400; +` + +const CardGridContainer = styled(StakingCard)` + display: flex; + flex-direction: column; + justify-content: space-between; + row-gap: 25px; +` + +export default DelegationInput diff --git a/packages/app/src/sections/dashboard/Stake/DelegationTab.tsx b/packages/app/src/sections/dashboard/Stake/DelegationTab.tsx new file mode 100644 index 000000000..a3d10a4da --- /dev/null +++ b/packages/app/src/sections/dashboard/Stake/DelegationTab.tsx @@ -0,0 +1,30 @@ +import styled from 'styled-components' + +import media from 'styles/media' + +import DelegationInput from './DelegationInput' +import DelegationTable from './DelegationTable' + +const DelegationTab = () => { + return ( + + + + + ) +} + +const GridContainer = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 15px; + + ${media.lessThan('lg')` + display: grid; + grid-template-columns: 1fr; + row-gap: 25px; + margin-bottom: 25px; + `} +` + +export default DelegationTab diff --git a/packages/app/src/sections/dashboard/Stake/DelegationTable.tsx b/packages/app/src/sections/dashboard/Stake/DelegationTable.tsx new file mode 100644 index 000000000..4315cf53d --- /dev/null +++ b/packages/app/src/sections/dashboard/Stake/DelegationTable.tsx @@ -0,0 +1,180 @@ +import { truncateAddress } from '@kwenta/sdk/utils' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import Button from 'components/Button' +import { Checkbox } from 'components/Checkbox' +import { FlexDivCol, FlexDivRow } from 'components/layout/flex' +import Table, { TableCellHead, TableHeader, TableNoResults } from 'components/Table' +import { TableCell } from 'components/Table/TableBodyRow' +import { Body, Heading } from 'components/Text' +import useWindowSize from 'hooks/useWindowSize' +import { useAppDispatch, useAppSelector } from 'state/hooks' +import { approveOperator, fetchApprovedOperators } from 'state/staking/actions' +import { selectApprovedOperators, selectIsApprovingOperator } from 'state/staking/selectors' +import media from 'styles/media' + +import { StakingCard } from './card' + +const DelegationTable = memo(() => { + const { t } = useTranslation() + const { lessThanWidth } = useWindowSize() + const dispatch = useAppDispatch() + const isApprovingOperator = useAppSelector(selectIsApprovingOperator) + const approvedOperators = useAppSelector(selectApprovedOperators) + + const [checkedState, setCheckedState] = useState(approvedOperators.map((_) => false)) + + const columnsDeps = useMemo( + () => [approvedOperators, checkedState, lessThanWidth], + [approvedOperators, checkedState, lessThanWidth] + ) + + useEffect(() => { + setCheckedState(approvedOperators.map((_) => false)) + }, [approvedOperators]) + + useEffect(() => { + dispatch(fetchApprovedOperators()) + }, [dispatch, approvedOperators.length, isApprovingOperator]) + + const handleOnSelectedRow = useCallback( + (position: number) => { + setCheckedState([ + ...checkedState.map((_, index) => (index === position ? !checkedState[position] : false)), + ]) + }, + [checkedState] + ) + + const handleRevokeOperator = useCallback( + (delegatedAddress: string) => { + dispatch(approveOperator({ delegatedAddress, isApproval: false })) + setCheckedState((data) => data.map((_) => false)) + }, + [dispatch] + ) + + return ( + + + {t('dashboard.stake.tabs.delegate.manage')} + {t('dashboard.stake.tabs.delegate.manage-copy')} + + handleOnSelectedRow(row.index)} + columnsDeps={columnsDeps} + noResultsContainerPadding="0px" + noResultsMessage={ + {t('dashboard.stake.tabs.delegate.no-result')} + } + columns={[ + { + header: () => <>, + cell: (cellProps) => ( + handleOnSelectedRow(cellProps.row.index)} + label="" + variant="fill" + checkSide="right" + /> + ), + accessorKey: 'selected', + size: 30, + enableSorting: false, + }, + { + header: () => {t('dashboard.stake.tabs.delegate.address')}, + accessorKey: 'address', + size: 300, + enableSorting: false, + cell: (cellProps) => ( + + {lessThanWidth('md') + ? truncateAddress(cellProps.getValue(), 15, 15) + : cellProps.getValue()} + + ), + }, + ]} + /> + + + + + ) +}) + +const mobileTableStyle = css` + ${media.lessThan('sm')` + &:first-child { + padding-left: 6px; + } + &:last-child { + padding-left: 6px; + } + `} +` + +const StyledTable = styled(Table)` + width: 100%; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-radius: 8px; + ${TableCell} { + font-size: 12px; + font-family: ${(props) => props.theme.fonts.mono}; + color: ${(props) => props.theme.colors.selectedTheme.button.text.primary}; + height: 16px; + &:first-child { + padding-left: 10px; + } + ${mobileTableStyle} + } + ${TableCellHead} { + &:first-child { + padding-left: 10px; + } + padding-left: 12px; + height: 28px; + ${mobileTableStyle} + } + + ${media.lessThan('sm')` + max-width: calc(100vw - 80px); + `} +` as typeof Table + +const CardGridContainer = styled(StakingCard)` + display: flex; + flex-direction: column; + justify-content: space-between; + row-gap: 25px; +` + +const StyledHeading = styled(Heading)` + font-weight: 400; +` + +export default DelegationTable diff --git a/packages/app/src/sections/dashboard/Stake/EscrowTable.tsx b/packages/app/src/sections/dashboard/Stake/EscrowTable.tsx index 50c30b8b7..8fc0d2733 100644 --- a/packages/app/src/sections/dashboard/Stake/EscrowTable.tsx +++ b/packages/app/src/sections/dashboard/Stake/EscrowTable.tsx @@ -134,7 +134,7 @@ const EscrowTable = () => { { = ({ currentTab, onChangeTab }) => onClick={onChangeTab(StakeTab.Escrow)} active={currentTab === StakeTab.Escrow} /> +
@@ -51,6 +58,9 @@ const StakingTabs: React.FC = ({ currentTab, onChangeTab }) => + + +
) diff --git a/packages/app/src/sections/dashboard/Stake/types.ts b/packages/app/src/sections/dashboard/Stake/types.ts index 31e970346..3be5cc5b1 100644 --- a/packages/app/src/sections/dashboard/Stake/types.ts +++ b/packages/app/src/sections/dashboard/Stake/types.ts @@ -38,3 +38,9 @@ export type RewardsInfo = { info: RewardsCard[] disabled?: boolean } + +export enum StakeTab { + Staking = 'staking', + Escrow = 'escrow', + Delegate = 'delegate', +} diff --git a/packages/app/src/sections/futures/EditPositionModal/EditStopLossAndTakeProfitInput.tsx b/packages/app/src/sections/futures/EditPositionModal/EditStopLossAndTakeProfitInput.tsx index 263f03293..e6e3ce0c3 100644 --- a/packages/app/src/sections/futures/EditPositionModal/EditStopLossAndTakeProfitInput.tsx +++ b/packages/app/src/sections/futures/EditPositionModal/EditStopLossAndTakeProfitInput.tsx @@ -9,7 +9,7 @@ import InputTitle from 'components/Input/InputTitle' import SLTPInputField, { SLTPInputFieldProps } from '../Trade/SLTPInputField' const EditStopLossAndTakeProfitInput: React.FC = memo( - ({ type, currentPrice, ...props }) => { + ({ type, price, ...props }) => { const { t } = useTranslation() return ( @@ -18,8 +18,8 @@ const EditStopLossAndTakeProfitInput: React.FC = memo( label={type === 'take-profit' ? 'Take Profit' : 'Stop Loss'} rightElement={ - {t('futures.market.trade.edit-sl-tp.last-price')}:{' '} - {formatDollars(currentPrice)} + {t('futures.market.trade.edit-sl-tp.entry-price')}:{' '} + {formatDollars(price)} } /> @@ -27,7 +27,7 @@ const EditStopLossAndTakeProfitInput: React.FC = memo( diff --git a/packages/app/src/sections/futures/EditPositionModal/EditStopLossAndTakeProfitModal.tsx b/packages/app/src/sections/futures/EditPositionModal/EditStopLossAndTakeProfitModal.tsx index 86730ebe5..cc09f3a10 100644 --- a/packages/app/src/sections/futures/EditPositionModal/EditStopLossAndTakeProfitModal.tsx +++ b/packages/app/src/sections/futures/EditPositionModal/EditStopLossAndTakeProfitModal.tsx @@ -45,7 +45,7 @@ export default function EditStopLossAndTakeProfitModal() { const { t } = useTranslation() const dispatch = useAppDispatch() const transactionState = useAppSelector(selectTransaction) - const { market, marketPrice, position } = useAppSelector(selectEditPositionModalInfo) + const { market, position } = useAppSelector(selectEditPositionModalInfo) const exsistingSLTPOrders = useAppSelector(selectAllSLTPOrders) const isSubmitting = useAppSelector(selectSubmittingFuturesTx) const { takeProfitPrice, stopLossPrice } = useAppSelector(selectSlTpModalInputs) @@ -114,6 +114,18 @@ export default function EditStopLossAndTakeProfitModal() { ] ) + const entryPriceWei = useMemo(() => { + return position?.activePosition?.details?.entryPrice ?? wei(0) + }, [position?.activePosition?.details?.entryPrice]) + + const calculateSizeWei = useMemo(() => { + if (!position?.activePosition.size) { + return wei(0) + } + + return position.activePosition.size.mul(entryPriceWei) + }, [position?.activePosition.size, entryPriceWei]) + useEffect(() => { const existingSL = exsistingSLTPOrders.find( (o) => o.marketKey === market?.marketKey && o.orderType === ConditionalOrderTypeEnum.STOP @@ -147,13 +159,13 @@ export default function EditStopLossAndTakeProfitModal() { const relativePercent = wei(percent).div(leverageWei) const stopLoss = position?.activePosition.side === 'short' - ? marketPrice.add(marketPrice.mul(relativePercent)) - : marketPrice.sub(marketPrice.mul(relativePercent)) + ? entryPriceWei.add(entryPriceWei.mul(relativePercent)) + : entryPriceWei.sub(entryPriceWei.mul(relativePercent)) const dp = suggestedDecimals(stopLoss) dispatch(setSLTPModalStopLoss(stopLoss.toString(dp))) } }, - [marketPrice, dispatch, position?.activePosition.side, leverageWei] + [entryPriceWei, dispatch, position?.activePosition.side, leverageWei] ) const onSelectTakeProfit = useCallback( @@ -166,13 +178,13 @@ export default function EditStopLossAndTakeProfitModal() { const relativePercent = wei(percent).div(leverageWei) const takeProfit = position?.activePosition.side === 'short' - ? marketPrice.sub(marketPrice.mul(relativePercent)) - : marketPrice.add(marketPrice.mul(relativePercent)) + ? entryPriceWei.sub(entryPriceWei.mul(relativePercent)) + : entryPriceWei.add(entryPriceWei.mul(relativePercent)) const dp = suggestedDecimals(takeProfit) dispatch(setSLTPModalTakeProfit(takeProfit.toString(dp))) } }, - [marketPrice, dispatch, position?.activePosition.side, leverageWei] + [entryPriceWei, dispatch, position?.activePosition.side, leverageWei] ) const onChangeStopLoss = useCallback( @@ -220,10 +232,11 @@ export default function EditStopLossAndTakeProfitModal() { @@ -240,8 +253,9 @@ export default function EditStopLossAndTakeProfitModal() { positionSide={position?.activePosition.side || PositionSide.LONG} leverage={position?.activePosition.leverage || wei(1)} invalidLabel={sltpValidity.stopLoss.invalidLabel} - currentPrice={marketPrice} + price={entryPriceWei} value={stopLossPrice} + size={calculateSizeWei} onChange={onChangeStopLoss} /> diff --git a/packages/app/src/sections/futures/Trade/SLTPInputField.tsx b/packages/app/src/sections/futures/Trade/SLTPInputField.tsx index 6e70cd2d9..d7cbecdda 100644 --- a/packages/app/src/sections/futures/Trade/SLTPInputField.tsx +++ b/packages/app/src/sections/futures/Trade/SLTPInputField.tsx @@ -12,8 +12,9 @@ export type SLTPInputFieldProps = { type: 'take-profit' | 'stop-loss' value: string invalidLabel: string | undefined - currentPrice: Wei + price: Wei leverage: Wei + size: Wei minMaxPrice?: Wei dataTestId?: string positionSide: PositionSide @@ -27,9 +28,10 @@ const SLTPInputField: React.FC = memo( type, value, invalidLabel, - currentPrice, + price, positionSide, leverage, + size, dataTestId, disabledReason, disabled, @@ -59,9 +61,10 @@ const SLTPInputField: React.FC = memo( ) } diff --git a/packages/app/src/sections/futures/Trade/SLTPInputs.tsx b/packages/app/src/sections/futures/Trade/SLTPInputs.tsx index 367f7398c..d6cbee6bb 100644 --- a/packages/app/src/sections/futures/Trade/SLTPInputs.tsx +++ b/packages/app/src/sections/futures/Trade/SLTPInputs.tsx @@ -17,12 +17,17 @@ import { selectLeverageInput, selectLeverageSide, selectTradePanelSLTPValidity, + selectTradeSizeInputs, } from 'state/futures/selectors' import { setSmartMarginTradeStopLoss, setSmartMarginTradeTakeProfit, } from 'state/futures/smartMargin/reducer' -import { selectSlTpTradeInputs } from 'state/futures/smartMargin/selectors' +import { + selectOrderType, + selectSlTpTradeInputs, + selectSmartMarginOrderPrice, +} from 'state/futures/smartMargin/selectors' import { useAppDispatch, useAppSelector } from 'state/hooks' import OrderAcknowledgement from './OrderAcknowledgement' @@ -39,6 +44,9 @@ export default function SLTPInputs() { const leverage = useAppSelector(selectLeverageInput) const hideWarning = useAppSelector(selectAckedOrdersWarning) const sltpValidity = useSelector(selectTradePanelSLTPValidity) + const { susdSize } = useAppSelector(selectTradeSizeInputs) + const orderType = useAppSelector(selectOrderType) + const orderPrice = useAppSelector(selectSmartMarginOrderPrice) const [showInputs, setShowInputs] = useState(false) const [showOrderWarning, setShowOrderWarning] = useState(false) @@ -56,6 +64,18 @@ export default function SLTPInputs() { return leverage && Number(leverage) > 0 ? wei(leverage) : wei(1) }, [leverage]) + const price = useMemo(() => { + switch (orderType) { + case 'market': + return currentPrice + case 'limit': + case 'stop_market': + return orderPrice ? wei(orderPrice) : currentPrice + default: + return currentPrice + } + }, [orderPrice, orderType, currentPrice]) + const onSelectStopLossPercent = useCallback( (index: number) => { const option = SL_OPTIONS[index] @@ -63,12 +83,12 @@ export default function SLTPInputs() { const relativePercent = wei(percent).div(leverageWei) const stopLoss = leverageSide === 'short' - ? currentPrice.add(currentPrice.mul(relativePercent)) - : currentPrice.sub(currentPrice.mul(relativePercent)) + ? price.add(price.mul(relativePercent)) + : price.sub(price.mul(relativePercent)) const dp = suggestedDecimals(stopLoss) dispatch(setSmartMarginTradeStopLoss(stopLoss.toString(dp))) }, - [currentPrice, dispatch, leverageSide, leverageWei] + [dispatch, leverageSide, leverageWei, price] ) const onSelectTakeProfit = useCallback( @@ -78,12 +98,12 @@ export default function SLTPInputs() { const relativePercent = wei(percent).div(leverageWei) const takeProfit = leverageSide === 'short' - ? currentPrice.sub(currentPrice.mul(relativePercent)) - : currentPrice.add(currentPrice.mul(relativePercent)) + ? price.sub(price.mul(relativePercent)) + : price.add(price.mul(relativePercent)) const dp = suggestedDecimals(takeProfit) dispatch(setSmartMarginTradeTakeProfit(takeProfit.toString(dp))) }, - [currentPrice, dispatch, leverageSide, leverageWei] + [dispatch, leverageSide, leverageWei, price] ) const onChangeStopLoss = useCallback( @@ -133,11 +153,12 @@ export default function SLTPInputs() { invalidLabel={sltpValidity.takeProfit.invalidLabel} value={takeProfitPrice} type={'take-profit'} - currentPrice={currentPrice} + price={price} positionSide={leverageSide} leverage={leverageWei} dataTestId={'trade-panel-take-profit-input'} onChange={onChangeTakeProfit} + size={susdSize} /> @@ -153,11 +174,12 @@ export default function SLTPInputs() { invalidLabel={sltpValidity.stopLoss.invalidLabel} value={stopLossPrice} type={'stop-loss'} - currentPrice={currentPrice} + price={price} positionSide={leverageSide} leverage={leverageWei} dataTestId={'trade-panel-stop-loss-input'} onChange={onChangeStopLoss} + size={susdSize} /> ) diff --git a/packages/app/src/sections/futures/Trade/ShowPercentage.tsx b/packages/app/src/sections/futures/Trade/ShowPercentage.tsx index b1a819209..349acdd41 100644 --- a/packages/app/src/sections/futures/Trade/ShowPercentage.tsx +++ b/packages/app/src/sections/futures/Trade/ShowPercentage.tsx @@ -1,45 +1,61 @@ import { PositionSide } from '@kwenta/sdk/types' -import { formatPercent } from '@kwenta/sdk/utils' +import { formatPercent, formatDollars } from '@kwenta/sdk/utils' import Wei, { wei } from '@synthetixio/wei' import { useMemo } from 'react' +import styled from 'styled-components' import { Body } from 'components/Text' type ShowPercentageProps = { targetPrice: string isStopLoss?: boolean - currentPrice: Wei + price: Wei leverageSide: PositionSide | string | undefined leverageWei: Wei + sizeWei: Wei } const ShowPercentage: React.FC = ({ targetPrice, isStopLoss = false, - currentPrice, + price, leverageSide, leverageWei, + sizeWei, }) => { - const calculatePercentage = useMemo(() => { - if (!targetPrice || !currentPrice || !leverageSide) return '' + const [calculatePercentage, calculatePL] = useMemo(() => { + if (!targetPrice || !price || !leverageSide || !sizeWei) return '' const priceWei = wei(targetPrice) + const diff = leverageSide === 'short' ? isStopLoss - ? priceWei.sub(currentPrice) - : currentPrice.sub(priceWei) + ? priceWei.sub(price) + : price.sub(priceWei) : isStopLoss - ? currentPrice.sub(priceWei) - : priceWei.sub(currentPrice) + ? price.sub(priceWei) + : priceWei.sub(price) + + const percentage = diff.div(price).mul(leverageWei) + const profitLoss = sizeWei.mul(percentage.div(leverageWei)).mul(isStopLoss ? -1 : 1) - return formatPercent(diff.div(currentPrice).mul(leverageWei)) - }, [currentPrice, isStopLoss, leverageSide, leverageWei, targetPrice]) + return [formatPercent(percentage), formatDollars(profitLoss, { sign: isStopLoss ? '' : '+' })] + }, [price, isStopLoss, leverageSide, leverageWei, targetPrice, sizeWei]) return ( + {calculatePL} {calculatePercentage} ) } +const ProfitLoss = styled.span<{ isStopLoss: boolean }>` + margin-right: 0.7rem; + color: ${({ theme, isStopLoss }) => + isStopLoss + ? theme.colors.selectedTheme.newTheme.text.negative + : theme.colors.selectedTheme.newTheme.text.positive}; +` + export default ShowPercentage diff --git a/packages/app/src/sections/futures/TraderHistory.tsx b/packages/app/src/sections/futures/TraderHistory.tsx index d5053d953..8c5e6c213 100644 --- a/packages/app/src/sections/futures/TraderHistory.tsx +++ b/packages/app/src/sections/futures/TraderHistory.tsx @@ -64,7 +64,7 @@ const TraderHistory: FC = memo( data={data} hideHeaders={compact} autoResetPageIndex={false} - compactPagination={true} + paginationSize={'sm'} columns={[ { header: () => ( diff --git a/packages/app/src/sections/leaderboard/AllTime.tsx b/packages/app/src/sections/leaderboard/AllTime.tsx index 6c2110141..82629b041 100644 --- a/packages/app/src/sections/leaderboard/AllTime.tsx +++ b/packages/app/src/sections/leaderboard/AllTime.tsx @@ -71,7 +71,7 @@ const AllTime: FC = ({ totalVolume: !compact, pnl: !compact, }} - compactPagination={true} + paginationSize={'sm'} columnsDeps={[activeTab]} noResultsMessage={ data?.length === 0 && ( diff --git a/packages/app/src/sections/referrals/ReferralCodes.tsx b/packages/app/src/sections/referrals/ReferralCodes.tsx index 85905c1bc..3e6123486 100644 --- a/packages/app/src/sections/referrals/ReferralCodes.tsx +++ b/packages/app/src/sections/referrals/ReferralCodes.tsx @@ -40,8 +40,6 @@ const ReferralCodes: FC = memo(({ data }) => { const referralCodesTableProps = useMemo( () => ({ data, - compactPagination: true, - pageSize: 4, showPagination: true, columnsDeps: [wallet, isCreatingCode, isL2], noResultsMessage: , @@ -81,6 +79,7 @@ const ReferralCodes: FC = memo(({ data }) => { ( @@ -163,6 +162,7 @@ const ReferralCodes: FC = memo(({ data }) => { = memo(({ data }) const rewardsHistoryTableProps = useMemo( () => ({ data, - compactPagination: true, pageSize: 4, showPagination: true, noResultsMessage: , @@ -36,6 +35,7 @@ const ReferralRewardsHistory: FC = memo(({ data }) ( @@ -93,6 +93,7 @@ const ReferralRewardsHistory: FC = memo(({ data }) ( dispatch(fetchEstimatedRewards()) dispatch(fetchClaimableRewards()) dispatch(fetchMigrationDetails()) + dispatch(fetchApprovedOperators()) } ) @@ -630,3 +634,67 @@ export const unstakeKwentaV2 = createAsyncThunk( }) } ) + +export const approveOperator = createAsyncThunk< + void, + { delegatedAddress: string; isApproval: boolean }, + ThunkConfig +>( + 'staking/approveOperator', + async ({ delegatedAddress, isApproval }, { dispatch, getState, extra: { sdk } }) => { + const wallet = selectWallet(getState()) + if (!wallet) throw new Error('Wallet not connected') + + const supportedNetwork = selectStakingSupportedNetwork(getState()) + if (!supportedNetwork) + throw new Error( + 'Approving Operator is unsupported on this network. Please switch to Optimism.' + ) + + try { + dispatch( + setTransaction({ + status: TransactionStatus.AwaitingExecution, + type: 'approve_operator', + hash: null, + }) + ) + + const tx = await sdk.kwentaToken.approveOperator(delegatedAddress, isApproval) + await monitorAndAwaitTransaction(dispatch, tx) + dispatch(fetchApprovedOperators()) + } catch (err) { + logError(err) + dispatch(handleTransactionError(err.message)) + throw err + } + } +) + +export const fetchApprovedOperators = createAsyncThunk<{ operators: string[] }, void, ThunkConfig>( + 'staking/fetchApprovedOperators', + async (_, { getState, extra: { sdk } }) => { + try { + const wallet = selectWallet(getState()) + if (!wallet) return { operators: [] } + const operatorsApprovalTxns = await sdk.kwentaToken.getApprovedOperators() + const operatorStatus: { [key: string]: boolean } = {} + for (const txn of operatorsApprovalTxns) { + if (operatorStatus[txn.operator] === undefined) { + operatorStatus[txn.operator] = txn.approved + } + } + const operators = Object.keys(operatorStatus) + .filter((operator) => operatorStatus[operator]) + .map((operator) => operator.toLowerCase()) + + return { + operators, + } + } catch (err) { + logError(err) + notifyError('Failed to fetch approved operators', err) + throw err + } + } +) diff --git a/packages/app/src/state/staking/reducer.ts b/packages/app/src/state/staking/reducer.ts index 589d0a4a1..d77214ea7 100644 --- a/packages/app/src/state/staking/reducer.ts +++ b/packages/app/src/state/staking/reducer.ts @@ -27,6 +27,7 @@ import { claimStakingRewardsV2, approveKwentaToken, compoundRewards, + fetchApprovedOperators, } from './actions' import { StakingState } from './types' @@ -98,6 +99,7 @@ export const STAKING_INITIAL_STATE: StakingState = { ...ZERO_CLAIMABLE_REWARDS, selectedEscrowVersion: 1, stakingMigrationCompleted: true, + approvedOperators: [], stakeStatus: FetchStatus.Idle, unstakeStatus: FetchStatus.Idle, stakeEscrowedStatus: FetchStatus.Idle, @@ -216,6 +218,9 @@ const stakingSlice = createSlice({ state.estimatedKwentaRewards = action.payload.estimatedKwentaRewards state.estimatedOpRewards = action.payload.estimatedOpRewards }) + builder.addCase(fetchApprovedOperators.fulfilled, (state, action) => { + state.approvedOperators = action.payload.operators + }) builder.addCase(approveKwentaToken.pending, (state) => { state.approveKwentaStatus = FetchStatus.Loading }) diff --git a/packages/app/src/state/staking/selectors.ts b/packages/app/src/state/staking/selectors.ts index 0165fc3bf..fb500e705 100644 --- a/packages/app/src/state/staking/selectors.ts +++ b/packages/app/src/state/staking/selectors.ts @@ -1,4 +1,5 @@ import { ZERO_WEI } from '@kwenta/sdk/constants' +import { TransactionStatus } from '@kwenta/sdk/types' import { toWei } from '@kwenta/sdk/utils' import { createSelector } from '@reduxjs/toolkit' import { wei } from '@synthetixio/wei' @@ -17,6 +18,16 @@ import { import { RootState } from 'state/store' import { FetchStatus } from 'state/types' +export const selectSubmittingStakingTx = createSelector( + (state: RootState) => state.app, + (app) => { + return ( + app.transaction?.status === TransactionStatus.AwaitingExecution || + app.transaction?.status === TransactionStatus.Executed + ) + } +) + export const selectClaimableBalanceV1 = createSelector( (state: RootState) => state.staking.v1.claimableBalance, toWei @@ -470,3 +481,14 @@ export const selectStepUnstakeActive = createSelector( (stepClaimFlowActive, stepMigrateFlowActive, stakedKwentaBalance) => !stepClaimFlowActive && !stepMigrateFlowActive && stakedKwentaBalance.gt(0) ) + +export const selectApprovedOperators = createSelector( + (state: RootState) => state.staking.approvedOperators, + (approvedOperators) => approvedOperators.map((operator) => ({ address: operator })) +) + +export const selectIsApprovingOperator = createSelector( + selectSubmittingStakingTx, + (state: RootState) => state.app, + (submitting, app) => submitting && app.transaction?.type === 'approve_operator' +) diff --git a/packages/app/src/state/staking/types.ts b/packages/app/src/state/staking/types.ts index 6216726de..4783a8bec 100644 --- a/packages/app/src/state/staking/types.ts +++ b/packages/app/src/state/staking/types.ts @@ -1,4 +1,4 @@ -import { EscrowData, ClaimParams } from '@kwenta/sdk/types' +import { EscrowData, ClaimParams, TransactionStatus } from '@kwenta/sdk/types' import { FetchStatus } from 'state/types' @@ -53,6 +53,7 @@ export type StakingState = StakingMiscInfo & selectedEscrowVersion: 1 | 2 selectedEpoch?: number stakingMigrationCompleted: boolean + approvedOperators: string[] stakeStatus: FetchStatus unstakeStatus: FetchStatus stakeEscrowedStatus: FetchStatus @@ -70,3 +71,12 @@ export type StakingState = StakingMiscInfo & export type StakingAction = StakeBalance & StakingMiscInfo export type StakingActionV2 = StakeBalance & StakingMiscInfoV2 + +export type StakingTransactionType = 'approve_operator' + +export type StakingTransaction = { + type: StakingTransactionType + status: TransactionStatus + error?: string + hash: string | null +} diff --git a/packages/app/src/state/stakingMigration/types.ts b/packages/app/src/state/stakingMigration/types.ts index cd6575cd6..07bc9137e 100644 --- a/packages/app/src/state/stakingMigration/types.ts +++ b/packages/app/src/state/stakingMigration/types.ts @@ -35,7 +35,7 @@ export type StakingMigrationTransactionType = | 'approve_escrow_migrator' | 'migrate_entries' -export type StakingMigrationlTransaction = { +export type StakingMigrationTransaction = { type: StakingMigrationTransactionType status: TransactionStatus error?: string diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index f852d8ca4..f4b221bbd 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -664,6 +664,16 @@ "vkwenta-token": "vKwenta", "approve": "Approve", "balance": "Balance:" + }, + "delegate": { + "title": "Delegate", + "copy": "Allow other addresses to collect and stake rewards on your behalf", + "manage": "Manage", + "manage-copy": "Manage delegated addresses", + "address": "Address", + "revoke": "Revoke", + "no-result": "You have no delegated addresses", + "warning": "Coming soon" } } }, @@ -1023,7 +1033,7 @@ "no-tp": "No TP", "no-sl": "No SL", "stop-loss": "Stop Loss", - "last-price": "Last Price", + "entry-price": "Entry Price", "estimated-profit": "Estimated Profit", "estimated-loss": "Estimated Loss", "warning": "This setting applies to the entire position." diff --git a/packages/sdk/src/constants/staking.ts b/packages/sdk/src/constants/staking.ts index cd3a9675d..5c5aa4a38 100644 --- a/packages/sdk/src/constants/staking.ts +++ b/packages/sdk/src/constants/staking.ts @@ -22,3 +22,10 @@ export const OP_REWARDS_CUTOFF_EPOCH = 22 export const REFERRAL_PROGRAM_START_EPOCH = 44 export const SUPPLY_RATE = wei(1).sub(wei(DECAY_RATE)) + +export const STAKING_ENDPOINT_OP_MAINNET = + 'https://subgraph.satsuma-prod.com/05943208e921/kwenta/staking-v2/api' + +export const STAKING_ENDPOINTS: Record = { + 10: STAKING_ENDPOINT_OP_MAINNET, +} diff --git a/packages/sdk/src/queries/staking.ts b/packages/sdk/src/queries/staking.ts new file mode 100644 index 000000000..0e7dab5f8 --- /dev/null +++ b/packages/sdk/src/queries/staking.ts @@ -0,0 +1,28 @@ +import request, { gql } from 'graphql-request' +import KwentaSDK from '..' +import { OperatorApprovals } from '../types' + +export const queryOperatorsByOwner = async ( + sdk: KwentaSDK, + walletAddress: string +): Promise => { + if (!walletAddress) return [] + const response: { operatorApproveds: OperatorApprovals[] } = await request( + sdk.kwentaToken.stakingGqlEndpoint, + gql` + query operatorApproveds($walletAddress: String!) { + operatorApproveds( + orderBy: blockTimestamp + orderDirection: desc + where: { owner: $walletAddress } + ) { + operator + blockTimestamp + approved + } + } + `, + { walletAddress } + ) + return response?.operatorApproveds || [] +} diff --git a/packages/sdk/src/services/futures.ts b/packages/sdk/src/services/futures.ts index 22a367e5c..f90b14a8f 100644 --- a/packages/sdk/src/services/futures.ts +++ b/packages/sdk/src/services/futures.ts @@ -512,47 +512,78 @@ export default class FuturesService { * ``` */ public async getSmartMarginBalanceInfo(walletAddress: string, smartMarginAddress: string) { - const smartMarginAccountContract = SmartMarginAccount__factory.connect( + const smartMarginAccountContract = new EthCallContract( smartMarginAddress, - this.sdk.context.provider + SmartMarginAccountABI ) const { SUSD, USDC, USDT, DAI, LUSD } = this.sdk.context.multicallContracts + // Cover testnet case + if (!this.sdk.context.isMainnet) { + if (!SUSD) throw new Error(UNSUPPORTED_NETWORK) + + const [freeMargin, keeperEthBal, walletEthBal, susdBalance, allowance] = + await this.sdk.context.multicallProvider.all([ + smartMarginAccountContract.freeMargin(), + this.sdk.context.multicallProvider.getEthBalance(smartMarginAddress), + this.sdk.context.multicallProvider.getEthBalance(walletAddress), + SUSD.balanceOf(walletAddress), + SUSD.allowance(walletAddress, smartMarginAddress), + ]) + + return { + freeMargin: wei(freeMargin), + keeperEthBal: wei(keeperEthBal), + walletEthBal: wei(walletEthBal), + allowance: wei(allowance), + balances: { + [SwapDepositToken.SUSD]: wei(susdBalance), + [SwapDepositToken.USDC]: wei(0, 6), + // [SwapDepositToken.USDT]: wei(usdtBalance, 6), + [SwapDepositToken.DAI]: wei(0), + // [SwapDepositToken.LUSD]: wei(lusdBalance), + }, + allowances: { + [SwapDepositToken.SUSD]: wei(allowance), + [SwapDepositToken.USDC]: wei(0, 6), + // [SwapDepositToken.USDT]: wei(usdtAllowance, 6), + [SwapDepositToken.DAI]: wei(0), + // [SwapDepositToken.LUSD]: wei(lusdAllowance), + }, + } + } + if (!SUSD || !USDC || !USDT || !DAI || !LUSD) throw new Error(UNSUPPORTED_NETWORK) const [ freeMargin, - [ - keeperEthBal, - walletEthBal, - susdBalance, - allowance, - usdcBalance, - usdcAllowance, - // usdtBalance, - // usdtAllowance, - daiBalance, - daiAllowance, - // lusdBalance, - // lusdAllowance, - ], - ] = await Promise.all([ + keeperEthBal, + walletEthBal, + susdBalance, + allowance, + usdcBalance, + usdcAllowance, + // usdtBalance, + // usdtAllowance, + daiBalance, + daiAllowance, + // lusdBalance, + // lusdAllowance, + ] = await this.sdk.context.multicallProvider.all([ smartMarginAccountContract.freeMargin(), - this.sdk.context.multicallProvider.all([ - this.sdk.context.multicallProvider.getEthBalance(smartMarginAddress), - this.sdk.context.multicallProvider.getEthBalance(walletAddress), - SUSD.balanceOf(walletAddress), - SUSD.allowance(walletAddress, smartMarginAccountContract.address), - USDC.balanceOf(walletAddress), - USDC.allowance(walletAddress, PERMIT2_ADDRESS), - // USDT.balanceOf(walletAddress), - // USDT.allowance(walletAddress, PERMIT2_ADDRESS), - DAI.balanceOf(walletAddress), - DAI.allowance(walletAddress, PERMIT2_ADDRESS), - // LUSD.balanceOf(walletAddress), - // LUSD.allowance(walletAddress, PERMIT2_ADDRESS), - ]), + this.sdk.context.multicallProvider.getEthBalance(smartMarginAddress), + this.sdk.context.multicallProvider.getEthBalance(walletAddress), + SUSD.balanceOf(walletAddress), + SUSD.allowance(walletAddress, smartMarginAddress), + USDC.balanceOf(walletAddress), + USDC.allowance(walletAddress, PERMIT2_ADDRESS), + // USDT.balanceOf(walletAddress), + // USDT.allowance(walletAddress, PERMIT2_ADDRESS), + DAI.balanceOf(walletAddress), + DAI.allowance(walletAddress, PERMIT2_ADDRESS), + // LUSD.balanceOf(walletAddress), + // LUSD.allowance(walletAddress, PERMIT2_ADDRESS), ]) return { diff --git a/packages/sdk/src/services/kwentaToken.ts b/packages/sdk/src/services/kwentaToken.ts index 8de56c1a7..a961b9438 100644 --- a/packages/sdk/src/services/kwentaToken.ts +++ b/packages/sdk/src/services/kwentaToken.ts @@ -24,8 +24,9 @@ import { formatTruncatedDuration } from '../utils/date' import { awsClient } from '../utils/files' import { weiFromWei } from '../utils/number' import { getFuturesAggregateStats, getFuturesTrades } from '../utils/subgraph' -import { calculateFeesForAccount, calculateTotalFees } from '../utils' +import { calculateFeesForAccount, calculateTotalFees, getStakingGqlEndpoint } from '../utils' import { ADDRESSES } from '../constants' +import { queryOperatorsByOwner } from '../queries/staking' export default class KwentaTokenService { private sdk: KwentaSDK @@ -34,6 +35,11 @@ export default class KwentaTokenService { this.sdk = sdk } + get stakingGqlEndpoint() { + const { networkId } = this.sdk.context + return getStakingGqlEndpoint(networkId) + } + public changePoolTokens(amount: string, action: 'stake' | 'withdraw') { if (!this.sdk.context.contracts.StakingRewards) { throw new Error(sdkErrors.UNSUPPORTED_NETWORK) @@ -872,4 +878,26 @@ export default class KwentaTokenService { [amount] ) } + + public approveOperator(delegatedAddress: string, isApproval: boolean) { + const { KwentaStakingRewardsV2 } = this.sdk.context.contracts + + if (!KwentaStakingRewardsV2) { + throw new Error(sdkErrors.UNSUPPORTED_NETWORK) + } + + return this.sdk.transactions.createContractTxn(KwentaStakingRewardsV2, 'approveOperator', [ + delegatedAddress, + isApproval, + ]) + } + + public async getApprovedOperators() { + if (!this.sdk.context.contracts.KwentaStakingRewardsV2) { + throw new Error(sdkErrors.UNSUPPORTED_NETWORK) + } + const { walletAddress } = this.sdk.context + + return queryOperatorsByOwner(this.sdk, walletAddress) + } } diff --git a/packages/sdk/src/types/staking.ts b/packages/sdk/src/types/staking.ts index 10e2e2234..a612becdd 100644 --- a/packages/sdk/src/types/staking.ts +++ b/packages/sdk/src/types/staking.ts @@ -19,3 +19,9 @@ export type FuturesFeeProps = { timestamp: string feesKwenta: BigNumber } + +export interface OperatorApprovals { + operator: string + blockTimestamp: number + approved: boolean +} diff --git a/packages/sdk/src/utils/referrals.ts b/packages/sdk/src/utils/referrals.ts index 7c7bdf006..9cbc801cd 100644 --- a/packages/sdk/src/utils/referrals.ts +++ b/packages/sdk/src/utils/referrals.ts @@ -60,23 +60,32 @@ const getCumulativeStatsByCode = async ( }) ) - const response: Record = await request( - sdk.futures.futuresGqlEndpoint, - gql` - query totalFuturesTrades { - ${traderVolumeQueries.join('')} - } - ` - ) + if (traderVolumeQueries.length > 0) { + const response: Record = await request( + sdk.futures.futuresGqlEndpoint, + gql` + query totalFuturesTrades { + ${traderVolumeQueries.join('')} + } + ` + ) - const totalTrades = response ? Object.values(response).flat() : [] - const totalVolume = calculateTraderVolume(totalTrades) + const totalTrades = response ? Object.values(response).flat() : [] + const totalVolume = calculateTraderVolume(totalTrades) - return { - code, - referredCount: traders.length.toString(), - referralVolume: totalVolume.toString(), - earnedRewards: totalRewards.toString(), + return { + code, + referredCount: traders.length.toString(), + referralVolume: totalVolume.toString(), + earnedRewards: totalRewards.toString(), + } + } else { + return { + code, + referredCount: '0', + referralVolume: '0', + earnedRewards: totalRewards.toString(), + } } }) ) diff --git a/packages/sdk/src/utils/staking.ts b/packages/sdk/src/utils/staking.ts index 9610bce83..fcd6b7b28 100644 --- a/packages/sdk/src/utils/staking.ts +++ b/packages/sdk/src/utils/staking.ts @@ -5,6 +5,7 @@ import { DECAY_RATE, EPOCH_START, INITIAL_WEEKLY_SUPPLY, + STAKING_ENDPOINTS, STAKING_REWARDS_RATIO, SUPPLY_RATE, WEEK, @@ -36,3 +37,7 @@ export const parseEpochData = (index: number, networkId?: NetworkId) => { const label = `Epoch ${index}: ${startDate} - ${endDate}` return { period: index, start: epochStart, end: epochEnd, label } } + +export const getStakingGqlEndpoint = (networkId: number) => { + return STAKING_ENDPOINTS[networkId] || STAKING_ENDPOINTS[10] +}