diff --git a/.gitignore b/.gitignore index 417c6cea02..d7d797f687 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ -.vscode/ \ No newline at end of file +.vscode/ +.DS_Store diff --git a/packages/app/src/__tests__/pages/smartMargin.test.tsx b/packages/app/src/__tests__/pages/smartMargin.test.tsx index be555addbd..245f9cfc8c 100644 --- a/packages/app/src/__tests__/pages/smartMargin.test.tsx +++ b/packages/app/src/__tests__/pages/smartMargin.test.tsx @@ -155,6 +155,18 @@ describe('Futures market page - smart margin', () => { keeperEthBal: wei('0.1'), walletEthBal: wei('1'), allowance: wei('1000'), + balances: { + SUSD: wei('100000'), + USDC: wei('100000'), + DAI: wei('100000'), + // LUSD: wei('100000'), + }, + allowances: { + SUSD: wei('100000'), + USDC: wei('100000'), + DAI: wei('100000'), + // LUSD: wei('100000'), + }, }) const store = setupStore(preloadedStateWithSmartMarginAccount(mockSmartMarginAccount('100000'))) diff --git a/packages/app/src/assets/png/tokens/DAI.png b/packages/app/src/assets/png/tokens/DAI.png new file mode 100644 index 0000000000..3b0ef58bae Binary files /dev/null and b/packages/app/src/assets/png/tokens/DAI.png differ diff --git a/packages/app/src/assets/png/tokens/LUSD.png b/packages/app/src/assets/png/tokens/LUSD.png new file mode 100644 index 0000000000..17d8dfbc26 Binary files /dev/null and b/packages/app/src/assets/png/tokens/LUSD.png differ diff --git a/packages/app/src/assets/png/tokens/USDC.png b/packages/app/src/assets/png/tokens/USDC.png new file mode 100644 index 0000000000..84bb66f206 Binary files /dev/null and b/packages/app/src/assets/png/tokens/USDC.png differ diff --git a/packages/app/src/assets/png/tokens/USDT.png b/packages/app/src/assets/png/tokens/USDT.png new file mode 100644 index 0000000000..89204de302 Binary files /dev/null and b/packages/app/src/assets/png/tokens/USDT.png differ diff --git a/packages/app/src/assets/png/tokens/sUSD-Black.png b/packages/app/src/assets/png/tokens/sUSD-Black.png new file mode 100644 index 0000000000..ecd856a7b9 Binary files /dev/null and b/packages/app/src/assets/png/tokens/sUSD-Black.png differ diff --git a/packages/app/src/assets/png/tokens/sUSD-White.png b/packages/app/src/assets/png/tokens/sUSD-White.png new file mode 100644 index 0000000000..81a85b52dd Binary files /dev/null and b/packages/app/src/assets/png/tokens/sUSD-White.png differ diff --git a/packages/app/src/components/BigToggle.tsx b/packages/app/src/components/BigToggle.tsx new file mode 100644 index 0000000000..a2f0975387 --- /dev/null +++ b/packages/app/src/components/BigToggle.tsx @@ -0,0 +1,116 @@ +import Image from 'next/image' +import { useCallback, useReducer } from 'react' +import styled, { css } from 'styled-components' + +import { StyledCaretDownIcon } from './Select' + +type BigToggleProps = { + value: T + options: T[] + onOptionClick(value: T): void + iconMap?: Record +} + +const BigToggle = ({ + options, + value, + onOptionClick, + iconMap, +}: BigToggleProps) => { + const [expanded, toggleExpanded] = useReducer((s) => !s, false) + + const handleOptionClick = useCallback( + (option: T) => () => { + onOptionClick(option) + toggleExpanded() + }, + [onOptionClick] + ) + + return ( + + + {iconMap?.[value] && {value}} + {value} + + + {expanded && ( + + {options + .filter((o) => o !== value) + .map((option) => ( + + {iconMap?.[option] && ( + {option} + )} + {option} + + ))} + + )} + + ) +} + +const BigToggleContainer = styled.div` + position: relative; +` + +const BigToggleButton = styled.button` + display: flex; + align-items: center; + border-radius: 40px; + padding: 7px; + font-size: 16px; + cursor: pointer; + line-height: 1; + + img { + margin-right: 7px; + } + + ${(props) => css` + border: ${props.theme.colors.selectedTheme.newTheme.button.default.border}; + background-color: ${props.theme.colors.selectedTheme.newTheme.button.default.background}; + color: ${props.theme.colors.selectedTheme.newTheme.button.default.color}; + `} +` + +const BigToggleListContainer = styled.div` + position: absolute; + top: 40px; + border: ${(props) => props.theme.colors.selectedTheme.newTheme.button.default.border}; + border-radius: 8px; + overflow: hidden; + width: 100%; +` + +const BigToggleListOption = styled.button` + border: none; + width: 100%; + padding: 7px; + cursor: pointer; + line-height: 1; + display: flex; + + img { + margin-right: 7px; + } + + ${(props) => css` + background-color: ${props.theme.colors.selectedTheme.newTheme.button.default.background}; + color: ${props.theme.colors.selectedTheme.newTheme.button.default.color}; + font-size: 16px; + + &:not(:last-of-type) { + border-bottom: ${props.theme.colors.selectedTheme.newTheme.button.default.border}; + } + + &:hover { + background-color: ${props.theme.colors.selectedTheme.newTheme.button.default.hover + .background}; + } + `} +` + +export default BigToggle diff --git a/packages/app/src/components/InfoBox.tsx b/packages/app/src/components/InfoBox.tsx index 7d081cad0c..5a7e5a1ef3 100644 --- a/packages/app/src/components/InfoBox.tsx +++ b/packages/app/src/components/InfoBox.tsx @@ -6,6 +6,8 @@ import { Body } from 'components/Text' import { BodyProps } from 'components/Text/Body' import { NO_VALUE } from 'constants/placeholder' +import { FlexDivRow } from './layout/flex' + type InfoBoxRowProps = { children?: React.ReactNode title: string @@ -52,9 +54,10 @@ export const InfoBoxRow: FC = memo( $isSubItem={isSubItem} onClick={expandable ? () => onToggleExpand?.(title) : undefined} > - - {title}: {keyNode} {expandable ? expanded ? : : null} - + + {title}: + {keyNode} {expandable ? expanded ? : : null} + {nodeValue ? ( nodeValue ) : ( @@ -98,6 +101,10 @@ export const InfoBoxContainer = styled.div` padding: 12px 14px; ` +const InfoBoxKeyContainer = styled(FlexDivRow)` + align-items: center; +` + const InfoBoxKey = styled(Body)` color: ${(props) => props.theme.colors.selectedTheme.text.label}; text-transform: capitalize; diff --git a/packages/app/src/components/Input/NumericInput.tsx b/packages/app/src/components/Input/NumericInput.tsx index 57bdbf5763..07cec7797d 100644 --- a/packages/app/src/components/Input/NumericInput.tsx +++ b/packages/app/src/components/Input/NumericInput.tsx @@ -2,6 +2,7 @@ import { FC, memo, useCallback } from 'react' import styled, { css } from 'styled-components' import Spacer from 'components/Spacer' +import { isInvalidNumber } from 'utils/input' type NumericInputProps = Omit< React.InputHTMLAttributes, @@ -22,10 +23,6 @@ type NumericInputProps = Omit< noShadow?: boolean } -const INVALID_CHARS = ['-', '+', 'e'] - -const isInvalid = (key: string) => INVALID_CHARS.includes(key) - const NumericInput: FC = memo( ({ value, @@ -86,7 +83,7 @@ const NumericInput: FC = memo( inputMode="decimal" onChange={handleChange} onKeyDown={(e) => { - if (isInvalid(e.key)) { + if (isInvalidNumber(e.key)) { e.preventDefault() } }} diff --git a/packages/app/src/components/Nav/NavLink.tsx b/packages/app/src/components/Nav/NavLink.tsx index fc9840c1fe..b541088f3c 100644 --- a/packages/app/src/components/Nav/NavLink.tsx +++ b/packages/app/src/components/Nav/NavLink.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import React, { ReactNode } from 'react' -import styled from 'styled-components' +import styled, { css } from 'styled-components' import LinkIconLight from 'assets/svg/app/link-light.svg' import { FlexDivRowCentered } from 'components/layout/flex' @@ -31,17 +31,15 @@ const NavButton: React.FC = ({ title, href, external, disabled, ) : ( - - - {title} - - + + {title} + )} ) } -const StyledLink = styled.a<{ isActive: boolean; disabled?: boolean }>` +const StyledLink = styled(Link)<{ isActive: boolean; disabled?: boolean }>` ${linkCSS}; display: inline-block; padding: 10px 14px; @@ -60,11 +58,13 @@ const StyledLink = styled.a<{ isActive: boolean; disabled?: boolean }>` background: ${(props) => props.theme.colors.selectedTheme.button.fill}; } - &.disabled { - color: ${(props) => props.theme.colors.selectedTheme.button.disabled.text}; - background: transparent; - pointer-events: none; - } + ${(props) => + props.disabled && + css` + color: ${props.theme.colors.selectedTheme.button.disabled.text}; + background: transparent; + pointer-events: none; + `} ` export default NavButton diff --git a/packages/app/src/components/SmallToggle.tsx b/packages/app/src/components/SmallToggle.tsx new file mode 100644 index 0000000000..699bd55ec3 --- /dev/null +++ b/packages/app/src/components/SmallToggle.tsx @@ -0,0 +1,123 @@ +import Image from 'next/image' +import { useCallback, useReducer } from 'react' +import styled from 'styled-components' + +import { StyledCaretDownIcon } from 'components/Select' +import { HOURS_TOGGLE_HEIGHT, zIndex } from 'constants/ui' + +type SmallToggleProps = { + value: T + options: T[] + getLabelByValue?: (value: T) => string + iconMap?: Record + onOptionClick: (value: T) => void +} + +const SmallToggle = ({ + value, + options, + getLabelByValue, + iconMap, + onOptionClick, +}: SmallToggleProps) => { + const [open, toggleOpen] = useReducer((o) => !o, false) + + const handleOptionClick = useCallback( + (option: T) => () => { + onOptionClick(option) + toggleOpen() + }, + [onOptionClick, toggleOpen] + ) + + return ( + + + + {iconMap?.[value] && {value}} + {getLabelByValue?.(value) ?? value} + + + {open && ( + + {options + .filter((o) => o !== value) + .map((option) => ( + + {iconMap?.[option] && ( + {value} + )} + {option} + + ))} + + )} + + + ) +} + +const ToggleTableRow = styled.div` + display: flex; + align-items: center; + margin: auto; + padding: 1.5px 6px; + height: ${HOURS_TOGGLE_HEIGHT}; + background: ${(props) => props.theme.colors.selectedTheme.newTheme.pill.gray.background}; + + :last-child { + border-radius: 0px 0px 9px 9px; + } + + img { + margin-right: 3px; + } + + :hover { + color: ${(props) => props.theme.colors.selectedTheme.newTheme.text.primary}; + background: ${(props) => + props.theme.colors.selectedTheme.newTheme.pill['gray'].hover.background}; + :last-child { + border-radius: 0px 0px 9px 9px; + } + } +` + +const ToggleTableRows = styled.div` + width: 100%; + position: absolute; + top: 20px; + color: ${(props) => props.theme.colors.selectedTheme.newTheme.text.secondary}; + z-index: ${zIndex.HEADER}; +` + +const ToggleTableHeader = styled.div` + display: flex; + justify-content: space-evenly; + align-items: center; + padding: 3px 5px; + font-size: 12px; + + img { + margin-right: 3px; + } +` + +const ToggleTable = styled.div<{ $open?: boolean }>` + display: flex; + flex-direction: column; + background: ${(props) => props.theme.colors.selectedTheme.newTheme.pill['gray'].background}; + color: ${(props) => props.theme.colors.selectedTheme.newTheme.text.primary}; + border-radius: 9px; + font-size: 12px; + font-family: ${(props) => props.theme.fonts.bold}; + ${(props) => props.$open && `border-radius: 9px 9px 0px 0px;`} +` + +const ToggleContainer = styled.div<{ open: boolean }>` + margin-left: 8px; + cursor: pointer; + position: relative; +` + +export default SmallToggle diff --git a/packages/app/src/components/SplitSelect.tsx b/packages/app/src/components/SplitSelect.tsx new file mode 100644 index 0000000000..df67b8dfc7 --- /dev/null +++ b/packages/app/src/components/SplitSelect.tsx @@ -0,0 +1,125 @@ +import styled, { css } from 'styled-components' + +import { isInvalidNumber } from 'utils/input' + +type SplitSelectOptionInput = { + type: 'input' + value?: string + onChange(value: string): void +} + +type SplitSelectOptionButton = { + type: 'button' + value: T +} + +type SplitSelectProps = { + selected: T | undefined + options: (SplitSelectOptionButton | SplitSelectOptionInput)[] + onSelect(option?: T): void + formatOption?: (option: T) => string + disabled?: boolean +} + +const SplitSelect = ({ + selected, + options, + onSelect, + formatOption, + disabled, +}: SplitSelectProps) => { + return ( + + {options.map((o) => { + return o.type === 'button' ? ( + onSelect(o.value)} + disabled={disabled} + $selected={selected === o.value} + > + {formatOption?.(o.value) ?? o.value} + + ) : ( + o.onChange(e.target.value)} + disabled={disabled} + placeholder="Custom" + type="number" + step="0.0001" + onFocus={() => onSelect(undefined)} + onBlur={(e) => { + if (!e.target.value) { + onSelect(options[0].value as T) + } + }} + onKeyDown={(e) => { + if (isInvalidNumber(e.key)) { + e.preventDefault() + } + }} + /> + ) + })} + + ) +} + +const SplitSelectInput = styled.input` + background-color: transparent; + border: none; + color: ${(props) => props.theme.colors.selectedTheme.newTheme.text.primary}; + text-align: center; + width: 100%; + + &:focus { + outline: none; + &::placeholder { + color: transparent; + } + background: ${(props) => props.theme.colors.selectedTheme.newTheme.button.default.background}; + } +` + +const SplitSelectContainer = styled.div<{ $length: number }>` + width: 100%; + display: grid; + grid-gap: 1px; + border-radius: 8px; + height: 26px; + overflow: hidden; + margin-bottom: 30px; + + ${(props) => css` + grid-template-columns: repeat(${props.$length}, 1fr); + border: ${props.theme.colors.selectedTheme.border}; + background-color: ${props.theme.colors.selectedTheme.newTheme.exchange.ratioSelect.background}; + `} +` + +const SplitSelectButton = styled.button<{ $selected: boolean }>` + height: 100%; + border: none; + font-size: 13px; + background-color: transparent; + text-align: center; + cursor: pointer; + + ${(props) => css` + font-family: ${props.theme.fonts.regular}; + color: ${props.theme.colors.selectedTheme.text.value}; + + &:not(:last-child) { + border-right: ${props.theme.colors.selectedTheme.border}; + } + + ${props.$selected && + css` + color: ${props.theme.colors.selectedTheme.white}; + background: ${props.theme.colors.selectedTheme.newTheme.button.default.background}; + `} + `} +` + +export default SplitSelect diff --git a/packages/app/src/components/Table/Table.tsx b/packages/app/src/components/Table/Table.tsx index 0a57ce7455..683be86da4 100644 --- a/packages/app/src/components/Table/Table.tsx +++ b/packages/app/src/components/Table/Table.tsx @@ -282,7 +282,7 @@ const NoResultsContainer = styled(Body)` padding: 50px 0; ` -const LoadingContainer = styled(Body)` +const LoadingContainer = styled.div` padding: 100px 0; ` diff --git a/packages/app/src/constants/defaults.ts b/packages/app/src/constants/defaults.ts index 1f263e2057..1d47a32d7d 100644 --- a/packages/app/src/constants/defaults.ts +++ b/packages/app/src/constants/defaults.ts @@ -41,3 +41,5 @@ export const ORDERS_WARNING_DISABLED = true export const DEFAULT_FUTURES_MARGIN_TYPE = FuturesMarginType.SMART_MARGIN export const DEFAULT_LEVERAGE = '1' + +export const SWAP_QUOTE_BUFFER = 0.2 diff --git a/packages/app/src/constants/ui.ts b/packages/app/src/constants/ui.ts index 47892e15c1..2cf53ed5f1 100644 --- a/packages/app/src/constants/ui.ts +++ b/packages/app/src/constants/ui.ts @@ -17,3 +17,6 @@ export enum zIndex { } export const STAKING_DISABLED = false + +// This flag controls the one-click swap-deposit-trade feature +export const SWAP_DEPOSIT_TRADE_ENABLED = false diff --git a/packages/app/src/pages/market.tsx b/packages/app/src/pages/market.tsx index 7be4e4fb44..54fa92e01b 100644 --- a/packages/app/src/pages/market.tsx +++ b/packages/app/src/pages/market.tsx @@ -41,6 +41,7 @@ import { usePollMarketFuturesData } from 'state/futures/hooks' import { setFuturesAccountType } from 'state/futures/reducer' import { setMarketAsset } from 'state/futures/smartMargin/reducer' import { + selectMaxTokenBalance, selectShowSmartMarginOnboard, selectSmartMarginAccount, selectSmartMarginAccountQueryStatus, @@ -69,6 +70,7 @@ const Market: MarketComponent = () => { const accountType = useAppSelector(selectFuturesType) const selectedMarketAsset = useAppSelector(selectMarketAsset) const crossMarginSupportedNetwork = useAppSelector(selectCrossMarginSupportedNetwork) + const maxTokenBalance = useAppSelector(selectMaxTokenBalance) const routerReferralCode = (router.query.ref as string)?.toLowerCase() const isReferralCodeValid = useAppSelector(selectIsReferralCodeValid) @@ -87,14 +89,12 @@ const Market: MarketComponent = () => { if (router.isReady && accountType !== routerAccountType) { dispatch(setFuturesAccountType(routerAccountType)) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router.isReady, routerAccountType]) + }, [dispatch, accountType, router.isReady, routerAccountType]) useEffect(() => { dispatch(clearTradeInputs()) // Clear trade state when switching address - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletAddress]) + }, [dispatch, walletAddress]) useEffect(() => { if ( @@ -162,7 +162,10 @@ const Market: MarketComponent = () => { )} {openModal === 'futures_deposit_withdraw_smart_margin' && ( - + )} {openModal === 'futures_confirm_smart_margin_trade' && } diff --git a/packages/app/src/sections/exchange/MobileSwap/RatioSelect.tsx b/packages/app/src/sections/exchange/MobileSwap/RatioSelect.tsx index 1558b82e70..035b2ef8f8 100644 --- a/packages/app/src/sections/exchange/MobileSwap/RatioSelect.tsx +++ b/packages/app/src/sections/exchange/MobileSwap/RatioSelect.tsx @@ -1,6 +1,6 @@ import { FC, useCallback } from 'react' -import styled, { css } from 'styled-components' +import SplitSelect from 'components/SplitSelect' import { setRatio } from 'state/exchange/actions' import { selectQuoteBalanceWei } from 'state/exchange/selectors' import type { SwapRatio } from 'state/exchange/types' @@ -8,6 +8,8 @@ import { useAppDispatch, useAppSelector } from 'state/hooks' const RATIOS: SwapRatio[] = [25, 50, 75, 100] +const RATIO_OPTIONS = RATIOS.map((r) => ({ type: 'button' as const, value: r })) + const RatioSelect: FC = () => { const ratio = useAppSelector(({ exchange }) => exchange.ratio) const dispatch = useAppDispatch() @@ -21,60 +23,14 @@ const RatioSelect: FC = () => { ) return ( - - {RATIOS.map((v) => ( - onRatioChange(v)} - disabled={quoteBalance.eq(0)} - > - {`${v}%`} - - ))} - + `${o}%`} + onSelect={onRatioChange} + disabled={quoteBalance.eq(0)} + /> ) } -const RatioSelectContainer = styled.div` - width: 100%; - display: grid; - grid-template-columns: repeat(4, 1fr); - grid-gap: 1px; - border-radius: 10px; - border-top: ${(props) => props.theme.colors.selectedTheme.border}; - border-left: ${(props) => props.theme.colors.selectedTheme.border}; - height: 30px; - overflow: hidden; - margin-bottom: 30px; - background-color: ${(props) => - props.theme.colors.selectedTheme.newTheme.exchange.ratioSelect.background}; -` - -const RatioButton = styled.button<{ $selected: boolean }>` - height: 100%; - border: none; - border-right: ${(props) => props.theme.colors.selectedTheme.border}; - border-bottom: ${(props) => props.theme.colors.selectedTheme.border}; - font-family: ${(props) => props.theme.fonts.regular}; - font-size: 13px; - color: ${(props) => props.theme.colors.selectedTheme.text.value}; - background-color: transparent; - - ${(props) => - props.$selected && - css` - color: ${(props) => props.theme.colors.selectedTheme.white}; - background: ${(props) => props.theme.colors.selectedTheme.button.hover}; - `} - - &:first-of-type { - border-radius: 10px 0 0 10px; - } - - &:last-of-type { - border-radius: 0 10px 10px 0; - } -` - export default RatioSelect diff --git a/packages/app/src/sections/futures/EditPositionModal/EditPositionSizeModal.tsx b/packages/app/src/sections/futures/EditPositionModal/EditPositionSizeModal.tsx index 5a9e964853..94dbdadd3e 100644 --- a/packages/app/src/sections/futures/EditPositionModal/EditPositionSizeModal.tsx +++ b/packages/app/src/sections/futures/EditPositionModal/EditPositionSizeModal.tsx @@ -49,7 +49,7 @@ export default function EditPositionSizeModal() { const [overridePriceProtection, setOverridePriceProtection] = useState(false) const [editType, setEditType] = useState(0) - const activePostiion = useMemo(() => position?.activePosition, [position?.activePosition]) + const activePosition = useMemo(() => position?.activePosition, [position?.activePosition]) useEffect(() => { dispatch(clearTradeInputs()) @@ -87,14 +87,14 @@ export default function EditPositionSizeModal() { const maxNativeIncreaseValue = useMemo(() => { if (!marketPrice || marketPrice.eq(0)) return ZERO_WEI const totalMax = position?.remainingMargin?.mul(maxLeverage) ?? ZERO_WEI - let max = totalMax.sub(activePostiion?.notionalValue ?? 0) + let max = totalMax.sub(activePosition?.notionalValue ?? 0) max = max.gt(0) ? max : ZERO_WEI return max.div(marketPrice) - }, [marketPrice, position?.remainingMargin, activePostiion?.notionalValue, maxLeverage]) + }, [marketPrice, position?.remainingMargin, activePosition?.notionalValue, maxLeverage]) const maxNativeValue = useMemo(() => { - return editType === 0 ? maxNativeIncreaseValue : activePostiion?.size ?? ZERO_WEI - }, [editType, maxNativeIncreaseValue, activePostiion?.size]) + return editType === 0 ? maxNativeIncreaseValue : activePosition?.size ?? ZERO_WEI + }, [editType, maxNativeIncreaseValue, activePosition?.size]) const minNativeValue = useMemo(() => { if (editType === 0) return ZERO_WEI @@ -119,10 +119,10 @@ export default function EditPositionSizeModal() { const maxLeverageExceeded = useMemo(() => { return ( - (editType === 0 && activePostiion?.leverage?.gt(maxLeverage)) || + (editType === 0 && activePosition?.leverage?.gt(maxLeverage)) || (editType === 1 && resultingLeverage?.gt(maxLeverage)) ) - }, [editType, activePostiion?.leverage, maxLeverage, resultingLeverage]) + }, [editType, activePosition?.leverage, maxLeverage, resultingLeverage]) const invalid = useMemo( () => sizeWei.abs().gt(maxNativeValueWithBuffer), @@ -193,7 +193,7 @@ export default function EditPositionSizeModal() { ) } title={t('futures.market.trade.edit-position.leverage-change')} - textValue={activePostiion?.leverage ? activePostiion.leverage.toString(2) + 'x' : '-'} + textValue={activePosition?.leverage ? activePosition.leverage.toString(2) + 'x' : '-'} /> diff --git a/packages/app/src/sections/futures/MarketDetails/HoursToggle.tsx b/packages/app/src/sections/futures/MarketDetails/HoursToggle.tsx index e09d18102e..9641e0819f 100644 --- a/packages/app/src/sections/futures/MarketDetails/HoursToggle.tsx +++ b/packages/app/src/sections/futures/MarketDetails/HoursToggle.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useReducer } from 'react' import styled from 'styled-components' import { StyledCaretDownIcon } from 'components/Select' @@ -9,25 +9,28 @@ import { selectSelectedInputHours } from 'state/futures/selectors' import { useAppDispatch, useAppSelector } from 'state/hooks' import media from 'styles/media' +// TODO: This component should be standardized and moved to the components folder. +// We should also consider using react-select for this. + +const getLabelByValue = (value: number) => FUNDING_RATE_PERIODS[value] ?? '1H' + const HoursToggle: React.FC = () => { const dispatch = useAppDispatch() const fundingHours = useAppSelector(selectSelectedInputHours) - const [open, setOpen] = useState(false) - const getLabelByValue = (value: number): string => FUNDING_RATE_PERIODS[value] ?? '1H' + const [open, toggleOpen] = useReducer((o) => !o, false) + const updatePeriod = useCallback( (v: number) => { dispatch(setSelectedInputFundingRateHour(v)) - setOpen(!open) + toggleOpen() }, - [dispatch, open] + [dispatch] ) + return ( - setOpen(!open)} - > + {getLabelByValue(fundingHours)} @@ -44,7 +47,7 @@ const HoursToggle: React.FC = () => { ) } -// solid ${(props) => props.theme.colors.selectedTheme.newTheme.pill['gray'].border}; + const ToggleTableRow = styled.div` margin: auto; padding: 1.5px 6px; @@ -55,6 +58,7 @@ const ToggleTableRow = styled.div` :last-child { border-radius: 0px 0px 9px 9px; } + :hover { color: ${(props) => props.theme.colors.selectedTheme.newTheme.text.primary}; background: ${(props) => diff --git a/packages/app/src/sections/futures/MobileTrade/UserTabs/PositionsTab.tsx b/packages/app/src/sections/futures/MobileTrade/UserTabs/PositionsTab.tsx index e1e7242074..672b37df16 100644 --- a/packages/app/src/sections/futures/MobileTrade/UserTabs/PositionsTab.tsx +++ b/packages/app/src/sections/futures/MobileTrade/UserTabs/PositionsTab.tsx @@ -108,13 +108,11 @@ const PositionsTab = () => {
{row.market.marketName} - - - {formatDollars(row.marketPrice, { - suggestDecimals: true, - })} - - + + {formatDollars(row.marketPrice, { + suggestDecimals: true, + })} +
diff --git a/packages/app/src/sections/futures/SmartMarginOnboard/SmartMarginOnboard.tsx b/packages/app/src/sections/futures/SmartMarginOnboard/SmartMarginOnboard.tsx index 1c95f3d0dd..99072bb658 100644 --- a/packages/app/src/sections/futures/SmartMarginOnboard/SmartMarginOnboard.tsx +++ b/packages/app/src/sections/futures/SmartMarginOnboard/SmartMarginOnboard.tsx @@ -21,6 +21,8 @@ import { import { useAppDispatch, useAppSelector } from 'state/hooks' import { FetchStatus } from 'state/types' +import { DepositTab } from '../Trade/TransferSmartMarginModal' + import SmartMarginFAQ from './SmartMarginFAQ' type Props = { @@ -106,39 +108,18 @@ export default function SmartMarginOnboard({ isOpen }: Props) { ) } - // TODO: Replace with bridge option - - // if (crossMarginAccount) { - // return ( - // <> - // {t('futures.modals.onboard.step3-intro')} - // - // - // {renderProgress(3)} - // {isDepositDisabled && ( - // - // {t('futures.market.trade.margin.modal.deposit.disclaimer')} - // - // )} - // - // {txProcessing ? : 'Deposit sUSD'} - // - // - // ); - // } + if (smartMarginAccount) { + return ( + <> + {t('futures.modals.onboard.step3-intro')} + + + ) + } return ( <> - {t('futures.modals.onboard.cm-intro')} + {t('futures.modals.onboard.sm-intro')}
FAQ: @@ -155,7 +136,7 @@ export default function SmartMarginOnboard({ isOpen }: Props) { {renderContent()} diff --git a/packages/app/src/sections/futures/SwapSlippageSelect.tsx b/packages/app/src/sections/futures/SwapSlippageSelect.tsx new file mode 100644 index 0000000000..b82c73d36d --- /dev/null +++ b/packages/app/src/sections/futures/SwapSlippageSelect.tsx @@ -0,0 +1,54 @@ +import { useCallback, useMemo } from 'react' + +import SplitSelect from 'components/SplitSelect' +import { + setSwapDepositCustomSlippage, + setSwapDepositSlippage, +} from 'state/futures/smartMargin/reducer' +import { selectSwapDepositSlippage } from 'state/futures/smartMargin/selectors' +import { useAppDispatch, useAppSelector } from 'state/hooks' + +const SLIPPAGE_OPTIONS = [0.15, 0.25] + +const FORMATTED_SLIPPAGE_OPTIONS = SLIPPAGE_OPTIONS.map((s) => ({ + type: 'button' as const, + value: s, +})) + +const SwapSlippageSelect = () => { + const dispatch = useAppDispatch() + const slippage = useAppSelector(selectSwapDepositSlippage) + + const handleSelectSlippage = useCallback( + (slippage: number | undefined) => { + dispatch(setSwapDepositSlippage(slippage)) + }, + [dispatch] + ) + + const handleCustomSlippageChange = useCallback( + (s: string) => { + const standard = s.replace(/[^0-9.,]/g, '').replace(/,/g, '.') + dispatch(setSwapDepositCustomSlippage(standard)) + }, + [dispatch] + ) + + const options = useMemo(() => { + return [ + ...FORMATTED_SLIPPAGE_OPTIONS, + { type: 'input' as const, onChange: handleCustomSlippageChange }, + ] + }, [handleCustomSlippageChange]) + + return ( + `${o}%`} + /> + ) +} + +export default SwapSlippageSelect diff --git a/packages/app/src/sections/futures/Trade/ManagePosition.tsx b/packages/app/src/sections/futures/Trade/ManagePosition.tsx index 33f4d45e7c..a546d053a1 100644 --- a/packages/app/src/sections/futures/Trade/ManagePosition.tsx +++ b/packages/app/src/sections/futures/Trade/ManagePosition.tsx @@ -1,6 +1,8 @@ import { ZERO_WEI } from '@kwenta/sdk/constants' +import { MIN_MARGIN_AMOUNT } from '@kwenta/sdk/constants' import { FuturesMarginType } from '@kwenta/sdk/types' import { isZero } from '@kwenta/sdk/utils' +import { useConnectModal } from '@rainbow-me/rainbowkit' import { wei } from '@synthetixio/wei' import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -9,6 +11,7 @@ import styled from 'styled-components' import Button from 'components/Button' import { ERROR_MESSAGES } from 'components/ErrorNotifier' import Error from 'components/ErrorView' +import Connector from 'containers/Connector' import { previewErrorI18n } from 'queries/futures/constants' import { setOpenModal } from 'state/app/reducer' import { @@ -26,6 +29,7 @@ import { } from 'state/futures/selectors' import { selectIsMarketCapReached, + selectLockedMarginInMarkets, selectOrderType, selectPlaceOrderTranslationKey, selectSelectedSmartMarginPosition, @@ -33,6 +37,7 @@ import { selectSmartMarginLeverage, selectSmartMarginOrderPrice, selectSmartMarginTradeInputs, + selectTotalAvailableMargin, selectTradePreview, selectTradePreviewError, selectTradePreviewStatus, @@ -45,6 +50,8 @@ import { orderPriceInvalidLabel } from 'utils/futures' const ManagePosition: React.FC = () => { const { t } = useTranslation() const dispatch = useAppDispatch() + const { isWalletConnected } = Connector.useContainer() + const { openConnectModal } = useConnectModal() const { susdSize } = useAppSelector(selectSmartMarginTradeInputs) const maxLeverageValue = useAppSelector(selectMaxLeverage) @@ -66,6 +73,8 @@ const ManagePosition: React.FC = () => { const smartMarginAccount = useAppSelector(selectSmartMarginAccount) const position = useAppSelector(selectSelectedSmartMarginPosition) const sltpValidity = useAppSelector(selectTradePanelSLTPValidity) + const accountMargin = useAppSelector(selectTotalAvailableMargin) + const lockedMargin = useAppSelector(selectLockedMarginInMarkets) const orderError = useMemo(() => { if (previewError) return t(previewErrorI18n(previewError)) @@ -92,6 +101,27 @@ const ManagePosition: React.FC = () => { ) }, [selectedAccountType, smartMarginAccount, dispatch]) + const isDepositRequired = useMemo(() => { + return accountMargin.lt(MIN_MARGIN_AMOUNT) && lockedMargin.eq(0) + }, [accountMargin, lockedMargin]) + + const otherReason = useMemo(() => { + if (!isWalletConnected) { + return { key: 'futures.market.trade.button.connect-wallet', action: openConnectModal } + } else if (!smartMarginAccount) { + return { + key: 'futures.market.trade.button.create-account', + action: () => dispatch(setOpenModal('futures_smart_margin_onboard')), + } + } else if (isDepositRequired) { + return { + key: 'futures.market.trade.button.deposit-funds', + action: () => () => dispatch(setOpenModal('futures_deposit_withdraw_smart_margin')), + } + } + return undefined + }, [isWalletConnected, smartMarginAccount, isDepositRequired, dispatch, openConnectModal]) + const placeOrderDisabledReason = useMemo<{ message: string show?: 'warn' | 'error' @@ -207,16 +237,16 @@ const ManagePosition: React.FC = () => { noOutline fullWidth loading={previewStatus.status === FetchStatus.Loading} - variant={leverageSide} - disabled={!!placeOrderDisabledReason} - onClick={onSubmit} + variant={otherReason ? 'yellow' : leverageSide} + disabled={!otherReason && !!placeOrderDisabledReason} + onClick={otherReason?.action ?? onSubmit} > - {t(placeOrderTranslationKey)} + {t(otherReason?.key ?? placeOrderTranslationKey)}
- {placeOrderDisabledReason?.show ? ( + {!otherReason && placeOrderDisabledReason?.show ? ( import('../../../components/SocketBridge'), { const SmartMarginOnboardModal: React.FC = memo(({ onDismiss }) => { const { t } = useTranslation() - const susdBalance = useAppSelector(selectSusdBalance) + const susdBalance = useAppSelector(selectSNXUSDBalance) return ( void - expanded: boolean } -const BrdigeAndWithdrawButton: FC = ({ - modalType, - onPillClick, - expanded, -}) => { +const BridgeAndWithdrawButton: FC = ({ modalType }) => { const dispatch = useAppDispatch() return ( - - { - e.stopPropagation() - dispatch(setOpenModal(modalType)) - }} - /> - - - - + { + e.stopPropagation() + dispatch(setOpenModal(modalType)) + }} + /> ) } const TradeBalance = memo(() => { const { t } = useTranslation() const dispatch = useAppDispatch() + const [expanded, toggleExpanded] = useReducer((e) => !e, false) const { deviceType } = useWindowSize() const accountMargin = useAppSelector(selectTotalAvailableMargin) const lockedMargin = useAppSelector(selectLockedMarginInMarkets) const openModal = useAppSelector(selectShowModal) - - const [expanded, setExpanded] = useState(false) + const { isWalletConnected } = Connector.useContainer() + const { openConnectModal } = useConnectModal() + const smartMarginAccount = useAppSelector(selectSmartMarginAccount) const { isMobile, size } = useMemo(() => { const isMobile = deviceType === 'mobile' @@ -77,14 +70,47 @@ const TradeBalance = memo(() => { return accountMargin.lt(MIN_MARGIN_AMOUNT) && lockedMargin.eq(0) }, [accountMargin, lockedMargin]) - const onClickContainer = useCallback(() => { - setExpanded(!expanded) - }, [expanded]) + const dismissModal = useCallback(() => { + dispatch(setOpenModal(null)) + }, [dispatch]) return ( - - {isDepositRequired ? ( + + {!isWalletConnected ? ( + + + + {t('futures.market.trade.trade-balance.no-wallet-connected')} + + + {t('futures.market.trade.trade-balance.no-wallet-connected-detail')} + + + + + ) : !smartMarginAccount ? ( + + + + {t('futures.market.trade.trade-balance.no-smart-account')} + + + {t('futures.market.trade.trade-balance.no-smart-account-detail')} + + + + + ) : isDepositRequired ? ( @@ -98,7 +124,6 @@ const TradeBalance = memo(() => { /> )} - {t('futures.market.trade.trade-balance.min-margin')} @@ -109,16 +134,12 @@ const TradeBalance = memo(() => { variant="yellow" size="xsmall" textTransform="none" - onClick={() => dispatch(setOpenModal('futures_smart_margin_socket'))} + onClick={() => dispatch(setOpenModal('futures_deposit_withdraw_smart_margin'))} > {t('header.balance.get-susd')} ) : ( - + )} ) : ( @@ -127,7 +148,7 @@ const TradeBalance = memo(() => { - + {t('futures.market.trade.trade-balance.available-margin')}: @@ -136,11 +157,11 @@ const TradeBalance = memo(() => { {lockedMargin.gt(0) && ( - + {t('futures.market.trade.trade-balance.locked-margin')}: - + {formatDollars(lockedMargin)} { )} - + ) : ( - - - {t('futures.market.trade.trade-balance.available-margin')} - - - {formatDollars(accountMargin)} - - + + + + {t('futures.market.trade.trade-balance.available-margin')} + + + {formatDollars(accountMargin)} + + + + {lockedMargin.gt(0) && ( - + {t('futures.market.trade.trade-balance.locked-margin')} { - + {formatDollars(lockedMargin)} )} - + )} )} - {expanded && {}} + {isWalletConnected && smartMarginAccount && !isDepositRequired && expanded && ( + + + + )} + {openModal === 'futures_smart_margin_socket' && ( - { - dispatch(setOpenModal(null)) - }} - /> + )} ) @@ -227,13 +251,13 @@ const Container = styled.div<{ mobile?: boolean }>` border-bottom: ${(props) => (props.mobile ? props.theme.colors.selectedTheme.border : 0)}; ` -const BalanceContainer = styled(FlexDivRowCentered)<{ clickable: boolean }>` +const BalanceContainer = styled(FlexDivRowCentered)<{ clickable?: boolean }>` cursor: ${(props) => (props.clickable ? 'pointer' : 'default')}; width: 100%; ` const DetailsContainer = styled.div` - margin-top: 15px; + margin-top: 7.5px; ` export default TradeBalance diff --git a/packages/app/src/sections/futures/Trade/TradePanelSmartMargin.tsx b/packages/app/src/sections/futures/Trade/TradePanelSmartMargin.tsx index 97a2c4370a..905c0d4693 100644 --- a/packages/app/src/sections/futures/Trade/TradePanelSmartMargin.tsx +++ b/packages/app/src/sections/futures/Trade/TradePanelSmartMargin.tsx @@ -51,11 +51,7 @@ const TradePanelSmartMargin: FC = memo(({ mobile, closeDrawer }) => { useEffect(() => { if (hideOrderWarning) return - if (orderType !== 'market') { - setShowOrderWarning(true) - } else { - setShowOrderWarning(false) - } + setShowOrderWarning(orderType !== 'market') }, [orderType, hideOrderWarning]) return ( diff --git a/packages/app/src/sections/futures/Trade/TransferSmartMarginModal.tsx b/packages/app/src/sections/futures/Trade/TransferSmartMarginModal.tsx index f766079f53..979bdfd7b0 100644 --- a/packages/app/src/sections/futures/Trade/TransferSmartMarginModal.tsx +++ b/packages/app/src/sections/futures/Trade/TransferSmartMarginModal.tsx @@ -1,136 +1,290 @@ -import { formatDollars } from '@kwenta/sdk/utils' +import { formatNumber, truncateNumbers } from '@kwenta/sdk/utils' import { wei } from '@synthetixio/wei' import dynamic from 'next/dynamic' -import React, { useCallback, useMemo, useState } from 'react' +import React, { FC, useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import CaretUpIcon from 'assets/svg/app/caret-up-slim.svg' import BaseModal from 'components/BaseModal' import Button from 'components/Button' +import PencilButton from 'components/Button/PencilButton' import { CardHeader } from 'components/Card' import Error from 'components/ErrorView' import NumericInput from 'components/Input/NumericInput' import { FlexDivRowCentered } from 'components/layout/flex' import SegmentedControl from 'components/SegmentedControl' import Spacer from 'components/Spacer' +import { Body, NumericValue } from 'components/Text' +import { DEFAULT_CRYPTO_DECIMALS } from 'constants/defaults' import { selectTransaction } from 'state/app/selectors' -import { selectSusdBalance } from 'state/balances/selectors' -import { selectIsSubmittingCrossTransfer } from 'state/futures/selectors' -import { withdrawSmartMargin } from 'state/futures/smartMargin/actions' +import { setSelectedSwapDepositToken } from 'state/futures/reducer' +import { selectIsSubmittingCrossTransfer as selectIsSubmittingSmartTransfer } from 'state/futures/selectors' +import { + approveSmartMargin, + depositSmartMargin, + withdrawSmartMargin, +} from 'state/futures/smartMargin/actions' +import { + selectApprovingSwapDeposit, + selectMaxSwapToken, + selectSwapDepositAllowance, + selectSwapDepositBalance, + selectSwapDepositSlippage, +} from 'state/futures/smartMargin/selectors' import { selectWithdrawableSmartMargin } from 'state/futures/smartMargin/selectors' import { useAppDispatch, useAppSelector } from 'state/hooks' +import SwapSlippageSelect from '../SwapSlippageSelect' +import SwapDepositTokenSelector from '../TradeSmartMargin/SwapDepositTokenSelector' + type Props = { onDismiss(): void - defaultTab: 'deposit' | 'withdraw' + defaultTab: TransferType } const SocketBridge = dynamic(() => import('../../../components/SocketBridge'), { ssr: false, }) -const PLACEHOLDER = '$0.00' +const PLACEHOLDER = '0.00' -const TransferSmartMarginModal: React.FC = ({ onDismiss, defaultTab }) => { +type DepositInputProps = { + value: string + setValue(value: string): void +} + +const DepositInput: FC = ({ value, setValue }) => { + return ( + + setValue(v)} /> + + + ) +} + +type DepositTabProps = { + extra?: React.ReactNode +} + +export const DepositTab: FC = ({ extra }) => { const { t } = useTranslation() + const [amount, setAmount] = useState('') + const [slippageControlVisible, toggleSlippageControl] = useReducer((o) => !o, false) + const dispatch = useAppDispatch() - const submitting = useAppSelector(selectIsSubmittingCrossTransfer) - const totalWithdrawable = useAppSelector(selectWithdrawableSmartMargin) - const transactionState = useAppSelector(selectTransaction) - const susdBalance = useAppSelector(selectSusdBalance) + const balance = useAppSelector(selectSwapDepositBalance) + const allowance = useAppSelector(selectSwapDepositAllowance) + const submitting = useAppSelector(selectIsSubmittingSmartTransfer) + const approving = useAppSelector(selectApprovingSwapDeposit) + const swapDepositSlippage = useAppSelector(selectSwapDepositSlippage) + + const needsApproval = useMemo(() => { + const amtWei = wei(amount || 0) + return allowance.eq(0) || amtWei.gt(allowance) + }, [amount, allowance]) + + const isDisabled = useMemo(() => { + if (balance.eq(0)) { + return true + } else if (needsApproval) { + return approving + } else { + const amtWei = wei(amount || 0) + return submitting || amtWei.eq(0) || amtWei.gt(balance) + } + }, [amount, submitting, balance, needsApproval, approving]) + + const handleSubmit = useCallback(() => { + if (needsApproval) { + dispatch(approveSmartMargin()) + } else { + dispatch(depositSmartMargin(wei(amount))) + } + }, [dispatch, amount, needsApproval]) + + const buttonKey = useMemo(() => { + return needsApproval ? 'approve' : approving ? 'approving' : 'deposit' + }, [needsApproval, approving]) + + const handleSetMax = useCallback(() => { + setAmount(truncateNumbers(balance, DEFAULT_CRYPTO_DECIMALS)) + }, [balance]) + + return ( + <> + + {t('futures.market.trade.margin.modal.deposit.amount')}: + + {t('futures.market.trade.margin.modal.balance')}:{' '} + + {formatNumber(balance)} + + + + + + + +
+ + {t('futures.market.trade.margin.modal.deposit.max-slippage')}: + + + {`${swapDepositSlippage}%`} + + +
+ + {slippageControlVisible && } + + {t('futures.market.trade.margin.modal.deposit.disclaimer')} + + + {extra && ( + <> + {extra} + + + )} + + + ) +} +const WithdrawTab = () => { + const { t } = useTranslation() + const dispatch = useAppDispatch() const [amount, setAmount] = useState('') - const [transferType, setTransferType] = useState(defaultTab === 'deposit' ? 0 : 1) + const totalWithdrawable = useAppSelector(selectWithdrawableSmartMargin) + const submitting = useAppSelector(selectIsSubmittingSmartTransfer) - const susdBal = transferType === 0 ? susdBalance : totalWithdrawable + const handleSetMax = useCallback(() => { + setAmount(truncateNumbers(totalWithdrawable, DEFAULT_CRYPTO_DECIMALS)) + }, [totalWithdrawable]) const isDisabled = useMemo(() => { const amtWei = wei(amount || 0) return submitting || amtWei.eq(0) || amtWei.gt(totalWithdrawable) }, [amount, submitting, totalWithdrawable]) - const handleSetMax = useCallback(() => { - if (transferType === 0) { - setAmount(susdBal.toString()) - } else { - setAmount(totalWithdrawable.toString()) - } - }, [transferType, susdBal, totalWithdrawable]) + const handleWithdraw = useCallback(() => { + dispatch(withdrawSmartMargin(wei(amount))) + }, [amount, dispatch]) - const onChangeTab = (selection: number) => { - setTransferType(selection) - setAmount('') - } + return ( + <> + + Amount + + {t('futures.market.trade.margin.modal.balance')}:{' '} + + {formatNumber(totalWithdrawable)} + + + - const onWithdraw = () => { - dispatch(withdrawSmartMargin(wei(amount))) - } + setAmount(v)} + right={ + {t('futures.market.trade.margin.modal.max')} + } + /> + + + + ) +} + +const BridgeTab = () => { + const { t } = useTranslation() + + return ( + <> + + {t('futures.market.trade.margin.modal.bridge.title')} + + + + + + ) +} + +// TODO: Map tranfer type to enum + +enum TransferType { + Deposit = 0, + Withdraw = 1, + Bridge = 2, +} + +const TransferTabMap = { + [TransferType.Deposit]: { + key: 'deposit', + component: , + }, + [TransferType.Withdraw]: { + key: 'withdraw', + component: , + }, + [TransferType.Bridge]: { + key: 'bridge', + component: , + }, +} as const + +const TransferSmartMarginModal: React.FC = ({ onDismiss, defaultTab }) => { + const { t } = useTranslation() + + const dispatch = useAppDispatch() + const transactionState = useAppSelector(selectTransaction) + const maxToken = useAppSelector(selectMaxSwapToken) + + const [transferType, setTransferType] = useState(defaultTab) + + useEffect(() => { + dispatch(setSelectedSwapDepositToken(maxToken)) + // eslint-disable-next-line + }, []) + + const onChangeTab = useCallback((selection: TransferType) => { + setTransferType(selection) + }, []) return ( - {transferType === 0 && ( - <> - - {t('futures.market.trade.margin.modal.bridge.title')} - - - - - - )} - - {t('futures.market.trade.margin.modal.balance')}: - - {formatDollars(susdBal)} sUSD - - - {transferType === 0 ? ( - <> - - - {t('futures.market.trade.margin.modal.deposit.disclaimer')} - - - ) : ( - <> - setAmount(v)} - right={ - - {t('futures.market.trade.margin.modal.max')} - - } - /> - - - - )} + + {TransferTabMap[transferType].component} {transactionState?.error && ( ` ` export const MaxButton = styled.button` - height: 22px; - padding: 4px 10px; + padding: 5px 10px; background: ${(props) => props.theme.colors.selectedTheme.button.background}; - border-radius: 11px; + border-radius: 15px; font-family: ${(props) => props.theme.fonts.mono}; - font-size: 13px; - line-height: 13px; + font-size: 12px; + line-height: 12px; border: ${(props) => props.theme.colors.selectedTheme.border}; color: ${(props) => props.theme.colors.selectedTheme.button.text.primary}; cursor: pointer; + font-variant: all-small-caps; ` const MinimumAmountDisclaimer = styled.div` - font-size: 12px; - color: ${(props) => props.theme.colors.selectedTheme.button.text.primary}; + padding: 10px; + border-radius: 8px; + background-color: ${(props) => props.theme.colors.selectedTheme.newTheme.disclaimer.background}; + color: ${(props) => props.theme.colors.selectedTheme.newTheme.disclaimer.color}; + font-size: 13px; text-align: center; ` @@ -204,4 +361,40 @@ const StyledCardHeader = styled(CardHeader)<{ noBorder: boolean }>` cursor: pointer; ` +const DepositInputMain = styled(NumericInput)` + border: 0; + padding: 0; + height: 23px; + background: transparent; + box-shadow: none; + + input { + font-size: 30px; + letter-spacing: -1px; + height: 30px; + width: 100%; + } +` + +const DepositInputContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + border: ${(props) => props.theme.colors.selectedTheme.newTheme.border.style}; + border-radius: 8px; + + input { + font-size: 19px; + font-family: ${(props) => props.theme.fonts.mono}; + color: ${(props) => props.theme.colors.selectedTheme.text.value}; + border: none; + background-color: transparent; + + &:focus { + outline: none; + } + } +` + export default TransferSmartMarginModal diff --git a/packages/app/src/sections/futures/TradeConfirmation/TradeConfirmationModal.tsx b/packages/app/src/sections/futures/TradeConfirmation/TradeConfirmationModal.tsx index c43af41a2d..6242e45ace 100644 --- a/packages/app/src/sections/futures/TradeConfirmation/TradeConfirmationModal.tsx +++ b/packages/app/src/sections/futures/TradeConfirmation/TradeConfirmationModal.tsx @@ -1,5 +1,5 @@ import { MIN_MARGIN_AMOUNT, ZERO_WEI } from '@kwenta/sdk/constants' -import { PositionSide } from '@kwenta/sdk/types' +import { PositionSide, SwapDepositToken } from '@kwenta/sdk/types' import { OrderNameByType, formatCurrency, @@ -9,7 +9,7 @@ import { stripZeros, } from '@kwenta/sdk/utils' import Wei, { wei } from '@synthetixio/wei' -import { useCallback, useMemo, useState } from 'react' +import { FC, useCallback, useMemo, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -17,10 +17,13 @@ import HelpIcon from 'assets/svg/app/question-mark.svg' import BaseModal from 'components/BaseModal' import Button from 'components/Button' import ErrorView from 'components/ErrorView' -import { ButtonLoader } from 'components/Loader' +import { FlexDivRowCentered } from 'components/layout/flex' +import { ButtonLoader, MiniLoader } from 'components/Loader' +import { StyledCaretDownIcon } from 'components/Select' import Spacer from 'components/Spacer' import Tooltip from 'components/Tooltip/Tooltip' import { NO_VALUE } from 'constants/placeholder' +import { SWAP_DEPOSIT_TRADE_ENABLED } from 'constants/ui' import { selectMarketAsset } from 'state/futures/common/selectors' import { selectLeverageSide, @@ -28,16 +31,27 @@ import { selectLeverageInput, selectTradePanelSLTPValidity, } from 'state/futures/selectors' -import { refetchTradePreview, submitSmartMarginOrder } from 'state/futures/smartMargin/actions' +import { + calculateTradeSwapDeposit, + refetchTradePreview, + submitSmartMarginOrder, +} from 'state/futures/smartMargin/actions' +import { clearTradeSwapDepositQuote } from 'state/futures/smartMargin/reducer' import { selectKeeperDepositExceedsBal, selectNewTradeHasSlTp, selectOrderType, selectSlTpTradeInputs, + selectSelectedSwapDepositToken, selectSmartMarginOrderPrice, selectTradePreview, + selectSmartMarginQueryStatuses, + selectTradeSwapDepositQuote, + selectSwapDepositQuoteLoading, + selectQuoteInvalidReason, } from 'state/futures/smartMargin/selectors' -import { useAppDispatch, useAppSelector, usePollAction } from 'state/hooks' +import { useAppDispatch, useAppSelector, useFetchAction, usePollAction } from 'state/hooks' +import { FetchStatus } from 'state/types' import AcceptWarningView from '../../../components/AcceptWarningView' @@ -71,14 +85,16 @@ export default function TradeConfirmationModal({ const marketAsset = useAppSelector(selectMarketAsset) const potentialTradeDetails = useAppSelector(selectTradePreview) const orderType = useAppSelector(selectOrderType) - const orderPrice = useAppSelector(selectSmartMarginOrderPrice) const position = useAppSelector(selectPosition) const leverageSide = useAppSelector(selectLeverageSide) const leverageInput = useAppSelector(selectLeverageInput) const ethBalanceExceeded = useAppSelector(selectKeeperDepositExceedsBal) - const { stopLossPrice, takeProfitPrice } = useAppSelector(selectSlTpTradeInputs) const hasSlTp = useAppSelector(selectNewTradeHasSlTp) const sltpValidity = useAppSelector(selectTradePanelSLTPValidity) + const quoteInvalidReason = useAppSelector(selectQuoteInvalidReason) + const quote = useAppSelector(selectTradeSwapDepositQuote) + const quoteLoading = useAppSelector(selectSwapDepositQuoteLoading) + const swapToken = useAppSelector(selectSelectedSwapDepositToken) const [overridePriceProtection, setOverridePriceProtection] = useState(false) const [acceptedSLRisk, setAcceptedSLRisk] = useState(false) @@ -87,10 +103,9 @@ export default function TradeConfirmationModal({ const onConfirmOrder = useCallback(() => dispatch(submitSmartMarginOrder(true)), [dispatch]) - const totalFee = useMemo( - () => potentialTradeDetails?.fee.add(executionFee) ?? executionFee, - [potentialTradeDetails?.fee, executionFee] - ) + useFetchAction(calculateTradeSwapDeposit, { + dependencies: [potentialTradeDetails?.margin.toString()], + }) const positionSide = useMemo(() => { if (potentialTradeDetails?.size.eq(ZERO_WEI)) { @@ -116,81 +131,6 @@ export default function TradeConfirmationModal({ : null }, [potentialTradeDetails, positionSide]) - const dataRows = useMemo( - () => [ - { - label: 'stop loss', - value: stopLossPrice ? formatDollars(stopLossPrice, { suggestDecimals: true }) : NO_VALUE, - }, - { - label: 'take profit', - value: takeProfitPrice - ? formatDollars(takeProfitPrice, { suggestDecimals: true }) - : NO_VALUE, - }, - { - label: 'liquidation price', - color: 'red', - value: formatDollars(positionDetails?.liqPrice ?? ZERO_WEI, { suggestDecimals: true }), - }, - { - label: 'resulting leverage', - value: `${formatNumber(positionDetails?.leverage ?? ZERO_WEI)}x`, - }, - { - label: 'resulting margin', - value: formatDollars(positionDetails?.margin ?? ZERO_WEI), - }, - orderType === 'limit' || orderType === 'stop_market' - ? { - label: OrderNameByType[orderType] + ' order price', - value: formatDollars(orderPrice, { suggestDecimals: true }), - } - : { - label: 'Est. fill price', - value: formatDollars(positionDetails?.price ?? ZERO_WEI, { suggestDecimals: true }), - }, - - { - label: 'price impact', - tooltipContent: t('futures.market.trade.delayed-order.description'), - value: `${formatPercent(potentialTradeDetails?.priceImpact ?? ZERO_WEI, { - suggestDecimals: true, - maxDecimals: 4, - })}`, - color: positionDetails?.exceedsPriceProtection ? 'red' : '', - }, - { - label: 'total fee', - value: formatDollars(totalFee), - }, - keeperFee - ? { - label: 'Keeper ETH deposit', - value: formatCurrency('ETH', keeperFee, { currencyKey: 'ETH' }), - } - : null, - gasFee && gasFee.gt(0) - ? { - label: 'network gas fee', - value: formatDollars(gasFee), - } - : null, - ], - [ - t, - positionDetails, - keeperFee, - gasFee, - totalFee, - orderType, - orderPrice, - potentialTradeDetails, - stopLossPrice, - takeProfitPrice, - ] - ) - const showEthBalWarning = useMemo(() => { return ethBalanceExceeded && (orderType !== 'market' || hasSlTp) }, [ethBalanceExceeded, orderType, hasSlTp]) @@ -199,7 +139,19 @@ export default function TradeConfirmationModal({ ? t('futures.market.trade.confirmation.modal.eth-bal-warning') : null + const quoteInvalidError = useMemo(() => { + return !!quoteInvalidReason + ? t(`futures.market.trade.confirmation.modal.quote-invalid-error-${quoteInvalidReason}`) + : null + }, [quoteInvalidReason, t]) + const disabledReason = useMemo(() => { + if (!!quoteInvalidReason) { + return t('futures.market.trade.confirmation.modal.disabled-quote-invalid') + } + if (!quote && quoteLoading) { + return t('futures.market.trade.confirmation.modal.disabled-quote-loading') + } if (showEthBalWarning) { return t('futures.market.trade.confirmation.modal.disabled-eth-bal', { depositAmount: formatNumber(stripZeros(keeperFee?.toString()), { suggestDecimals: true }), @@ -217,15 +169,23 @@ export default function TradeConfirmationModal({ keeperFee, overridePriceProtection, positionDetails?.exceedsPriceProtection, + quoteInvalidReason, + quote, + quoteLoading, ]) + const handleDismiss = useCallback(() => { + dispatch(clearTradeSwapDepositQuote()) + onDismiss() + }, [dispatch, onDismiss]) + const buttonText = allowanceValid ? t(`futures.market.trade.confirmation.modal.confirm-order.${leverageSide}`) - : t(`futures.market.trade.confirmation.modal.approve-order`) + : t(`futures.market.trade.confirmation.modal.approve-order`, { asset: swapToken }) return ( @@ -238,33 +198,20 @@ export default function TradeConfirmationModal({ leverage={wei(leverageInput || '0')} /> - {dataRows.map((row, i) => { - if (!row) return null - return ( - - {row.tooltipContent ? ( - - - - ) : ( - - )} - - - {row.value} - - - ) - })} + + + + + {orderType === 'limit' || orderType === 'stop_market' ? ( + + ) : ( + + )} + + + + + {SWAP_DEPOSIT_TRADE_ENABLED && } {positionDetails?.exceedsPriceProtection && ( )} @@ -309,6 +256,244 @@ export default function TradeConfirmationModal({ ) } +type DataRowProps = { + label: string + value: React.ReactNode + tooltipContent?: string + color?: string + expanded?: boolean + children?: React.ReactNode + onToggleExpand?: () => void +} + +const DataRow: FC = ({ + label, + value, + tooltipContent, + color, + expanded, + children, + onToggleExpand, +}) => { + return ( + <> + + {tooltipContent ? ( + + + + ) : ( + + + {onToggleExpand ? : null} + + )} + + + {value} + + + {expanded ? children : null} + + ) +} + +const SLTPRows = () => { + const { stopLossPrice, takeProfitPrice } = useAppSelector(selectSlTpTradeInputs) + + return ( + <> + + + + + ) +} + +const LiquidationPriceRow = () => { + const potentialTradeDetails = useAppSelector(selectTradePreview) + + return ( + + ) +} + +const ResultingLeverageRow = () => { + const potentialTradeDetails = useAppSelector(selectTradePreview) + + const leverage = potentialTradeDetails + ? potentialTradeDetails.margin.eq(ZERO_WEI) + ? ZERO_WEI + : potentialTradeDetails.size + .mul(potentialTradeDetails.price) + .div(potentialTradeDetails.margin) + .abs() + : null + + return +} + +const ResultingMarginRow = () => { + const potentialTradeDetails = useAppSelector(selectTradePreview) + + return ( + + ) +} + +const OrderPriceRow = () => { + const orderType = useAppSelector(selectOrderType) + const orderPrice = useAppSelector(selectSmartMarginOrderPrice) + + return ( + + ) +} + +const EstimatedFillPriceRow = () => { + const potentialTradeDetails = useAppSelector(selectTradePreview) + + return ( + + ) +} + +const PriceImpactRow = () => { + const { t } = useTranslation() + const potentialTradeDetails = useAppSelector(selectTradePreview) + + return ( + + ) +} + +type TotalFeeRowProps = { + executionFee: Wei +} + +const TotalFeeRow: FC = ({ executionFee }) => { + const potentialTradeDetails = useAppSelector(selectTradePreview) + + const totalFee = useMemo( + () => potentialTradeDetails?.fee.add(executionFee) ?? executionFee, + [potentialTradeDetails?.fee, executionFee] + ) + + return +} + +type KeeperFeeRowProps = { + keeperFee?: Wei | null +} + +const KeeperFeeRow: FC = ({ keeperFee }) => { + if (!keeperFee) return null + + return ( + + ) +} + +type GasFeeRowProps = { + gasFee?: Wei | null +} + +const GasFeeRow: FC = ({ gasFee }) => { + if (gasFee?.gt(0)) { + return + } + + return null +} + +const SwapRow = () => { + const swapDepositToken = useAppSelector(selectSelectedSwapDepositToken) + const [expanded, toggleExpanded] = useReducer((e) => !e, false) + + const { tradeSwapDepositQuote } = useAppSelector(selectSmartMarginQueryStatuses) + const quote = useAppSelector(selectTradeSwapDepositQuote) + + if (swapDepositToken === SwapDepositToken.SUSD) return null + + return ( + + ) : quote ? ( + `${formatNumber(quote?.amountIn ?? 0, { suggestDecimals: true })} ${ + quote?.token + } -> ${formatNumber(quote?.amountOut ?? 0, { suggestDecimals: true })} sUSD` + ) : ( + '-' + ) + } + > + + + ) +} + +const ExchangeRateRow = () => { + const swapDepositToken = useAppSelector(selectSelectedSwapDepositToken) + const quote = useAppSelector(selectTradeSwapDepositQuote) + const price = useMemo(() => { + if (!quote) return 0 + return quote.amountOut.div(quote.amountIn) + }, [quote]) + + return ( + + ) +} + const StyledBaseModal = styled(BaseModal)` [data-reach-dialog-content] { width: 400px; diff --git a/packages/app/src/sections/futures/TradeSmartMargin/SmartMarginInfoBox.tsx b/packages/app/src/sections/futures/TradeSmartMargin/SmartMarginInfoBox.tsx index 4562556717..b1c58f48ef 100644 --- a/packages/app/src/sections/futures/TradeSmartMargin/SmartMarginInfoBox.tsx +++ b/packages/app/src/sections/futures/TradeSmartMargin/SmartMarginInfoBox.tsx @@ -1,19 +1,21 @@ import { formatCurrency, formatDollars } from '@kwenta/sdk/utils' import React, { memo } from 'react' +import PencilButton from 'components/Button/PencilButton' import { InfoBoxRow } from 'components/InfoBox' +import { SWAP_DEPOSIT_TRADE_ENABLED } from 'constants/ui' import { setOpenModal } from 'state/app/reducer' import { selectShowModal } from 'state/app/selectors' import { selectSusdBalance } from 'state/balances/selectors' +import { selectSwapDepositBalanceQuote } from 'state/futures/smartMargin/selectors' import { selectAvailableMarginInMarkets, selectSmartMarginBalanceInfo, } from 'state/futures/smartMargin/selectors' import { useAppDispatch, useAppSelector } from 'state/hooks' -import PencilButton from '../../../components/Button/PencilButton' - import ManageKeeperBalanceModal from './ManageKeeperBalanceModal' +import SwapDepositTokenSelector from './SwapDepositTokenSelector' function SmartMarginInfoBox() { const dispatch = useAppDispatch() @@ -23,9 +25,22 @@ function SmartMarginInfoBox() { const { freeMargin } = useAppSelector(selectSmartMarginBalanceInfo) const idleMarginInMarkets = useAppSelector(selectAvailableMarginInMarkets) const walletBal = useAppSelector(selectSusdBalance) + const quotedBal = useAppSelector(selectSwapDepositBalanceQuote) return ( <> + : null} + textValue={formatDollars( + SWAP_DEPOSIT_TRADE_ENABLED && quotedBal?.susdQuote ? quotedBal.susdQuote : walletBal + )} + /> + + } /> - - {openModal === 'futures_withdraw_keeper_balance' && ( diff --git a/packages/app/src/sections/futures/TradeSmartMargin/SwapDepositTokenSelector.tsx b/packages/app/src/sections/futures/TradeSmartMargin/SwapDepositTokenSelector.tsx new file mode 100644 index 0000000000..b697537223 --- /dev/null +++ b/packages/app/src/sections/futures/TradeSmartMargin/SwapDepositTokenSelector.tsx @@ -0,0 +1,70 @@ +import { SWAP_DEPOSIT_TOKENS } from '@kwenta/sdk/constants' +import { SwapDepositToken } from '@kwenta/sdk/types' +import { FC, useCallback, useMemo } from 'react' + +import DAIIcon from 'assets/png/tokens/DAI.png' +// import LUSDIcon from 'assets/png/tokens/LUSD.png' +import SUSDBlackIcon from 'assets/png/tokens/sUSD-Black.png' +import SUSDWhiteIcon from 'assets/png/tokens/sUSD-White.png' +import USDCIcon from 'assets/png/tokens/USDC.png' +// import USDTIcon from 'assets/png/tokens/USDT.png' +import BigToggle from 'components/BigToggle' +import SmallToggle from 'components/SmallToggle' +import { setSelectedSwapDepositToken } from 'state/futures/reducer' +import { selectSelectedSwapDepositToken } from 'state/futures/smartMargin/selectors' +import { useAppDispatch, useAppSelector } from 'state/hooks' +import { selectCurrentTheme } from 'state/preferences/selectors' + +type SwapDepositTokenSelectorProps = { + small?: boolean +} + +const SwapDepositTokenSelector: FC = ({ small = true }) => { + const dispatch = useAppDispatch() + const selectedToken = useAppSelector(selectSelectedSwapDepositToken) + const currentTheme = useAppSelector(selectCurrentTheme) + + const handleTokenSelect = useCallback( + (token: SwapDepositToken) => { + dispatch(setSelectedSwapDepositToken(token)) + }, + [dispatch] + ) + + const SUSDIcon = useMemo(() => { + return currentTheme === 'dark' ? SUSDWhiteIcon : SUSDBlackIcon + }, [currentTheme]) + + const swapTokenIconMap = useMemo( + () => ({ + [SwapDepositToken.DAI]: DAIIcon, + [SwapDepositToken.USDC]: USDCIcon, + // [SwapDepositToken.USDT]: USDTIcon, + [SwapDepositToken.SUSD]: SUSDIcon, + // [SwapDepositToken.LUSD]: LUSDIcon, + }), + [SUSDIcon] + ) + + return ( +
+ {small ? ( + + ) : ( + + )} +
+ ) +} + +export default SwapDepositTokenSelector diff --git a/packages/app/src/state/__mocks__/sdk.ts b/packages/app/src/state/__mocks__/sdk.ts index c72a3e2a0b..82f915a1e4 100644 --- a/packages/app/src/state/__mocks__/sdk.ts +++ b/packages/app/src/state/__mocks__/sdk.ts @@ -25,6 +25,20 @@ export const mockFuturesService = () => ({ keeperEthBal: wei('0.1'), walletEthBal: wei('1'), allowance: wei('1000'), + balances: { + SUSD: wei('1000'), + USDC: wei('1000'), + // USDT: wei('1000'), + DAI: wei('1000'), + // LUSD: wei('1000'), + }, + allowances: { + SUSD: wei('1000'), + USDC: wei('1000'), + // USDT: wei('1000'), + DAI: wei('1000'), + // LUSD: wei('1000'), + }, }), getMarkets: () => { return [...SDK_MARKETS] diff --git a/packages/app/src/state/constants.ts b/packages/app/src/state/constants.ts index ad0a7fefc0..bc1ae925d0 100644 --- a/packages/app/src/state/constants.ts +++ b/packages/app/src/state/constants.ts @@ -26,6 +26,8 @@ export const ZERO_STATE_ACCOUNT = { allowance: '0', keeperEthBal: '0', walletEthBal: '0', + balances: { SUSD: '0', USDC: '0', DAI: '0' }, + allowances: { SUSD: '0', USDC: '0', DAI: '0' }, }, trades: [], positions: [], diff --git a/packages/app/src/state/futures/common/types.ts b/packages/app/src/state/futures/common/types.ts index be3d0074f2..c30a735dfa 100644 --- a/packages/app/src/state/futures/common/types.ts +++ b/packages/app/src/state/futures/common/types.ts @@ -62,6 +62,9 @@ export type FuturesQueryStatuses = { export type SmartMarginQueryStatuses = FuturesQueryStatuses & { smartMarginBalanceInfo: QueryStatus + swapDepositBalanceQuote: QueryStatus + swapDepositQuote: QueryStatus + tradeSwapDepositQuote: QueryStatus } export type TradeSizeInputs = { diff --git a/packages/app/src/state/futures/hooks.ts b/packages/app/src/state/futures/hooks.ts index ca63430f4a..abd7e136c5 100644 --- a/packages/app/src/state/futures/hooks.ts +++ b/packages/app/src/state/futures/hooks.ts @@ -1,5 +1,6 @@ import { FuturesMarginType } from '@kwenta/sdk/types' +import { SWAP_DEPOSIT_TRADE_ENABLED } from 'constants/ui' import { fetchCrossMarginAccountData, fetchCrossMarginMarketData, @@ -36,10 +37,12 @@ import { fetchSmartMarginAccountData, fetchSmartMarginMarketData, fetchSmartMarginOpenOrders, + fetchSwapDepositBalanceQuote, } from './smartMargin/actions' import { selectSmartMarginAccount, selectSmartMarginSupportedNetwork, + selectSelectedSwapDepositToken, } from './smartMargin/selectors' // TODO: Optimise polling and queries @@ -53,6 +56,7 @@ export const usePollMarketFuturesData = () => { const selectedAccountType = useAppSelector(selectFuturesType) const networkSupportsSmartMargin = useAppSelector(selectSmartMarginSupportedNetwork) const networkSupportsCrossMargin = useAppSelector(selectCrossMarginSupportedNetwork) + const swapDepositToken = useAppSelector(selectSelectedSwapDepositToken) const networkSupportTradingRewards = useAppSelector(selectTradingRewardsSupportedNetwork) useFetchAction(fetchBoostNftMinted, { @@ -138,6 +142,12 @@ export const usePollMarketFuturesData = () => { intervalTime: 30000, disabled: !wallet, }) + + usePollAction('fetchSwapDepositBalanceQuote', fetchSwapDepositBalanceQuote, { + dependencies: [swapDepositToken, wallet], + intervalTime: 10 * 60 * 1000, + disabled: !wallet || !swapDepositToken || !SWAP_DEPOSIT_TRADE_ENABLED, + }) } export const usePollDashboardFuturesData = () => { diff --git a/packages/app/src/state/futures/reducer.ts b/packages/app/src/state/futures/reducer.ts index c9b5401e2d..e19fe3f5f1 100644 --- a/packages/app/src/state/futures/reducer.ts +++ b/packages/app/src/state/futures/reducer.ts @@ -1,4 +1,5 @@ import { Period } from '@kwenta/sdk/constants' +import { SwapDepositToken } from '@kwenta/sdk/types' import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { DEFAULT_FUTURES_MARGIN_TYPE } from 'constants/defaults' @@ -20,6 +21,7 @@ export const FUTURES_INITIAL_STATE: FuturesState = { selectedInputDenomination: 'usd', selectedInputHours: 1, selectedChart: 'price', + selectedSwapDepositToken: SwapDepositToken.SUSD, preferences: { showHistory: true, }, @@ -71,6 +73,9 @@ const futuresSlice = createSlice({ setHistoricalFundingRatePeriod: (state, action: PayloadAction) => { state.historicalFundingRatePeriod = action.payload }, + setSelectedSwapDepositToken: (state, action: PayloadAction) => { + state.selectedSwapDepositToken = action.payload + }, }, extraReducers(builder) { @@ -107,4 +112,5 @@ export const { toggleShowTradeHistory, setSelectedChart, setHistoricalFundingRatePeriod, + setSelectedSwapDepositToken, } = futuresSlice.actions diff --git a/packages/app/src/state/futures/smartMargin/actions.ts b/packages/app/src/state/futures/smartMargin/actions.ts index c51af9168a..2d7d410b56 100644 --- a/packages/app/src/state/futures/smartMargin/actions.ts +++ b/packages/app/src/state/futures/smartMargin/actions.ts @@ -25,6 +25,7 @@ import { NetworkId, TransactionStatus, FuturesMarginType, + SwapDepositToken, } from '@kwenta/sdk/types' import { calculateDesiredFillPrice, @@ -38,6 +39,7 @@ import Wei, { wei } from '@synthetixio/wei' import { debounce } from 'lodash' import { notifyError } from 'components/ErrorNotifier' +import { SWAP_QUOTE_BUFFER } from 'constants/defaults' import { monitorAndAwaitTransaction } from 'state/app/helpers' import { handleTransactionError, @@ -96,6 +98,13 @@ import { clearSmartMarginTradePreviews, setKeeperDeposit, } from './reducer' +import { + selectIdleAccountMargin, + selectSelectedSwapDepositToken, + selectSwapDepositBalance, + selectSwapDepositBalanceQuote, + selectTradeSwapDepositQuote, +} from './selectors' import { selectSmartMarginAccount, selectSmartMarginMarginDelta, @@ -116,7 +125,6 @@ import { selectSmartMarginPreviewCount, selectTradePreview, selectCloseSMPositionOrderInputs, - selectSmartMarginActivePositions, selectEditPositionModalInfo, selectSlTpModalInputs, selectSmartMarginKeeperDeposit, @@ -420,6 +428,64 @@ export const fetchSmartMarginTradePreview = createAsyncThunk< } ) +export const calculateTradeSwapDeposit = createAsyncThunk< + | { + token: SwapDepositToken + amountIn: string + amountOut: string + quoteInvalidReason?: `insufficient-${'balance' | 'quote'}` + } + | undefined, + void, + ThunkConfig +>('futures/calculateTradeSwapDeposit', async (_, { getState }) => { + const state = getState() + const wallet = selectWallet(state) + const marketInfo = selectV2MarketInfo(state) + const swapDepositToken = selectSelectedSwapDepositToken(state) + const marginDelta = selectSmartMarginMarginDelta(state) + const idleMargin = selectIdleAccountMargin(state) + const swapDepositBalance = selectSwapDepositBalance(state) + const balanceQuote = selectSwapDepositBalanceQuote(state) + + if (!wallet || !marketInfo || !swapDepositToken || swapDepositToken === SwapDepositToken.SUSD) + return + + try { + const requiredSwapDeposit = marginDelta.sub(idleMargin) + + if (requiredSwapDeposit.lte(0)) { + return + } + + // Add some buffer to account for price change since quote + // but keeping within the bounds of original balance quote + + const depositWithBuffer = requiredSwapDeposit.add( + requiredSwapDeposit.mul(SWAP_QUOTE_BUFFER).div(100) + ) + + const tokenInAmount = depositWithBuffer.div(balanceQuote?.rate || 0) + + let quoteInvalidReason: `insufficient-${'balance' | 'quote'}` | undefined = undefined + + if (tokenInAmount.gt(swapDepositBalance)) { + quoteInvalidReason = 'insufficient-balance' + } + + return { + token: swapDepositToken, + amountIn: tokenInAmount.toString(), + amountOut: requiredSwapDeposit.toString(), + quoteInvalidReason, + } + } catch (err) { + logError(err) + notifyError('Failed to calculate swap deposit', err) + throw err + } +}) + export const clearTradeInputs = createAsyncThunk( 'futures/clearTradeInputs', async (_, { dispatch }) => { @@ -945,6 +1011,21 @@ export const createSmartMarginAccount = createAsyncThunk< } ) +export const depositSmartMargin = createAsyncThunk( + 'futures/depositSmartMargin', + async (amount, { getState, dispatch, extra: { sdk } }) => { + const state = getState() + const account = selectSmartMarginAccount(state) + const token = selectSelectedSwapDepositToken(state) + + if (!account) { + notifyError('No smart margin account') + return + } + await submitSMTransferTransaction(dispatch, sdk, 'deposit_smart_margin', account, amount, token) + } +) + export const withdrawSmartMargin = createAsyncThunk( 'futures/withdrawSmartMargin', async (amount, { getState, dispatch, extra: { sdk } }) => { @@ -960,8 +1041,10 @@ export const withdrawSmartMargin = createAsyncThunk( export const approveSmartMargin = createAsyncThunk( 'futures/approveSmartMargin', async (_, { getState, dispatch, extra: { sdk } }) => { - const address = selectSmartMarginAccount(getState()) - if (!address) throw new Error('No smart margin account') + const state = getState() + const account = selectSmartMarginAccount(state) + const token = selectSelectedSwapDepositToken(state) + if (!account) throw new Error('No smart margin account') try { dispatch( setTransaction({ @@ -970,7 +1053,7 @@ export const approveSmartMargin = createAsyncThunk( hash: null, }) ) - const tx = await sdk.futures.approveSmartMarginDeposit({ address }) + const tx = await sdk.futures.approveSmartMarginDeposit({ address: account, token }) await monitorAndAwaitTransaction(dispatch, tx) dispatch(fetchSmartMarginBalanceInfo()) } catch (err) { @@ -983,19 +1066,21 @@ export const approveSmartMargin = createAsyncThunk( export const submitSmartMarginOrder = createAsyncThunk( 'futures/submitSmartMarginOrder', async (overridePriceProtection, { getState, dispatch, extra: { sdk } }) => { - const marketInfo = selectV2MarketInfo(getState()) - const account = selectSmartMarginAccount(getState()) - const tradeInputs = selectSmartMarginTradeInputs(getState()) - const marginDelta = selectSmartMarginMarginDelta(getState()) - const feeCap = selectOrderFeeCap(getState()) - const orderType = selectOrderType(getState()) - const orderPrice = selectSmartMarginOrderPrice(getState()) - const preview = selectTradePreview(getState()) - const keeperEthDeposit = selectSmartMarginKeeperDeposit(getState()) - const wallet = selectWallet(getState()) - const position = selectSelectedSmartMarginPosition(getState()) - const openDelayedOrders = selectSmartMarginDelayedOrders(getState()) - const { stopLossPrice, takeProfitPrice } = selectSlTpTradeInputs(getState()) + const state = getState() + const marketInfo = selectV2MarketInfo(state) + const account = selectSmartMarginAccount(state) + const tradeInputs = selectSmartMarginTradeInputs(state) + const marginDelta = selectSmartMarginMarginDelta(state) + const feeCap = selectOrderFeeCap(state) + const orderType = selectOrderType(state) + const orderPrice = selectSmartMarginOrderPrice(state) + const preview = selectTradePreview(state) + const keeperEthDeposit = selectSmartMarginKeeperDeposit(state) + const wallet = selectWallet(state) + const position = selectSelectedSmartMarginPosition(state) + const openDelayedOrders = selectSmartMarginDelayedOrders(state) + const { stopLossPrice, takeProfitPrice } = selectSlTpTradeInputs(state) + const swapQuote = selectTradeSwapDepositQuote(state) try { if (!marketInfo) throw new Error('Market info not found') @@ -1075,17 +1160,28 @@ export const submitSmartMarginOrder = createAsyncThunk { dispatch( setTransaction({ @@ -1365,8 +1462,17 @@ const submitSMTransferTransaction = async ( const isPrepareOnly = false const tx = type === 'deposit_smart_margin' - ? await sdk.futures.depositSmartMarginAccount({ address, amount, isPrepareOnly }) - : await sdk.futures.withdrawSmartMarginAccount({ address, amount, isPrepareOnly }) + ? await sdk.futures.depositSmartMarginAccount({ + address: account, + amount, + token, + isPrepareOnly, + }) + : await sdk.futures.withdrawSmartMarginAccount({ + address: account, + amount, + isPrepareOnly, + }) await monitorAndAwaitTransaction(dispatch, tx) dispatch(fetchSmartMarginBalanceInfo()) dispatch(setOpenModal(null)) @@ -1491,3 +1597,29 @@ const getMarketDetailsByKey = (getState: () => RootState, key: FuturesMarketKey) key: market.marketKey, } } + +export const fetchSwapDepositBalanceQuote = createAsyncThunk< + { + rate: string + susdQuote: string + }, + void, + ThunkConfig +>('futures/fetchSwapDepositBalanceQuote', async (_, { getState, extra: { sdk } }) => { + const state = getState() + const token = selectSelectedSwapDepositToken(state) + const balance = selectSwapDepositBalance(state) + if (token === SwapDepositToken.SUSD || balance.eq(0)) + return { + rate: '1', + susdQuote: balance.toString(), + } + + const susdQuote = await sdk.futures.getSwapDepositQuote(token, balance) + const rate = susdQuote.div(balance) + + return { + rate: rate.toString(), + susdQuote: susdQuote.sub(susdQuote.mul(SWAP_QUOTE_BUFFER).div(100)).toString(), + } +}) diff --git a/packages/app/src/state/futures/smartMargin/reducer.ts b/packages/app/src/state/futures/smartMargin/reducer.ts index 6defcd638d..675e51feb6 100644 --- a/packages/app/src/state/futures/smartMargin/reducer.ts +++ b/packages/app/src/state/futures/smartMargin/reducer.ts @@ -39,6 +39,8 @@ import { fetchFundingRatesHistory, fetchFuturesFees, fetchFuturesFeesForAccount, + fetchSwapDepositBalanceQuote, + calculateTradeSwapDeposit, } from './actions' import { SmartMarginAccountData, @@ -69,6 +71,9 @@ export const SMART_MARGIN_INITIAL_STATE: SmartMarginState = { historicalFundingRates: DEFAULT_QUERY_STATUS, futuresFees: DEFAULT_QUERY_STATUS, futuresFeesForAccount: DEFAULT_QUERY_STATUS, + swapDepositBalanceQuote: DEFAULT_QUERY_STATUS, + swapDepositQuote: DEFAULT_QUERY_STATUS, + tradeSwapDepositQuote: DEFAULT_QUERY_STATUS, }, accounts: DEFAULT_MAP_BY_NETWORK, selectedMarketAsset: FuturesMarketAsset.sETH, @@ -112,6 +117,9 @@ export const SMART_MARGIN_INITIAL_STATE: SmartMarginState = { }, futuresFees: '0', futuresFeesForAccount: '0', + swapDepositBalanceQuote: undefined, + swapDepositSlippage: 0.15, + swapDepositCustomSlippage: '', } const smartMarginSlice = createSlice({ @@ -250,6 +258,16 @@ const smartMarginSlice = createSlice({ incrementSmartMarginPreviewCount: (smartMargin) => { smartMargin.previewDebounceCount = smartMargin.previewDebounceCount + 1 }, + setSwapDepositSlippage: (smartMargin, action) => { + smartMargin.swapDepositCustomSlippage = '' + smartMargin.swapDepositSlippage = action.payload + }, + setSwapDepositCustomSlippage: (smartMargin, action) => { + smartMargin.swapDepositCustomSlippage = action.payload + }, + clearTradeSwapDepositQuote: (smartMargin) => { + smartMargin.tradeSwapDepositQuote = undefined + }, }, extraReducers: (builder) => { // Markets @@ -516,6 +534,35 @@ const smartMarginSlice = createSlice({ error: 'Failed to fetch fee data for the account', } }) + + builder.addCase(fetchSwapDepositBalanceQuote.pending, (futuresState) => { + futuresState.queryStatuses.swapDepositBalanceQuote = LOADING_STATUS + }) + builder.addCase(fetchSwapDepositBalanceQuote.fulfilled, (futuresState, action) => { + futuresState.queryStatuses.swapDepositBalanceQuote = SUCCESS_STATUS + futuresState.swapDepositBalanceQuote = action.payload + }) + builder.addCase(fetchSwapDepositBalanceQuote.rejected, (futuresState) => { + futuresState.swapDepositBalanceQuote = undefined + futuresState.queryStatuses.swapDepositBalanceQuote = { + status: FetchStatus.Error, + error: 'Failed to fetch quote for the swap deposit token', + } + }) + + builder.addCase(calculateTradeSwapDeposit.pending, (futuresState) => { + futuresState.queryStatuses.tradeSwapDepositQuote = LOADING_STATUS + }) + builder.addCase(calculateTradeSwapDeposit.fulfilled, (futuresState, action) => { + futuresState.queryStatuses.tradeSwapDepositQuote = SUCCESS_STATUS + futuresState.tradeSwapDepositQuote = action.payload + }) + builder.addCase(calculateTradeSwapDeposit.rejected, (futuresState) => { + futuresState.queryStatuses.tradeSwapDepositQuote = { + status: FetchStatus.Error, + error: 'Failed to fetch quote for the swap deposit', + } + }) }, }) @@ -547,6 +594,9 @@ export const { incrementSmartMarginPreviewCount, setSLTPModalStopLoss, setSLTPModalTakeProfit, + setSwapDepositSlippage, + setSwapDepositCustomSlippage, + clearTradeSwapDepositQuote, } = smartMarginSlice.actions const findWalletForAccount = ( diff --git a/packages/app/src/state/futures/smartMargin/selectors.ts b/packages/app/src/state/futures/smartMargin/selectors.ts index ebc693a8c1..a643f0772b 100644 --- a/packages/app/src/state/futures/smartMargin/selectors.ts +++ b/packages/app/src/state/futures/smartMargin/selectors.ts @@ -4,6 +4,7 @@ import { ConditionalOrderTypeEnum, PositionSide, FuturesMarketKey, + SwapDepositToken, } from '@kwenta/sdk/types' import { calculateDesiredFillPrice, @@ -18,6 +19,7 @@ import Wei, { wei } from '@synthetixio/wei' import { FuturesPositionTablePosition, FuturesPositionTablePositionActive } from 'types/futures' import { DEFAULT_DELAYED_CANCEL_BUFFER } from 'constants/defaults' +import { SWAP_DEPOSIT_TRADE_ENABLED } from 'constants/ui' import { selectSusdBalance } from 'state/balances/selectors' import { EST_KEEPER_GAS_FEE } from 'state/constants' import { @@ -500,10 +502,26 @@ export const selectSmartMarginBalanceInfo = createSelector( freeMargin: wei(0), keeperEthBal: wei(0), allowance: wei(0), + balances: { SUSD: wei(0), USDC: wei(0), DAI: wei(0) }, + allowances: { SUSD: wei(0), USDC: wei(0), DAI: wei(0) }, } } ) +export const selectMaxSwapToken = createSelector(selectSmartMarginBalanceInfo, ({ balances }) => { + const maxToken = Object.keys(balances).reduce((a, b) => + balances[a as SwapDepositToken].gt(balances[b as SwapDepositToken]) ? a : b + ) + + return maxToken as SwapDepositToken +}) + +export const selectMaxTokenBalance = createSelector( + selectSmartMarginBalanceInfo, + selectMaxSwapToken, + (balanceInfo, maxToken) => balanceInfo.balances[maxToken] +) + export const selectSmartMarginDepositApproved = createSelector( selectSmartMarginAccountData, (account) => { @@ -527,11 +545,44 @@ export const selectAvailableMarginInMarkets = selectIdleMarginInMarkets() export const selectLockedMarginInMarkets = selectIdleMarginInMarkets(true) +export const selectSwapDepositBalanceQuote = createSelector( + (state: RootState) => state.smartMargin.swapDepositBalanceQuote, + (quote) => { + return quote + ? { + susdQuote: wei(quote.susdQuote), + rate: wei(quote.rate), + } + : undefined + } +) + +export const selectSelectedSwapDepositToken = (state: RootState) => + state.futures.selectedSwapDepositToken + +export const selectSwapDepositBalance = createSelector( + selectSmartMarginBalanceInfo, + selectSelectedSwapDepositToken, + (smartMarginBalanceInfo, swapDepositToken) => smartMarginBalanceInfo.balances[swapDepositToken] +) + +export const selectSwapDepositAllowance = createSelector( + selectSmartMarginBalanceInfo, + selectSelectedSwapDepositToken, + (smartMarginBalanceInfo, swapDepositToken) => smartMarginBalanceInfo.allowances[swapDepositToken] +) + +export const selectApprovingSwapDeposit = createSelector( + selectSubmittingFuturesTx, + (state: RootState) => state.app.transaction, + (submitting, transaction) => submitting && transaction?.type === 'approve_cross_margin' +) + export const selectIdleAccountMargin = createSelector( selectAvailableMarginInMarkets, selectLockedMarginInMarkets, selectSmartMarginBalanceInfo, - (lockedMargin, availableInMarkets, { freeMargin }) => { + (availableInMarkets, lockedMargin, { freeMargin }) => { return lockedMargin.add(availableInMarkets).add(freeMargin) } ) @@ -540,26 +591,37 @@ export const selectTotalAvailableMargin = createSelector( selectAvailableMarginInMarkets, selectSmartMarginBalanceInfo, selectSusdBalance, - (idleInMarkets, { freeMargin }, balance) => { - return balance.add(idleInMarkets).add(freeMargin) + selectSwapDepositBalanceQuote, + selectSelectedSwapDepositToken, + (idleInMarkets, { freeMargin }, susdBalance, balanceQuote, selectedToken) => { + const walletBalance = + !SWAP_DEPOSIT_TRADE_ENABLED || selectedToken === SwapDepositToken.SUSD + ? susdBalance + : balanceQuote?.susdQuote ?? wei(0) + return walletBalance.add(idleInMarkets).add(freeMargin) } ) export const selectSmartMarginAllowanceValid = createSelector( - selectSmartMarginAccountData, selectSmartMarginBalanceInfo, selectAvailableMarginInMarkets, selectSmartMarginMarginDelta, - (account, { freeMargin }, idleInMarkets, marginDelta) => { + selectSwapDepositAllowance, + ({ freeMargin }, idleInMarkets, marginDelta, allowance) => { const totalIdleMargin = freeMargin.add(idleInMarkets) - if (!account) return false const marginDeposit = marginDelta.sub(totalIdleMargin) - return ( - totalIdleMargin.gte(marginDelta) || wei(account.balanceInfo.allowance || 0).gte(marginDeposit) - ) + return totalIdleMargin.gte(marginDelta) || wei(allowance || 0).gte(marginDeposit) } ) +export const selectQuoteInvalidReason = (state: RootState) => + state.smartMargin.tradeSwapDepositQuote?.quoteInvalidReason + +export const selectSwapDepositQuoteLoading = createSelector( + (state: RootState) => state.smartMargin.queryStatuses.tradeSwapDepositQuote, + ({ status }) => status === FetchStatus.Loading +) + export const selectWithdrawableSmartMargin = createSelector( selectAvailableMarginInMarkets, selectSmartMarginBalanceInfo, @@ -1183,3 +1245,25 @@ export const selectPlaceOrderTranslationKey = createSelector( return 'futures.market.trade.button.open-position' } ) + +export const selectSwapDepositCustomSlippage = (state: RootState) => + state.smartMargin.swapDepositCustomSlippage + +export const selectSwapDepositSlippage = createSelector( + selectSwapDepositCustomSlippage, + (state: RootState) => state.smartMargin.swapDepositSlippage, + (customSlippage, slippage) => (customSlippage ? Number(customSlippage) : slippage ?? 0) +) + +export const selectTradeSwapDepositQuote = createSelector( + (state: RootState) => state.smartMargin.tradeSwapDepositQuote, + (quote) => { + return quote + ? { + token: quote.token, + amountIn: wei(quote.amountIn), + amountOut: wei(quote.amountOut), + } + : null + } +) diff --git a/packages/app/src/state/futures/smartMargin/types.ts b/packages/app/src/state/futures/smartMargin/types.ts index bdf45eb48c..22c7563d24 100644 --- a/packages/app/src/state/futures/smartMargin/types.ts +++ b/packages/app/src/state/futures/smartMargin/types.ts @@ -11,6 +11,7 @@ import { FuturesVolumes, PerpsMarketV2, PerpsV2Position, + SwapDepositToken, } from '@kwenta/sdk/types' import Wei from '@synthetixio/wei' @@ -86,6 +87,8 @@ export type SmartMarginBalanceInfo = { keeperEthBal: T allowance: T walletEthBal: T + balances: Record + allowances: Record } export type SmartMarginTradeFees = { @@ -152,4 +155,16 @@ export type SmartMarginState = { } futuresFees: string futuresFeesForAccount: string + swapDepositBalanceQuote?: { + susdQuote: string + rate: string + } + swapDepositSlippage: number + swapDepositCustomSlippage: string + tradeSwapDepositQuote?: { + token: SwapDepositToken + amountIn: string + amountOut: string + quoteInvalidReason?: `insufficient-${'balance' | 'quote'}` + } } diff --git a/packages/app/src/state/futures/types.ts b/packages/app/src/state/futures/types.ts index c6fb3c743f..5918ab7df3 100644 --- a/packages/app/src/state/futures/types.ts +++ b/packages/app/src/state/futures/types.ts @@ -4,6 +4,7 @@ import { FuturesPositionHistory, FuturesMarketKey, FuturesMarketAsset, + SwapDepositToken, } from '@kwenta/sdk/types' import Wei from '@synthetixio/wei' @@ -58,6 +59,7 @@ export type FuturesState = { selectedInputHours: number selectedChart: 'price' | 'funding' historicalFundingRatePeriod: Period + selectedSwapDepositToken: SwapDepositToken preferences: { showHistory?: boolean } diff --git a/packages/app/src/state/hooks.ts b/packages/app/src/state/hooks.ts index 5484c52ca3..09f48673e9 100644 --- a/packages/app/src/state/hooks.ts +++ b/packages/app/src/state/hooks.ts @@ -100,5 +100,5 @@ export const useFetchAction = ( dispatch(action()) } // eslint-disable-next-line - }, [providerReady, options?.disabled, ...(options?.dependencies || [])]) + }, [dispatch, action, providerReady, options?.disabled, ...(options?.dependencies || [])]) } diff --git a/packages/app/src/state/migrations.ts b/packages/app/src/state/migrations.ts index 30d4abbde4..5fa43c2ca5 100644 --- a/packages/app/src/state/migrations.ts +++ b/packages/app/src/state/migrations.ts @@ -3,6 +3,7 @@ import { BALANCES_INITIAL_STATE } from './balances/reducer' import { EARN_INITIAL_STATE } from './earn/reducer' import { EXCHANGES_INITIAL_STATE } from './exchange/reducer' import { FUTURES_INITIAL_STATE } from './futures/reducer' +import { SMART_MARGIN_INITIAL_STATE } from './futures/smartMargin/reducer' import { HOME_INITIAL_STATE } from './home/reducer' import { PREFERENCES_INITIAL_STATE } from './preferences/reducer' import { PRICES_INITIAL_STATE } from './prices/reducer' @@ -72,6 +73,13 @@ export const migrations = { referral: REFERRALS_INITIAL_STATE, } }, + 39: (state: any) => { + return { + ...state, + futures: FUTURES_INITIAL_STATE, + smartMargin: SMART_MARGIN_INITIAL_STATE, + } + }, } export default migrations diff --git a/packages/app/src/state/store.ts b/packages/app/src/state/store.ts index 829e29e0af..18e5045d4d 100644 --- a/packages/app/src/state/store.ts +++ b/packages/app/src/state/store.ts @@ -37,7 +37,7 @@ const LOG_REDUX = false const persistConfig = { key: 'root1', storage, - version: 38, + version: 39, blacklist: ['app', 'wallet'], migrate: createMigrate(migrations, { debug: true }), } diff --git a/packages/app/src/styles/theme/colors/dark.ts b/packages/app/src/styles/theme/colors/dark.ts index 6f46683024..e533cdabd1 100644 --- a/packages/app/src/styles/theme/colors/dark.ts +++ b/packages/app/src/styles/theme/colors/dark.ts @@ -27,7 +27,7 @@ const newTheme = { background: common.palette.neutral.n800, color: common.palette.neutral.n0, hover: { - background: common.palette.neutral.n800, + background: common.palette.neutral.n700, }, }, tab: { @@ -230,6 +230,10 @@ const newTheme = { background: common.palette.neutral.n700, }, }, + disclaimer: { + background: common.palette.yellow.y1000, + color: common.palette.yellow.y500, + }, } const darkTheme = { diff --git a/packages/app/src/styles/theme/colors/light.ts b/packages/app/src/styles/theme/colors/light.ts index be2e410c22..6adf6d2d46 100644 --- a/packages/app/src/styles/theme/colors/light.ts +++ b/packages/app/src/styles/theme/colors/light.ts @@ -231,6 +231,10 @@ const newTheme = { background: common.palette.neutral.n30, }, }, + disclaimer: { + background: common.palette.yellow.y300, + color: common.palette.yellow.y1000, + }, } const lightTheme = { diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index cd0c8efb15..39dbd5b414 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -45,7 +45,7 @@ "optimism-testnet": "OP Goerli" }, "balance": { - "get-susd": "Get sUSD", + "get-susd": "Deposit Funds", "get-more-susd": "Get more sUSD", "total-margin-label": "Total Margin:", "no-accessible-margin": "You have no accessible margin" @@ -568,7 +568,7 @@ "total": "Total", "fee": "Fee", "vest-v1": "VEST V1", - "vest-v2": "VEST V2", + "vest-v2": "VEST V2", "transfer": "TRANSFER", "delegate": "DELEGATE", "no-entries": "You have no escrowed entries.", @@ -816,7 +816,9 @@ "place-delayed-order": "Place Order", "place-limit-order": "Place Limit Order", "place-stop-order": "Place Stop Market Order", - "connect-wallet": "Connect Wallet" + "connect-wallet": "Connect Wallet", + "create-account": "Create Account", + "deposit-funds": "Deposit Funds" }, "margin": { "deposit-susd": "Deposit your sUSD to start trading", @@ -830,19 +832,21 @@ "gas-fee": "Gas Fee", "max": "Max", "deposit": { + "amount": "Amount", "title": "Deposit", "button": "Deposit Margin", "approve-button": "Approve sUSD", "disclaimer": "A $50 margin minimum is required to open a position.", "min-deposit": "$50 minimum margin to open a position", "min-margin-error": "Position size too small, minimum market margin of $50 required", - "exceeds-balance": "Amount exceeds balance" + "exceeds-balance": "Amount exceeds balance", + "max-slippage": "Max Slippage" }, "withdraw": { "title": "Withdraw", "button": "Withdraw Margin" }, - "title": "Edit margin", + "title": "Manage Account Balances", "actions": { "withdraw": "Withdraw", "deposit": "Deposit" @@ -851,6 +855,12 @@ "title": "Bridge & Swap", "low-balance": "Your sUSD balance is low. You can bridge more assets from another network below.", "no-balance": "To get started, you need sUSD to trade on Kwenta. You can bridge assets from another network into sUSD below." + }, + "buttons": { + "deposit": "Deposit Margin", + "approve": "Approve Permit", + "approving": "Approving Permit...", + "withdraw": "Withdraw Margin" } } }, @@ -875,11 +885,18 @@ "only-available-margin": "Only {{balance}} available margin", "min-margin": "Min. $50 sUSD required to trade", "tooltip": "Margin currently locked in closed markets. It will become available once the market reopens.", - "deposit": "Deposit" + "deposit": "Deposit", + "manage-button": "Manage", + "no-wallet-connected": "No wallet connected", + "no-wallet-connected-detail": "Connect wallet to deposit margin", + "connect-wallet-button": "Connect wallet", + "no-smart-account": "No account", + "no-smart-account-detail": "Create account to start trading", + "create-account-button": "Create account" }, "confirmation": { "modal": { - "approve-order": "Step 1: Approve sUSD spend", + "approve-order": "Step 1: Approve {{asset}} spend", "confirm-order": { "long": "Confirm Long order", "short": "Confirm Short order" @@ -889,10 +906,15 @@ "disabled-min-margin": "Minimum market margin of $50 required", "disabled-eth-bal": "Min ETH {{depositAmount}} balance required", "disabled-exceeds-price-protection": "Price protection exceeded ", + "disabled-quote-invalid": "Invalid swap quote", + "disabled-quote-loading": "Loading quote...", "eth-bal-warning": "Conditional orders require a small ETH deposit to use for gas when executing", "delayed-disclaimer": "The fee amount will be deposited to cover trade and automated execution fees. If an order is cancelled or not executed, the deposit will be forfeited to the Synthetix debt pool.", "stop-loss-warning": "Stop loss is close to the liquidation price and cannot be guaranteed to execute before you get liquidated, proceed anyway?", - "slippage-warning": "This trade incurs high slippage, proceed anyway?" + "slippage-warning": "This trade incurs high slippage, proceed anyway?", + "quote-invalid-error": "This trade quote is no longer valid, please adjust the margin delta.", + "quote-invalid-error-insufficient-balance": "The token balance in insufficient to execute this trade, please adjust the margin delta.", + "quote-invalid-error-insufficient-quote": "The quoted amount is insufficient to cover the required deposit amount." } }, "leverage": { @@ -1120,9 +1142,10 @@ "sm-title": "Smart Margin", "cm-title": "Cross Margin", "cm-intro": "Start by creating your cross margin account", + "sm-intro": "Start by creating your smart margin account", "step1-intro": "Start by creating your smart margin account", "step2-intro": "Now, approve the contract to spend sUSD", - "step3-intro": "Lastly, deposit sUSD to begin trading", + "step3-intro": "Lastly, deposit funds to begin trading", "step3-complete": "Your smart margin account has been successfully created!", "faq1": "What is smart margin?", "faq2": "Is smart margin better than isolated margin?", diff --git a/packages/app/src/utils/futures.ts b/packages/app/src/utils/futures.ts index 40257d82d6..fdbf102b22 100644 --- a/packages/app/src/utils/futures.ts +++ b/packages/app/src/utils/futures.ts @@ -16,6 +16,7 @@ import { PerpsMarketV2, PerpsMarketV3, PerpsV3Position, + SwapDepositToken, } from '@kwenta/sdk/types' import { AssetDisplayByAsset, @@ -252,6 +253,20 @@ export const serializeCmBalanceInfo = ( keeperEthBal: overview.keeperEthBal.toString(), walletEthBal: overview.walletEthBal.toString(), allowance: overview.allowance.toString(), + balances: { + [SwapDepositToken.SUSD]: overview.balances[SwapDepositToken.SUSD].toString(), + [SwapDepositToken.USDC]: overview.balances[SwapDepositToken.USDC].toString(), + // [SwapDepositToken.USDT]: overview.balances[SwapDepositToken.USDT].toString(), + [SwapDepositToken.DAI]: overview.balances[SwapDepositToken.DAI].toString(), + // [SwapDepositToken.LUSD]: overview.balances[SwapDepositToken.LUSD].toString(), + }, + allowances: { + [SwapDepositToken.SUSD]: overview.allowances[SwapDepositToken.SUSD].toString(), + [SwapDepositToken.USDC]: overview.allowances[SwapDepositToken.USDC].toString(), + // [SwapDepositToken.USDT]: overview.allowances[SwapDepositToken.USDT].toString(), + [SwapDepositToken.DAI]: overview.allowances[SwapDepositToken.DAI].toString(), + // [SwapDepositToken.LUSD]: overview.allowances[SwapDepositToken.LUSD].toString(), + }, } } @@ -263,6 +278,20 @@ export const unserializeCmBalanceInfo = ( keeperEthBal: wei(balanceInfo.keeperEthBal), walletEthBal: wei(balanceInfo.walletEthBal), allowance: wei(balanceInfo.allowance), + balances: { + [SwapDepositToken.SUSD]: wei(balanceInfo.balances[SwapDepositToken.SUSD]), + [SwapDepositToken.USDC]: wei(balanceInfo.balances[SwapDepositToken.USDC]), + // [SwapDepositToken.USDT]: wei(balanceInfo.balances[SwapDepositToken.USDT]), + [SwapDepositToken.DAI]: wei(balanceInfo.balances[SwapDepositToken.DAI]), + // [SwapDepositToken.LUSD]: wei(balanceInfo.balances[SwapDepositToken.LUSD]), + }, + allowances: { + [SwapDepositToken.SUSD]: wei(balanceInfo.allowances[SwapDepositToken.SUSD]), + [SwapDepositToken.USDC]: wei(balanceInfo.allowances[SwapDepositToken.USDC]), + // [SwapDepositToken.USDT]: wei(balanceInfo.allowances[SwapDepositToken.USDT]), + [SwapDepositToken.DAI]: wei(balanceInfo.allowances[SwapDepositToken.DAI]), + // [SwapDepositToken.LUSD]: wei(balanceInfo.allowances[SwapDepositToken.LUSD]), + }, } } diff --git a/packages/app/src/utils/input.ts b/packages/app/src/utils/input.ts new file mode 100644 index 0000000000..e5758cf3d8 --- /dev/null +++ b/packages/app/src/utils/input.ts @@ -0,0 +1,3 @@ +const INVALID_NUMERIC_CHARS = ['-', '+', 'e'] + +export const isInvalidNumber = (key: string) => INVALID_NUMERIC_CHARS.includes(key) diff --git a/packages/app/testing/unit/mocks/data/futures.ts b/packages/app/testing/unit/mocks/data/futures.ts index fa22deadc8..a73ea648f2 100644 --- a/packages/app/testing/unit/mocks/data/futures.ts +++ b/packages/app/testing/unit/mocks/data/futures.ts @@ -25,6 +25,8 @@ export const mockSmartMarginAccount = (freeMargin: string = '1000', keeperEthBal keeperEthBal: keeperEthBal, allowance: freeMargin, walletEthBal: '1', + balances: { SUSD: '0', USDC: '0', USDT: '0', DAI: '0' }, + allowances: { SUSD: '0', USDC: '0', USDT: '0', DAI: '0' }, }, delayedOrders: [], conditionalOrders: [], diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6ae86f2ea2..de5acc6d8f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -51,6 +51,7 @@ "dependencies": { "@eth-optimism/contracts": "^0.6.0", "@ethersproject/abi": "^5.7.0", + "@ethersproject/abstract-signer": "^5.7.0", "@ethersproject/bignumber": "^5.7.0", "@ethersproject/properties": "^5.7.0", "@ethersproject/providers": "^5.7.2", @@ -58,6 +59,7 @@ "@kwenta/synthswap": "^1.0.3", "@pythnetwork/pyth-evm-js": "^1.17.0", "@synthetixio/wei": "^2.74.4", + "@uniswap/permit2-sdk": "^1.2.0", "axios": "0.27.2", "bn.js": "^5.2.1", "codegen-graph-ts": "^0.1.4", diff --git a/packages/sdk/src/constants/futures.ts b/packages/sdk/src/constants/futures.ts index d2da1efd7f..d9cb4b0423 100644 --- a/packages/sdk/src/constants/futures.ts +++ b/packages/sdk/src/constants/futures.ts @@ -8,6 +8,7 @@ import { FuturesMarketKey, SmartMarginOrderType, FuturesOrderType, + SwapDepositToken, } from '../types/futures' import { weiFromWei } from '../utils/number' @@ -848,6 +849,12 @@ export const DEFAULT_DESIRED_TIMEDELTA = 0 export const AGGREGATE_ASSET_KEY = '0x' +export const LOW_FEE_TIER = 500 + +export const LOW_FEE_TIER_BYTES = '0x0001f4' + +export const AMOUNT_OUT_MIN = 1 + // subgraph fragments export const ISOLATED_MARGIN_FRAGMENT = gql` query userFuturesMarginTransfers($walletAddress: String!) { @@ -884,3 +891,11 @@ export const SMART_MARGIN_FRAGMENT = gql` } } ` + +export const SWAP_DEPOSIT_TOKENS = [ + SwapDepositToken.SUSD, + SwapDepositToken.USDC, + // SwapDepositToken.USDT, + SwapDepositToken.DAI, + // SwapDepositToken.LUSD, +] diff --git a/packages/sdk/src/constants/index.ts b/packages/sdk/src/constants/index.ts index e7d53f6a8d..a0015a59d7 100644 --- a/packages/sdk/src/constants/index.ts +++ b/packages/sdk/src/constants/index.ts @@ -10,3 +10,4 @@ export * from './staking' export * from './stats' export * from './transactions' export * from '../contracts/constants' +export * from './permit2' diff --git a/packages/sdk/src/constants/permit2.ts b/packages/sdk/src/constants/permit2.ts new file mode 100644 index 0000000000..8ad2eae613 --- /dev/null +++ b/packages/sdk/src/constants/permit2.ts @@ -0,0 +1,6 @@ +import { PERMIT2_ADDRESS } from '@uniswap/permit2-sdk' + +const PERMIT_STRUCT = + '((address token,uint160 amount,uint48 expiration,uint48 nonce) details,address spender,uint256 sigDeadline)' + +export { PERMIT2_ADDRESS, PERMIT_STRUCT } diff --git a/packages/sdk/src/context.ts b/packages/sdk/src/context.ts index ef280c4896..7beb45422e 100644 --- a/packages/sdk/src/context.ts +++ b/packages/sdk/src/context.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'events' import { Provider as EthCallProvider } from 'ethcall' import { ethers } from 'ethers' +import { TypedDataSigner } from '@ethersproject/abstract-signer' import * as sdkErrors from './common/errors' import { @@ -15,7 +16,7 @@ import { NetworkId } from './types/common' export interface IContext { provider: ethers.providers.Provider networkId: NetworkId - signer?: ethers.Signer + signer?: ethers.Signer & TypedDataSigner walletAddress?: string logError?: (err: Error, skipReport?: boolean) => void } @@ -97,7 +98,7 @@ export default class Context implements IContext { public async setSigner(signer: ethers.Signer) { this.context.walletAddress = await signer.getAddress() - this.context.signer = signer + this.context.signer = signer as ethers.Signer & TypedDataSigner } public logError(err: Error, skipReport = false) { diff --git a/packages/sdk/src/contracts/constants.ts b/packages/sdk/src/contracts/constants.ts index f506fb4eb3..741237ab88 100644 --- a/packages/sdk/src/contracts/constants.ts +++ b/packages/sdk/src/contracts/constants.ts @@ -60,6 +60,21 @@ export const ADDRESSES: Record> = { 10: '0x8c6f28f2F1A3C87F0f938b96d27520d9751ec8d9', 420: '0xebaeaad9236615542844adc5c149f86c36ad1136', }, + USDC: { + 1: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 10: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + }, + USDT: { + 1: '0xdac17f958d2ee523a2206206994597c13d831ec7', + 10: '0x94b008aa00579c1307b0ef2c499ad98a8ce58e58', + }, + DAI: { + 1: '0x6b175474e89094c44da98b954eedeac495271d0f', + 10: '0xda10009cbd5d07dd0cecc66161fc93d7c9000da1', + }, + LUSD: { + 10: '0xc40F949F8a4e094D1b49a23ea9241D289B7b2819', + }, SNXUSD: { 420: '0xe487Ad4291019b33e2230F8E2FB1fb6490325260', }, diff --git a/packages/sdk/src/contracts/index.ts b/packages/sdk/src/contracts/index.ts index d607a1300e..96b157373f 100644 --- a/packages/sdk/src/contracts/index.ts +++ b/packages/sdk/src/contracts/index.ts @@ -121,6 +121,18 @@ export const getContractsByNetwork = ( SUSD: ADDRESSES.SUSD[networkId] ? ERC20__factory.connect(ADDRESSES.SUSD[networkId], provider) : undefined, + USDC: ADDRESSES.USDC[networkId] + ? ERC20__factory.connect(ADDRESSES.USDC[networkId], provider) + : undefined, + USDT: ADDRESSES.USDT[networkId] + ? ERC20__factory.connect(ADDRESSES.USDT[networkId], provider) + : undefined, + DAI: ADDRESSES.DAI[networkId] + ? ERC20__factory.connect(ADDRESSES.DAI[networkId], provider) + : undefined, + LUSD: ADDRESSES.LUSD[networkId] + ? ERC20__factory.connect(ADDRESSES.LUSD[networkId], provider) + : undefined, SNXUSD: ADDRESSES.SNXUSD[networkId] ? ERC20__factory.connect(ADDRESSES.SNXUSD[networkId], provider) : undefined, diff --git a/packages/sdk/src/services/futures.ts b/packages/sdk/src/services/futures.ts index e0debf4eda..15e1c871ac 100644 --- a/packages/sdk/src/services/futures.ts +++ b/packages/sdk/src/services/futures.ts @@ -9,7 +9,12 @@ import { orderBy } from 'lodash' import KwentaSDK from '..' import { UNSUPPORTED_NETWORK } from '../common/errors' -import { KWENTA_TRACKING_CODE, ORDERS_FETCH_SIZE, SL_TP_MAX_SIZE } from '../constants/futures' +import { + KWENTA_TRACKING_CODE, + LOW_FEE_TIER_BYTES, + ORDERS_FETCH_SIZE, + SL_TP_MAX_SIZE, +} from '../constants/futures' import { Period, PERIOD_IN_HOURS, PERIOD_IN_SECONDS } from '../constants/period' import { getContractsByNetwork, getPerpsV2MarketMulticall } from '../contracts' import PerpsMarketABI from '../contracts/abis/PerpsV2Market.json' @@ -54,6 +59,7 @@ import { Market, MarketClosureReason, MarketWithIdleMargin, + SwapDepositToken, ModifyMarketMarginParams, ModifySmartMarginPositionParams, PerpsMarketV2, @@ -61,6 +67,7 @@ import { SubmitIsolatedMarginOrdersParams, SubmitSmartMarginOrderParams, UpdateConditionalOrderParams, + DepositSmartMarginParams, } from '../types/futures' import { PricesMap } from '../types/prices' import { calculateTimestampForPeriod } from '../utils/date' @@ -72,7 +79,6 @@ import { encodeConditionalOrderParams, encodeModidyMarketMarginParams, encodeSubmitOffchainOrderParams, - formatPerpsV2Market, formatPotentialTrade, formatV2DelayedOrder, getFuturesEndpoint, @@ -81,11 +87,16 @@ import { mapFuturesPositions, mapTrades, marginTypeToSubgraphType, + formatPerpsV2Market, + getQuote, + getDecimalsForSwapDepositToken, MarketKeyByAsset, marketsForNetwork, } from '../utils/futures' import { getFuturesAggregateStats } from '../utils/subgraph' import { getReasonFromCode } from '../utils/synths' +import { getPermit2Amount, getPermit2TypedData } from '../utils/permit2' +import { PERMIT2_ADDRESS, PERMIT_STRUCT } from '../constants/permit2' export default class FuturesService { private sdk: KwentaSDK @@ -505,15 +516,39 @@ export default class FuturesService { smartMarginAddress, this.sdk.context.provider ) - const { SUSD } = this.sdk.context.contracts - if (!SUSD) throw new Error(UNSUPPORTED_NETWORK) + const { SUSD, USDC, USDT, DAI, LUSD } = this.sdk.context.contracts + + if (!SUSD || !USDC || !USDT || !DAI || !LUSD) throw new Error(UNSUPPORTED_NETWORK) // TODO: EthCall - const [freeMargin, keeperEthBal, walletEthBal, allowance] = await Promise.all([ + const [ + freeMargin, + keeperEthBal, + walletEthBal, + susdBalance, + allowance, + usdcBalance, + usdcAllowance, + // usdtBalance, + // usdtAllowance, + daiBalance, + daiAllowance, + // lusdBalance, + // lusdAllowance, + ] = await Promise.all([ smartMarginAccountContract.freeMargin(), this.sdk.context.provider.getBalance(smartMarginAddress), this.sdk.context.provider.getBalance(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), ]) return { @@ -521,6 +556,20 @@ export default class FuturesService { keeperEthBal: wei(keeperEthBal), walletEthBal: wei(walletEthBal), allowance: wei(allowance), + balances: { + [SwapDepositToken.SUSD]: wei(susdBalance), + [SwapDepositToken.USDC]: wei(usdcBalance, 6), + // [SwapDepositToken.USDT]: wei(usdtBalance, 6), + [SwapDepositToken.DAI]: wei(daiBalance), + // [SwapDepositToken.LUSD]: wei(lusdBalance), + }, + allowances: { + [SwapDepositToken.SUSD]: wei(allowance), + [SwapDepositToken.USDC]: wei(usdcAllowance, 6), + // [SwapDepositToken.USDT]: wei(usdtAllowance, 6), + [SwapDepositToken.DAI]: wei(daiAllowance), + // [SwapDepositToken.LUSD]: wei(lusdAllowance), + }, } } @@ -897,20 +946,24 @@ export default class FuturesService { /** * @desc Approve a smart margin account deposit * @param smartMarginAddress Smart margin account address + * @param token Swap deposit token * @param amount Amount to approve * @returns ethers.js TransactionResponse object */ public async approveSmartMarginDeposit({ address, + token = SwapDepositToken.SUSD, amount = BigNumber.from(ethers.constants.MaxUint256), isPrepareOnly, }: ApproveSmartMarginDepositParams): TxReturn { - if (!this.sdk.context.contracts.SUSD) throw new Error(UNSUPPORTED_NETWORK) - const txn = this.sdk.transactions.prepareContractTxn( - this.sdk.context.contracts.SUSD, - 'approve', - [address, amount] - ) + const tokenContract = this.sdk.context.contracts[token] + + if (!tokenContract) throw new Error(UNSUPPORTED_NETWORK) + + const txn = this.sdk.transactions.prepareContractTxn(tokenContract, 'approve', [ + token === SwapDepositToken.SUSD ? address : PERMIT2_ADDRESS, + amount, + ]) if (isPrepareOnly) { return txn as TxReturn } else { @@ -922,21 +975,76 @@ export default class FuturesService { * @desc Deposit sUSD into a smart margin account * @param smartMarginAddress Smart margin account address * @param amount Amount to deposit + * @param token Swap deposit token + * @param slippage Slippage tolerance for the swap deposit * @returns ethers.js TransactionResponse object */ public async depositSmartMarginAccount({ address, amount, + token = SwapDepositToken.SUSD, + slippage = 0.15, isPrepareOnly, - }: ChangeMarketBalanceParams): TxReturn { + }: DepositSmartMarginParams): TxReturn { + const tokenContract = this.sdk.context.contracts[token] + const { SUSD } = this.sdk.context.contracts + + if (!tokenContract || !SUSD) throw new Error(UNSUPPORTED_NETWORK) + + const walletAddress = await this.sdk.context.signer.getAddress() + const smartMarginAccountContract = SmartMarginAccount__factory.connect( address, this.sdk.context.signer ) + const commands: AccountExecuteFunctions[] = [] + const inputs: string[] = [] + + let amountOutMin = amount + + if (token !== SwapDepositToken.SUSD) { + const permitAmount = await getPermit2Amount( + this.sdk.context.provider, + walletAddress, + tokenContract.address, + address + ) + + if (amount.toBN().gt(permitAmount)) { + const { command, input } = await this.signPermit(address, tokenContract.address) + + commands.push(command) + inputs.push(input) + } + + const quote = await getQuote(token, amount) + + // TODO: Consider passing slippage into getQuote function + + amountOutMin = quote.sub(quote.mul(slippage).div(100)) + + if (!amountOutMin) { + throw new Error('Deposit failed: Could not get quote for swap deposit') + } + + const path = tokenContract.address + LOW_FEE_TIER_BYTES.slice(2) + SUSD.address.slice(2) + + commands.push(AccountExecuteFunctions.UNISWAP_V3_SWAP) + inputs.push( + defaultAbiCoder.encode( + ['uint256', 'uint256', 'bytes'], + [wei(amount, getDecimalsForSwapDepositToken(token)).toBN(), amountOutMin.toBN(), path] + ) + ) + } else { + commands.push(AccountExecuteFunctions.ACCOUNT_MODIFY_MARGIN) + inputs.push(defaultAbiCoder.encode(['int256'], [amountOutMin.toBN()])) + } + const txn = this.sdk.transactions.prepareContractTxn(smartMarginAccountContract, 'execute', [ - [AccountExecuteFunctions.ACCOUNT_MODIFY_MARGIN], - [defaultAbiCoder.encode(['int256'], [amount.toBN()])], + commands, + inputs, ]) if (isPrepareOnly) { @@ -977,6 +1085,20 @@ export default class FuturesService { } } + public async getSwapDepositQuote(token: SwapDepositToken, amount: Wei) { + if (token === SwapDepositToken.SUSD) { + return amount + } + + const quote = await getQuote(token, amount) + + if (!quote) { + throw new Error('Could not get Uniswap quote for swap deposit token balance') + } + + return quote + } + /** * @desc Modify the margin for a specific market in a smart margin account * @param address Smart margin account address @@ -1399,9 +1521,11 @@ export default class FuturesService { if (order.marginDelta.gt(0)) { const totalFreeMargin = freeMargin.add(idleMargin.marketsTotal) - const depositAmount = order.marginDelta.gt(totalFreeMargin) + + let depositAmount = order.marginDelta.gt(totalFreeMargin) ? order.marginDelta.sub(totalFreeMargin).abs() : wei(0) + if (depositAmount.gt(0)) { // If there's not enough idle margin to cover the margin delta we pull it from the wallet commands.push(AccountExecuteFunctions.ACCOUNT_MODIFY_MARGIN) @@ -1833,4 +1957,24 @@ export default class FuturesService { return { commands, inputs, idleMargin } } + + private async signPermit(smartMarginAddress: string, tokenAddress: string) { + // If we don't have enough from idle market margin then we pull from the wallet + const walletAddress = await this.sdk.context.signer.getAddress() + + // Skip amount, we will use the permit to approve the max amount + const data = await getPermit2TypedData( + this.sdk.context.provider, + tokenAddress, + walletAddress, + smartMarginAddress + ) + + const signedMessage = await this.sdk.transactions.signTypedData(data) + + return { + command: AccountExecuteFunctions.PERMIT2_PERMIT, + input: defaultAbiCoder.encode([PERMIT_STRUCT, 'bytes'], [data.values, signedMessage]), + } + } } diff --git a/packages/sdk/src/services/transactions.ts b/packages/sdk/src/services/transactions.ts index 9eca8f8002..32015853b2 100644 --- a/packages/sdk/src/services/transactions.ts +++ b/packages/sdk/src/services/transactions.ts @@ -1,7 +1,7 @@ import { getContractFactory, predeploys } from '@eth-optimism/contracts' import { BigNumber } from '@ethersproject/bignumber' import { wei } from '@synthetixio/wei' -import { ethers } from 'ethers' +import { TypedDataDomain, TypedDataField, ethers } from 'ethers' import { omit, clone } from 'lodash' import KwentaSDK from '..' @@ -38,42 +38,45 @@ export default class TransactionsService { return emitter } - watchTransaction(transactionHash: string, emitter: Emitter): void { + async watchTransaction(transactionHash: string, emitter: Emitter) { emitter.emit(TRANSACTION_EVENTS_MAP.txSent, { transactionHash }) - this.sdk.context.provider - .waitForTransaction(transactionHash) - .then(({ status, blockNumber, transactionHash }) => { - if (status === 1) { - emitter.emit(TRANSACTION_EVENTS_MAP.txConfirmed, { - status, + + const { + status, + blockNumber, + transactionHash: hash, + } = await this.sdk.context.provider.waitForTransaction(transactionHash) + + if (status === 1) { + emitter.emit(TRANSACTION_EVENTS_MAP.txConfirmed, { + status, + blockNumber, + transactionHash: hash, + }) + } else { + setTimeout(async () => { + const { chainId } = await this.sdk.context.provider.getNetwork() + + try { + const revertReason = await getRevertReason({ + txHash: transactionHash, + networkId: chainId, blockNumber, + provider: this.sdk.context.provider, + }) + + emitter.emit(TRANSACTION_EVENTS_MAP.txFailed, { + transactionHash, + failureReason: revertReason, + }) + } catch (e) { + emitter.emit(TRANSACTION_EVENTS_MAP.txFailed, { transactionHash, + failureReason: 'Transaction reverted for an unknown reason', }) - } else { - setTimeout(() => { - this.sdk.context.provider.getNetwork().then(({ chainId }) => { - try { - getRevertReason({ - txHash: transactionHash, - networkId: chainId, - blockNumber, - provider: this.sdk.context.provider, - }).then((revertReason) => - emitter.emit(TRANSACTION_EVENTS_MAP.txFailed, { - transactionHash, - failureReason: revertReason, - }) - ) - } catch (e) { - emitter.emit(TRANSACTION_EVENTS_MAP.txFailed, { - transactionHash, - failureReason: 'Transaction reverted for an unknown reason', - }) - } - }) - }, 5000) } - }) + }, 5000) + } } public createContractTxn( @@ -173,4 +176,16 @@ export default class TransactionsService { public getGasPrice() { return getEthGasPrice(this.sdk.context.networkId, this.sdk.context.provider) } + + public async signTypedData({ + domain, + types, + values, + }: { + domain: TypedDataDomain + types: Record> + values: Record + }) { + return this.sdk.context.signer._signTypedData(domain, types, values) + } } diff --git a/packages/sdk/src/types/futures.ts b/packages/sdk/src/types/futures.ts index 3e25e81c46..af1cfedd6d 100644 --- a/packages/sdk/src/types/futures.ts +++ b/packages/sdk/src/types/futures.ts @@ -550,6 +550,8 @@ export enum AccountExecuteFunctions { PERPS_V2_CANCEL_OFFCHAIN_DELAYED_ORDER = 11, GELATO_PLACE_CONDITIONAL_ORDER = 12, GELATO_CANCEL_CONDITIONAL_ORDER = 13, + UNISWAP_V3_SWAP = 14, + PERMIT2_PERMIT = 15, } export type MarginTransfer = { @@ -630,6 +632,14 @@ export type PerpsV3SubgraphMarket = { takerFee: string } +export enum SwapDepositToken { + SUSD = 'SUSD', + USDC = 'USDC', + // USDT = 'USDT', + DAI = 'DAI', + // LUSD = 'LUSD', +} + export interface FuturesTradeByReferral { timestamp: string account: string @@ -643,9 +653,17 @@ export type PrepareTxParams = { export type ApproveSmartMarginDepositParams = PrepareTxParams & { address: string + token: SwapDepositToken amount?: BigNumber } +export type DepositSmartMarginParams = PrepareTxParams & { + address: string + amount: Wei + token: SwapDepositToken + slippage?: number +} + export type ChangeMarketBalanceParams = PrepareTxParams & { address: string amount: Wei @@ -700,6 +718,11 @@ export type SubmitSmartMarginOrderParams = Prepar options?: { cancelPendingReduceOrders?: boolean cancelExpiredDelayedOrders?: boolean + swapDeposit?: { + token: SwapDepositToken + amountIn: Wei + amountOutMin: Wei + } } } diff --git a/packages/sdk/src/utils/futures.ts b/packages/sdk/src/utils/futures.ts index b0201d5a99..ba69aba002 100644 --- a/packages/sdk/src/utils/futures.ts +++ b/packages/sdk/src/utils/futures.ts @@ -49,6 +49,7 @@ import { PerpsV3SettlementStrategy, SettlementSubgraphType, PerpsMarketV2, + SwapDepositToken, } from '../types/futures' import { formatCurrency, formatDollars, weiFromWei } from '../utils/number' import { @@ -63,6 +64,9 @@ import { import { PerpsV2MarketData } from '../contracts/types' import { IPerpsV2MarketSettings } from '../contracts/types/PerpsV2MarketData' import { AsyncOrder } from '../contracts/types/PerpsV3MarketProxy' +import { ethers } from 'ethers' +import { ADDRESSES } from '../constants' +import axios from 'axios' export const getFuturesEndpoint = (networkId: number) => { return FUTURES_ENDPOINTS[networkId] || FUTURES_ENDPOINTS[10] @@ -1060,3 +1064,66 @@ export const formatPerpsV2Market = ( export const sameSide = (a: Wei, b: Wei) => { return a.gt(wei(0)) === b.gt(wei(0)) } + +export const getDecimalsForSwapDepositToken = (token: SwapDepositToken) => { + return ['USDT', 'USDC'].includes(token) ? 6 : 18 +} + +const SUSD_ADDRESS = ADDRESSES.SUSD['10'] + +export const getQuote = async (token: SwapDepositToken, amountIn: Wei) => { + const tokenAddress = ADDRESSES[token]['10'] + const decimals = getDecimalsForSwapDepositToken(token) + const amountString = wei(amountIn, decimals).toString(0, true) + + const response = await axios.get(`${process.env.NEXT_PUBLIC_SERVICES_PROXY}/0x/swap/v1/quote`, { + headers: { + accept: 'application/json', + }, + params: { + buyToken: SUSD_ADDRESS, + sellToken: tokenAddress, + sellAmount: amountString, + excludedSources: EXCLUDED_SOURCES.join(','), + }, + }) + + return wei(ethers.utils.formatUnits(response.data.buyAmount, 18).toString()) +} + +const EXCLUDED_SOURCES = [ + 'Native', + 'Uniswap', + 'Uniswap_V2', + 'Eth2Dai', + 'Kyber', + 'Curve', + 'LiquidityProvider', + 'MultiBridge', + 'Balancer', + 'Balancer_V2', + 'CREAM', + 'Bancor', + 'MakerPsm', + 'mStable', + 'Mooniswap', + 'MultiHop', + 'Shell', + 'Swerve', + 'SnowSwap', + 'SushiSwap', + 'DODO', + 'DODO_V2', + 'CryptoCom', + 'Linkswap', + 'KyberDMM', + 'Smoothy', + 'Component', + 'Saddle', + 'xSigma', + // 'Uniswap_V3', + 'Curve_V2', + 'Lido', + 'ShibaSwap', + 'Clipper', +] diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils/index.ts index 72695a8a25..8cd73983bf 100644 --- a/packages/sdk/src/utils/index.ts +++ b/packages/sdk/src/utils/index.ts @@ -13,3 +13,4 @@ export * from './subgraph' export * from './synths' export * from './system' export * from './transactions' +export * from './permit2' diff --git a/packages/sdk/src/utils/permit2.ts b/packages/sdk/src/utils/permit2.ts new file mode 100644 index 0000000000..58d731b5e6 --- /dev/null +++ b/packages/sdk/src/utils/permit2.ts @@ -0,0 +1,60 @@ +import { BigNumber } from 'ethers' +import { PERMIT2_ADDRESS } from '../constants' +import { Provider } from '@ethersproject/providers' +import { + AllowanceProvider, + MaxUint48, + MaxUint160, + AllowanceTransfer, + PermitSingle, +} from '@uniswap/permit2-sdk' + +const getPermit2Nonce = async ( + provider: Provider, + owner: string, + token: string, + spender: string +): Promise => { + const allowanceProvider = new AllowanceProvider(provider, PERMIT2_ADDRESS) + const allowance = await allowanceProvider.getAllowanceData(token, owner, spender) + return allowance.nonce +} + +const getPermit2Amount = async ( + provider: Provider, + owner: string, + token: string, + spender: string +): Promise => { + const allowanceProvider = new AllowanceProvider(provider, PERMIT2_ADDRESS) + const allowance = await allowanceProvider.getAllowanceData(token, owner, spender) + return allowance.amount +} + +const getPermit2TypedData = async ( + provider: Provider, + tokenAddress: string, + owner: string, + spender: string, + amount?: BigNumber, + deadline?: BigNumber +) => { + const chainId = (await provider.getNetwork()).chainId + + const details = { + token: tokenAddress, + amount: amount?.toHexString() ?? MaxUint160.toHexString(), + expiration: deadline?.toHexString() ?? MaxUint48.toHexString(), + nonce: await getPermit2Nonce(provider, owner, tokenAddress, spender), + } + + const message: PermitSingle = { + details, + spender, + sigDeadline: deadline?.toHexString() ?? MaxUint48.toHexString(), + } + + return AllowanceTransfer.getPermitData(message, PERMIT2_ADDRESS, chainId) +} + +export { getPermit2TypedData, getPermit2Amount } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ee1470037..eac014e5f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,6 +360,9 @@ importers: '@ethersproject/abi': specifier: ^5.7.0 version: 5.7.0 + '@ethersproject/abstract-signer': + specifier: ^5.7.0 + version: 5.7.0 '@ethersproject/bignumber': specifier: ^5.7.0 version: 5.7.0 @@ -381,6 +384,9 @@ importers: '@synthetixio/wei': specifier: ^2.74.4 version: 2.74.4 + '@uniswap/permit2-sdk': + specifier: ^1.2.0 + version: 1.2.0 axios: specifier: 0.27.2 version: 0.27.2 @@ -785,7 +791,7 @@ packages: '@babel/helper-plugin-utils': 7.22.5 debug: 4.3.4(supports-color@5.5.0) lodash.debounce: 4.0.8 - resolve: 1.22.2 + resolve: 1.22.4 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -801,7 +807,7 @@ packages: '@babel/helper-plugin-utils': 7.22.5 debug: 4.3.4(supports-color@5.5.0) lodash.debounce: 4.0.8 - resolve: 1.22.2 + resolve: 1.22.4 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -817,7 +823,7 @@ packages: '@babel/helper-plugin-utils': 7.22.5 debug: 4.3.4(supports-color@5.5.0) lodash.debounce: 4.0.8 - resolve: 1.22.2 + resolve: 1.22.4 transitivePeerDependencies: - supports-color dev: true @@ -4613,7 +4619,7 @@ packages: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: eslint: 8.47.0 - eslint-visitor-keys: 3.4.2 + eslint-visitor-keys: 3.4.3 /@eslint-community/regexpp@4.6.2: resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==} @@ -6098,8 +6104,8 @@ packages: resolution: {integrity: sha512-E2i07xDale6CMwIoyvafRRWwIkfTnkuN7s3BGY+vOaSmBP7xvFAukRWRtgFs4M8OnYTbI2jxuAruw7uJPRs1ww==} dev: false - /@socket.tech/ll-core@0.1.47: - resolution: {integrity: sha512-JSCiWdWfpGaJWAQUwaZYUJPkFmaZQg8Q8EVzg7ZL1m6bbtwI0ass/dZDhwV0kbtElUeEmJF26hpdT+ApSpNTXw==} + /@socket.tech/ll-core@0.1.44: + resolution: {integrity: sha512-kSQpArl54ja107ke2C1jJCZzkllGzkXZVB+OSjcqOz9eMzr4Ffw2zYxqYfLrfxAPsQmxIc1FvCNtD70mLJZWFw==} dev: false /@socket.tech/plugin@1.2.1(@types/react-dom@18.2.7)(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1): @@ -6111,7 +6117,7 @@ packages: '@floating-ui/react': 0.25.0(react-dom@18.2.0)(react@18.2.0) '@react-spring/web': 9.7.3(react-dom@18.2.0)(react@18.2.0) '@reduxjs/toolkit': 1.9.5(react-redux@8.1.2)(react@18.2.0) - '@socket.tech/ll-core': 0.1.47 + '@socket.tech/ll-core': 0.1.44 '@socket.tech/socket-v2-sdk': 1.23.1 ethers: 5.7.2 react: 18.2.0 @@ -6132,7 +6138,7 @@ packages: /@socket.tech/socket-v2-sdk@1.23.1: resolution: {integrity: sha512-x79vgmEuFHrK5cLnbsGR1fmxkUUuhl/0IHsvMCoaOxlCmwmZAfTt2ICxC8EyRGiEjVh1gLYrbCOYWgDXw362mQ==} dependencies: - '@socket.tech/ll-core': 0.1.47 + '@socket.tech/ll-core': 0.1.44 '@socket.tech/ll-core-v2': 0.0.32 axios: 0.27.2 ethers: 5.7.2 @@ -8531,6 +8537,16 @@ packages: '@typescript-eslint/types': 6.4.1 eslint-visitor-keys: 3.4.2 + /@uniswap/permit2-sdk@1.2.0: + resolution: {integrity: sha512-Ietv3FxN7+RCXcPSED/i/8b0a2GUZrMdyX05k3FsSztvYKyPFAMS/hBXojF0NZqYB1bHecqYc7Ej+7tV/rdYXg==} + dependencies: + ethers: 5.7.2 + tiny-invariant: 1.3.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + /@vanilla-extract/css@1.9.1: resolution: {integrity: sha512-pu2SFiff5jRhPwvGoj8cM5l/qIyLvigOmy22ss5DGjwV5pJYezRjDLxWumi2luIwioMWvh9EozCjyfH8nq+7fQ==} dependencies: @@ -9240,12 +9256,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - /acorn@8.9.0: - resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: false - /address@1.2.2: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} engines: {node: '>= 10.0.0'} @@ -9728,9 +9738,9 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.22.6 cosmiconfig: 7.1.0 - resolve: 1.22.2 + resolve: 1.22.4 /babel-plugin-named-exports-order@0.0.2: resolution: {integrity: sha512-OgOYHOLoRK+/mvXU9imKHlG6GkPLYrUCvFXG/CM93R/aNNO8pOOF4aS+S8CCHMDQoNSeiOYEZb/G6RwL95Jktw==} @@ -10169,7 +10179,7 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001511 + caniuse-lite: 1.0.30001518 electron-to-chromium: 1.4.449 node-releases: 2.0.12 update-browserslist-db: 1.0.11(browserslist@4.21.9) @@ -10318,9 +10328,6 @@ packages: resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} dev: false - /caniuse-lite@1.0.30001511: - resolution: {integrity: sha512-NaWPJawcoedlghN4P7bDNeADD7K+rZaY6V8ZcME7PkEZo/nfOg+lnrUgRWiKbNxcQ4/toFKSxnS4WdbyPZnKkw==} - /caniuse-lite@1.0.30001518: resolution: {integrity: sha512-rup09/e3I0BKjncL+FesTayKtPrdwKhUufQFd3riFw1hHg8JmIFoInYfB102cFcY/pPgGmdyl/iy+jgiDi2vdA==} @@ -11960,8 +11967,8 @@ packages: resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==} dependencies: debug: 3.2.7 - is-core-module: 2.12.1 - resolve: 1.22.3 + is-core-module: 2.13.0 + resolve: 1.22.4 transitivePeerDependencies: - supports-color @@ -11979,7 +11986,7 @@ packages: eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.4.1)(eslint-import-resolver-typescript@3.5.5)(eslint@8.47.0) get-tsconfig: 4.6.2 globby: 13.2.1 - is-core-module: 2.12.1 + is-core-module: 2.13.0 is-glob: 4.0.3 synckit: 0.8.5 transitivePeerDependencies: @@ -12950,7 +12957,7 @@ packages: engines: {node: '>=14'} dependencies: cross-spawn: 7.0.3 - signal-exit: 4.0.2 + signal-exit: 4.1.0 dev: true /fork-ts-checker-webpack-plugin@7.3.0(typescript@5.1.6)(webpack@5.88.1): @@ -13068,7 +13075,7 @@ packages: dependencies: call-bind: 1.0.2 define-properties: 1.2.0 - es-abstract: 1.21.2 + es-abstract: 1.22.1 functions-have-names: 1.2.3 /functions-have-names@1.2.3: @@ -13209,7 +13216,7 @@ packages: hasBin: true dependencies: foreground-child: 3.1.1 - jackspeak: 2.2.1 + jackspeak: 2.2.2 minimatch: 9.0.3 minipass: 5.0.0 path-scurry: 1.10.1 @@ -13828,11 +13835,6 @@ packages: ci-info: 2.0.0 dev: true - /is-core-module@2.12.1: - resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} - dependencies: - has: 1.0.3 - /is-core-module@2.13.0: resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==} dependencies: @@ -14180,8 +14182,8 @@ packages: has-tostringtag: 1.0.0 reflect.getprototypeof: 1.0.3 - /jackspeak@2.2.1: - resolution: {integrity: sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==} + /jackspeak@2.2.2: + resolution: {integrity: sha512-mgNtVv4vUuaKA97yxUHoA3+FkuhtxkjdXEWOyB/N76fjy0FjezEt34oy3epBtvCvS+7DyKwqCFWx/oJLV5+kCg==} engines: {node: '>=14'} dependencies: '@isaacs/cliui': 8.0.2 @@ -14522,7 +14524,7 @@ packages: jest-pnp-resolver: 1.2.3(jest-resolve@29.6.2) jest-util: 29.6.2 jest-validate: 29.6.2 - resolve: 1.22.2 + resolve: 1.22.4 resolve.exports: 2.0.2 slash: 3.0.0 dev: true @@ -14842,7 +14844,7 @@ packages: optional: true dependencies: abab: 2.0.6 - acorn: 8.9.0 + acorn: 8.10.0 acorn-globals: 7.0.1 cssstyle: 3.0.0 data-urls: 4.0.0 @@ -15799,7 +15801,7 @@ packages: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: hosted-git-info: 2.8.9 - resolve: 1.22.2 + resolve: 1.22.4 semver: 5.7.1 validate-npm-package-license: 3.0.4 dev: true @@ -17334,7 +17336,7 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} dependencies: - resolve: 1.22.2 + resolve: 1.22.4 dev: true /redent@3.0.0: @@ -17578,12 +17580,13 @@ packages: resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} hasBin: true dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + dev: true - /resolve@1.22.3: - resolution: {integrity: sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==} + /resolve@1.22.4: + resolution: {integrity: sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==} hasBin: true dependencies: is-core-module: 2.13.0 @@ -17594,7 +17597,7 @@ packages: resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==} hasBin: true dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -17970,8 +17973,8 @@ packages: /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - /signal-exit@4.0.2: - resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} dev: true @@ -18255,21 +18258,21 @@ packages: dependencies: call-bind: 1.0.2 define-properties: 1.2.0 - es-abstract: 1.21.2 + es-abstract: 1.22.1 /string.prototype.trimend@1.0.6: resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} dependencies: call-bind: 1.0.2 define-properties: 1.2.0 - es-abstract: 1.21.2 + es-abstract: 1.22.1 /string.prototype.trimstart@1.0.6: resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==} dependencies: call-bind: 1.0.2 define-properties: 1.2.0 - es-abstract: 1.21.2 + es-abstract: 1.22.1 /string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -18662,6 +18665,10 @@ packages: setimmediate: 1.0.5 dev: true + /tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + dev: false + /tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} dev: false