diff --git a/.gitignore b/.gitignore index 19897a16e..c5cefc9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,6 @@ mochawesome-report **/generated/**/* !src/analytics/generated/**/* !src/pages/Swap/LimitOrderBox/generated/**/* +!src/pages/Swap/LimitOrder/generated/**/* storybook-static diff --git a/src/pages/Swap/Components/Tabs.tsx b/src/pages/Swap/Components/Tabs.tsx index 853fc2f61..758c88366 100644 --- a/src/pages/Swap/Components/Tabs.tsx +++ b/src/pages/Swap/Components/Tabs.tsx @@ -95,6 +95,16 @@ export function Tabs() { Swap + + setActiveTab(SwapTab.LIMIT_ORDER_NEW)} + title="Limit Order" + className={activeTab === SwapTab.LIMIT_ORDER_NEW ? 'active' : ''} + > + + {t('tabs.limit')} + + { diff --git a/src/pages/Swap/LimitOrder/Components/ApprovalFlow.tsx b/src/pages/Swap/LimitOrder/Components/ApprovalFlow.tsx new file mode 100644 index 000000000..ef94eb17e --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/ApprovalFlow.tsx @@ -0,0 +1,33 @@ +import { ButtonPrimary } from '../../../../components/Button' +import { Loader } from '../../../../components/Loader' +import ProgressSteps from '../../../../components/ProgressSteps' +import { ApprovalState } from '../../../../hooks/useApproveCallback' + +import { AutoRow } from './AutoRow' + +interface ApprovalFlowProps { + approval: ApprovalState + approveCallback: () => Promise + tokenInSymbol: string +} + +export const ApprovalFlow = ({ approval, approveCallback, tokenInSymbol }: ApprovalFlowProps) => ( + <> + + {approval === ApprovalState.PENDING ? ( + + Approving + + ) : ( + 'Approve ' + tokenInSymbol + )} + + + + + > +) diff --git a/src/pages/Swap/LimitOrder/Components/AutoRow.tsx b/src/pages/Swap/LimitOrder/Components/AutoRow.tsx new file mode 100644 index 000000000..a45f8f27a --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/AutoRow.tsx @@ -0,0 +1,12 @@ +import styled from 'styled-components' + +import { AutoRow as AutoRowBase } from '../../../../components/Row' + +export const AutoRow = styled(AutoRowBase)` + gap: 12px; + justify-items: space-between; + flex-wrap: nowrap; + > div { + width: 50%; + } +` diff --git a/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/ConfirmationFooter.tsx b/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/ConfirmationFooter.tsx new file mode 100644 index 000000000..a4338d45b --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/ConfirmationFooter.tsx @@ -0,0 +1,59 @@ +import styled, { useTheme } from 'styled-components' + +import { ButtonPrimary } from '../../../../../components/Button' +import { StyledKey, StyledValue } from '../../../Components/SwapModalFooter' + +export type FooterData = { + askPrice: string + marketPriceDifference: string + isDiffPositive: boolean + expiresIn: string + market: string + onConfirm: () => void +} + +export const ConfirmationFooter = ({ + askPrice, + onConfirm, + expiresIn, + marketPriceDifference, + isDiffPositive, + market, +}: FooterData) => { + const theme = useTheme() + const priceDiffColor = isDiffPositive ? theme.green1 : theme.red1 + + return ( + + + Ask price + {askPrice} + + + Diff. market price + {marketPriceDifference}% + + + Expires in + {expiresIn} + + + Market + {market} + + + PLACE LIMIT ORDER + + + ) +} + +const Wrapper = styled.div` + display: flex; + gap: 7px; + flex-direction: column; +` +const SingleRow = styled.div` + display: flex; + justify-content: space-between; +` diff --git a/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/ConfirmationHeader.tsx b/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/ConfirmationHeader.tsx new file mode 100644 index 000000000..e3e3d30a0 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/ConfirmationHeader.tsx @@ -0,0 +1,105 @@ +import { CurrencyAmount, TokenAmount } from '@swapr/sdk' + +import { ArrowDown } from 'react-feather' +import styled from 'styled-components' + +import { CurrencyLogo } from '../../../../../components/CurrencyLogo' +import { toFixedSix } from '../utils' + +export type HeaderData = { + fiatValueInput: CurrencyAmount | null + fiatValueOutput: CurrencyAmount | null + buyToken: TokenAmount + sellToken: TokenAmount +} + +export const ConfirmationHeader = ({ fiatValueInput, fiatValueOutput, buyToken, sellToken }: HeaderData) => { + const fiatInput = fiatValueInput && fiatValueInput.toFixed(2, { groupSeparator: ',' }) + const fiatOutput = fiatValueOutput && fiatValueOutput.toFixed(2, { groupSeparator: ',' }) + + return ( + + + + YOU SWAP + + {sellToken.currency.symbol}{' '} + + + + + {toFixedSix(Number(sellToken.toExact()))} + {fiatInput && ${fiatInput}} + + + + + + YOU RECIEVE + + {buyToken.currency.symbol}{' '} + + + + + {toFixedSix(Number(buyToken.toExact()))} + {fiatOutput && ${fiatOutput}} + + + + ) +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + margin-top: 20px; +` +const CurrencyAmountContainer = styled.div` + display: flex; + padding: 0 19px; + align-items: center; + justify-content: space-between; + height: 82px; + border: 1px solid ${({ theme }) => theme.bg3}; + border-radius: 8.72381px; +` +const CurrencySymbol = styled.div` + font-weight: 600; + font-size: 20px; + line-height: 24px; +` +const LogoWithText = styled.div` + display: flex; + align-items: center; + gap: 6px; +` +const CurrencyLogoInfo = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +` + +const AmountWithUsd = styled.div` + display: flex; + flex-direction: column; + align-items: end; + gap: 4px; +` +const Amount = styled.div` + font-weight: 600; + font-size: 20px; + line-height: 28px; +` +const PurpleText = styled.div` + font-weight: 600; + font-size: 10px; + line-height: 12px; + letter-spacing: 0.08em; + color: ${({ theme }) => theme.purple3}; +` +const StyledArrow = styled(ArrowDown)` + width: 100%; + margin: 4px 0; + height: 16px; +` diff --git a/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/index.tsx b/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/index.tsx new file mode 100644 index 000000000..4c8db6b97 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/ConfirmLimitOrderModal/index.tsx @@ -0,0 +1,111 @@ +import { CurrencyAmount } from '@swapr/sdk' + +import { useCallback } from 'react' + +import TransactionConfirmationModal, { + ConfirmationModalContent, + TransactionErrorContent, +} from '../../../../../components/TransactionConfirmationModal' +import { Kind, LimitOrderBase, MarketPrices, Providers } from '../../../../../services/LimitOrders' +import { calculateMarketPriceDiffPercentage } from '../utils' + +import { ConfirmationFooter } from './ConfirmationFooter' +import { ConfirmationHeader } from './ConfirmationHeader' + +interface ConfirmLimitOrderModalProps { + isOpen: boolean + attemptingTxn: boolean + errorMessage: string | undefined + onDismiss: () => void + onConfirm: () => void + marketPrices: MarketPrices + fiatValueInput: CurrencyAmount | null + fiatValueOutput: CurrencyAmount | null + protocol: LimitOrderBase +} + +export default function ConfirmLimitOrderModal({ + onConfirm, + onDismiss, + errorMessage, + isOpen, + attemptingTxn, + marketPrices, + fiatValueInput, + fiatValueOutput, + + protocol, +}: ConfirmLimitOrderModalProps) { + const { + buyAmount, + sellAmount, + limitPrice, + expiresAt, + expiresAtUnit, + kind, + limitOrderProtocol = Providers.COW, + } = protocol + + const modalHeader = useCallback(() => { + return ( + + ) + }, [fiatValueInput, fiatValueOutput, buyAmount, sellAmount]) + + const [baseTokenAmount, quoteTokenAmount] = kind === Kind.Sell ? [sellAmount, buyAmount] : [buyAmount, sellAmount] + const askPrice = `${kind} ${baseTokenAmount?.currency?.symbol} at ${limitPrice} ${quoteTokenAmount?.currency?.symbol}` + + let { marketPriceDiffPercentage, isDiffPositive } = calculateMarketPriceDiffPercentage( + kind ?? Kind.Sell, + marketPrices, + limitPrice! + ) + const expiresInFormatted = `${expiresAt} ${expiresAtUnit}` + + const modalBottom = useCallback(() => { + return onConfirm ? ( + + ) : null + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [marketPriceDiffPercentage, isDiffPositive, onConfirm, askPrice, expiresInFormatted]) + + // text to show while loading + const pendingText = 'Confirm Signature' + + const confirmationContent = useCallback( + () => + errorMessage ? ( + + ) : ( + + ), + [onDismiss, modalBottom, modalHeader, errorMessage] + ) + + return ( + + ) +} diff --git a/src/pages/Swap/LimitOrder/Components/InputGroup/InputGroup.tsx b/src/pages/Swap/LimitOrder/Components/InputGroup/InputGroup.tsx new file mode 100644 index 000000000..d34604283 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/InputGroup/InputGroup.tsx @@ -0,0 +1,61 @@ +import { ButtonHTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes } from 'react' + +import { StyledButtonAddonsInnerWrapper, StyledInnerWrapper, StyledInput, StyledLabel, Wrapper } from './styled' + +export interface ReactComponentPropsBase { + children: React.ReactNode +} + +export type InputGroupProps = ReactComponentPropsBase + +export function InputGroup({ children }: InputGroupProps) { + return {children} +} + +export type InputProps = Omit, 'children'> + +export function Input(props: InputProps) { + return +} + +export type InputGroupLabelProps = LabelHTMLAttributes + +export function Label(props: InputGroupLabelProps) { + return +} + +export type InnerWrapperProps = ReactComponentPropsBase + +export function InnerWrapper(props: InnerWrapperProps) { + return +} + +export type ButtonAddonsWrapperProps = ReactComponentPropsBase + +/** + * + * @returns + */ +export function ButtonAddonsWrapper(props: ButtonAddonsWrapperProps) { + return {props.children} +} + +export type AddonButtonProps = ReactComponentPropsBase & + Partial, 'onClick'>> + +/** + * The AddonButton is the base component for all addon buttons. + * Extend this component to create new addon buttons. + */ +export function AddonButton(props: AddonButtonProps) { + return <>{props.children}> +} + +/** + * + */ +InputGroup.Input = Input +InputGroup.Label = Label +InputGroup.InnerWrapper = InnerWrapper +InputGroup.ButtonAddonsWrapper = ButtonAddonsWrapper +InputGroup.AddonButton = AddonButton diff --git a/src/pages/Swap/LimitOrder/Components/InputGroup/index.ts b/src/pages/Swap/LimitOrder/Components/InputGroup/index.ts new file mode 100644 index 000000000..c5658db94 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/InputGroup/index.ts @@ -0,0 +1 @@ +export * from './InputGroup' diff --git a/src/pages/Swap/LimitOrder/Components/InputGroup/styled.ts b/src/pages/Swap/LimitOrder/Components/InputGroup/styled.ts new file mode 100644 index 000000000..ebef67c34 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/InputGroup/styled.ts @@ -0,0 +1,61 @@ +import styled from 'styled-components' + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; +` + +export const StyledInnerWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: flex-start; + padding: 12px 16px; + gap: 10px; + background: #1d202f; + border-radius: 8px; + position: relative; +` + +export const StyledButtonAddonsWrapper = styled.div` + position: absolute; + top: 0; + right: 0; + bottom: 0; +` + +export const StyledButtonAddonsInnerWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; +` + +export const StyledLabel = styled.label` + font-weight: 600; + font-size: 10px; + line-height: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #8c83c0; + margin-bottom: 12px; +` + +export const StyledInput = styled.input` + width: 100%; + flex-grow: 1; + background: transparent; + outline: none; + border: none; + + /* Chrome, Safari, Edge, Opera */ + ::-webkit-outer-spin-button, + ::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* Firefox */ + &[type='number'] { + -moz-appearance: textfield; + } +` diff --git a/src/pages/Swap/LimitOrder/Components/LimitFallback/LimitFallback.tsx b/src/pages/Swap/LimitOrder/Components/LimitFallback/LimitFallback.tsx new file mode 100644 index 000000000..e49442176 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/LimitFallback/LimitFallback.tsx @@ -0,0 +1,14 @@ +import { ButtonPrimary } from '../../../../../components/Button' +import { useWalletSwitcherPopoverToggle } from '../../../../../state/application/hooks' + +import { Text } from './styled' + +export default function LimitOrderFallback() { + const toggleWalletModal = useWalletSwitcherPopoverToggle() + return ( + <> + Please connect wallet to access Limit Order + Connect Wallet + > + ) +} diff --git a/src/pages/Swap/LimitOrder/Components/LimitFallback/index.ts b/src/pages/Swap/LimitOrder/Components/LimitFallback/index.ts new file mode 100644 index 000000000..d5da28f3c --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/LimitFallback/index.ts @@ -0,0 +1 @@ +export { default } from './LimitFallback' diff --git a/src/pages/Swap/LimitOrder/Components/LimitFallback/styled.ts b/src/pages/Swap/LimitOrder/Components/LimitFallback/styled.ts new file mode 100644 index 000000000..d687312b8 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/LimitFallback/styled.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components' + +export const Text = styled.p` + display: flex; + justify-content: center; + margin: 20px 0px 30px 0px; +` diff --git a/src/pages/Swap/LimitOrder/Components/MaxAlert.tsx b/src/pages/Swap/LimitOrder/Components/MaxAlert.tsx new file mode 100644 index 000000000..535868316 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/MaxAlert.tsx @@ -0,0 +1,12 @@ +import styled from 'styled-components' + +export const MaxAlert = styled.div` + background-color: ${({ theme }) => theme.orange1}; + display: flex; + font-size: 13px; + padding: 10px; + margin: 10px 0px; + color: ${({ theme }) => theme.purpleBase}; + border-radius: 5px; + justify-content: center; +` diff --git a/src/pages/Swap/LimitOrder/Components/OrderExpiryField.tsx b/src/pages/Swap/LimitOrder/Components/OrderExpiryField.tsx new file mode 100644 index 000000000..b9f1d2a5b --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/OrderExpiryField.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { OrderExpiresInUnit, LimitOrderBase } from '../../../../services/LimitOrders' + +import { ButtonAddonsWrapper, InnerWrapper, Input, InputGroup, Label } from './InputGroup' + +const invalidChars = ['-', '+', 'e'] + +export const ExpiryUnitButton = styled.span<{ + isActive: boolean +}>` + color: #464366; + cursor: pointer; + ${({ isActive }) => isActive && `color: #8780BF;`}; + min-height: 22px; +` + +const ExpiryLabels = styled(Label)` + display: flex; + justify-content: space-between; + align-items: center; + min-height: 19px; +` + +export const MaxExpiryTime = styled.button` + font-size: 11px; + color: #8780bf; + border: none; + cursor: pointer; + background-color: #2d3145; + border-radius: 5px; + text-transform: uppercase; + padding: 3px 8px; + &:hover { + color: #736f96; + } +` + +export function OrderExpiryField({ protocol }: { protocol: LimitOrderBase }) { + const { expiresAt, expiresAtUnit } = protocol + + const [inputExpiresIn, setInputExpiresIn] = useState(expiresAt) + const [inputExpiresUnit, setInputExpiresUnit] = useState(expiresAtUnit) + const { t } = useTranslation('swap') + + const expiresInChangeHandler: React.ChangeEventHandler = event => { + const newExpiresIn = event.target.value + // Update local state + if (newExpiresIn === '') { + setInputExpiresIn(newExpiresIn) + } else { + const value = parseFloat(newExpiresIn ?? 0) + //Don't want to set negative time + if (value >= 0) { + // Max time can be set to 180 minutes only + // Update this once CoW supports future time + setInputExpiresIn(value) + } + } + } + + useEffect(() => { + if (inputExpiresIn !== '' && parseFloat(inputExpiresIn.toString()) > 0) { + protocol.onExpireChange(parseFloat(inputExpiresIn.toString())) + } + }, [inputExpiresIn, protocol]) + + useEffect(() => { + protocol.onExpireUnitChange(inputExpiresUnit) + }, [inputExpiresUnit, protocol]) + + return ( + + + {t('limitOrder.expiresIn')} + + + { + if (invalidChars.includes(e.key)) { + e.preventDefault() + } + }} + onBlur={e => { + if (e.target.value === '' || e.target.value === '0') { + setInputExpiresIn(1) + } + }} + type="number" + onChange={expiresInChangeHandler} + required + /> + + setInputExpiresUnit(OrderExpiresInUnit.Minutes)} + > + Min + + setInputExpiresUnit(OrderExpiresInUnit.Days)} + > + Days + + + + + ) +} diff --git a/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/MarketPriceButton.tsx b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/MarketPriceButton.tsx new file mode 100644 index 000000000..d6eaed39a --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/MarketPriceButton.tsx @@ -0,0 +1,14 @@ +import { useTranslation } from 'react-i18next' + +import { MarketPrice, StyledProgressCircle } from './styles' + +export const MarketPriceButton = () => { + const { t } = useTranslation('swap') + + return ( + + {t('limitOrder.marketPrice')} + + + ) +} diff --git a/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/OrderLimitPriceField.tsx b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/OrderLimitPriceField.tsx new file mode 100644 index 000000000..4d50b9d1d --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/OrderLimitPriceField.tsx @@ -0,0 +1,249 @@ +import { Currency, TokenAmount } from '@swapr/sdk' + +import { parseUnits } from 'ethers/lib/utils' +import { useEffect, useRef, useState } from 'react' +import { RefreshCw } from 'react-feather' +import { useTranslation } from 'react-i18next' +import { Flex } from 'rebass' + +import { Kind, LimitOrderBase, MarketPrices } from '../../../../../services/LimitOrders' +import { InputGroup } from '../InputGroup' +import { calculateMarketPriceDiffPercentage } from '../utils' + +import { MarketPriceButton } from './MarketPriceButton' +import { + LimitLabel, + MarketPriceDiff, + SetToMarket, + SwapTokenIconWrapper, + SwapTokenWrapper, + ToggleCurrencyButton, + LimitLabelGroup, +} from './styles' + +const invalidChars = ['-', '+', 'e'] + +const getBaseQuoteTokens = ({ kind, sellToken, buyToken }: { kind: Kind; sellToken: Currency; buyToken: Currency }) => { + return kind === Kind.Sell + ? { baseToken: sellToken, quoteToken: buyToken } + : { baseToken: buyToken, quoteToken: sellToken } +} + +interface OrderLimitPriceFieldProps { + protocol: LimitOrderBase + sellAmount: TokenAmount + buyAmount: TokenAmount + kind: Kind + marketPrices: MarketPrices + sellToken: Currency + buyToken: Currency + setSellAmount(t: TokenAmount): void + setBuyAmount(t: TokenAmount): void + setKind(t: Kind): void + setLoading(t: boolean): void + handleGetMarketPrice(): Promise +} + +export function OrderLimitPriceField({ + protocol, + sellAmount, + buyAmount, + kind, + marketPrices, + sellToken, + buyToken, + setSellAmount, + setBuyAmount, + setKind, + setLoading, + handleGetMarketPrice, +}: OrderLimitPriceFieldProps) { + const { t } = useTranslation('swap') + + const quoteRef = useRef() + + const { baseToken, quoteToken } = getBaseQuoteTokens({ kind, sellToken, buyToken }) + + const inputGroupLabel = `${kind} 1 ${baseToken?.symbol} at` + const toggleCurrencyButtonLabel = `${quoteToken?.symbol}` + + useEffect(() => { + const limitPrice = protocol.getLimitPrice() + setInputLimitPrice(Number(limitPrice).toString()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [protocol.limitPrice]) + + useEffect(() => { + quoteRef.current = setInterval(async () => { + setLoading(true) + try { + if (!protocol.userUpdatedLimitPrice) { + await protocol.getQuote() + } + } finally { + setLoading(false) + } + }, 15000) + + if (protocol.userUpdatedLimitPrice) { + setLoading(false) + clearInterval(quoteRef.current) + } + + return () => { + clearInterval(quoteRef.current) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [protocol.userUpdatedLimitPrice]) + + const [inputLimitPrice, setInputLimitPrice] = useState(protocol.limitPrice) + + const { marketPriceDiffPercentage, isDiffPositive } = calculateMarketPriceDiffPercentage( + kind ?? Kind.Sell, + marketPrices, + protocol.limitPrice + ) + + const showPercentage = + Number(marketPriceDiffPercentage.toFixed(1)) !== 0 && Number(marketPriceDiffPercentage) !== -100 + + /** + * Handle the limit price input change. Compute the buy amount and update the state. + */ + const onChangeHandler: React.ChangeEventHandler = event => { + const nextLimitPriceFormatted = event.target.value // When the limit price is empty, set the limit price to 0 + if (nextLimitPriceFormatted.split('.').length > 2) { + event.preventDefault() + return + } + if (nextLimitPriceFormatted === '' || nextLimitPriceFormatted === '0.') { + setInputLimitPrice(nextLimitPriceFormatted) + } else if (nextLimitPriceFormatted === '.') { + setInputLimitPrice('0.') + protocol.onLimitPriceChange('0') + return + } else { + const nextLimitPriceFloat = parseFloat(nextLimitPriceFormatted ?? 0) + if (nextLimitPriceFloat < 0 || isNaN(nextLimitPriceFloat)) { + setInputLimitPrice('0') + protocol.onLimitPriceChange('0') + return + } + + setInputLimitPrice(nextLimitPriceFormatted) + + protocol.onLimitPriceChange(nextLimitPriceFormatted) + protocol.onUserUpadtedLimitPrice(true) + + if (protocol.kind === Kind.Sell) { + protocol.quoteBuyAmount = new TokenAmount( + protocol.buyToken, + parseUnits(nextLimitPriceFormatted, protocol.buyToken.decimals).toString() + ) + protocol.quoteSellAmount = new TokenAmount( + protocol.sellToken, + parseUnits('1', protocol.sellToken.decimals).toString() + ) + } else { + protocol.quoteBuyAmount = new TokenAmount( + protocol.buyToken, + parseUnits('1', protocol.buyToken.decimals).toString() + ) + protocol.quoteSellAmount = new TokenAmount( + protocol.sellToken, + parseUnits(nextLimitPriceFormatted, protocol.sellToken.decimals).toString() + ) + } + const limitPrice = protocol.getLimitPrice() + protocol.onLimitPriceChange(limitPrice) + + if (kind === Kind.Sell) { + const buyAmount = parseFloat(protocol.sellAmount.toExact()) * parseFloat(limitPrice) + + const newBuyTokenAmount = new TokenAmount( + protocol.buyToken, + parseUnits(buyAmount.toFixed(6), protocol.buyToken.decimals).toString() + ) + setBuyAmount(newBuyTokenAmount) + protocol.onBuyAmountChange(newBuyTokenAmount) + } else { + const sellAmount = parseFloat(protocol.buyAmount.toExact()) * parseFloat(limitPrice) + + const newSellTokenAmount = new TokenAmount( + protocol.sellToken, + parseUnits(sellAmount.toFixed(6), protocol.sellToken.decimals).toString() + ) + + setSellAmount(newSellTokenAmount) + protocol.onSellAmountChange(newSellTokenAmount) + } + } + } + + const onClickGetMarketPrice = async () => { + const limitPrice = await handleGetMarketPrice() + setInputLimitPrice(limitPrice) + } + + return ( + + + + + + {inputGroupLabel} + {showPercentage && ( + ({marketPriceDiffPercentage.toFixed(2)}%) + )} + + + + {!protocol.userUpdatedLimitPrice && buyAmount?.currency && sellAmount?.currency ? ( + + ) : ( + {t('limitOrder.getMarketPrice')} + )} + + + + + { + if (invalidChars.includes(e.key)) { + e.preventDefault() + } + }} + onBlur={e => { + if (e.target.value.trim() === '' || e.target.value === '0' || e.target.value === '0.') { + setInputLimitPrice(protocol.limitPrice) + } + }} + value={inputLimitPrice} + onChange={onChangeHandler} + /> + + { + const newKind = kind === Kind.Sell ? Kind.Buy : Kind.Sell + setKind(newKind) + protocol.onKindChange(newKind) + + const limitPrice = protocol.getLimitPrice() + setInputLimitPrice(limitPrice) + }} + > + + {toggleCurrencyButtonLabel} + + + + + + + + + ) +} diff --git a/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/index.ts b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/index.ts new file mode 100644 index 000000000..f61c5c3ba --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/index.ts @@ -0,0 +1 @@ +export * from './OrderLimitPriceField' diff --git a/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/styles.ts b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/styles.ts new file mode 100644 index 000000000..7496b778d --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/styles.ts @@ -0,0 +1,102 @@ +import { Flex } from 'rebass' +import styled from 'styled-components' + +import { ReactComponent as ProgressCircle } from '../../../../../assets/images/progress-circle.svg' + +export const LimitLabel = styled.label` + font-weight: 600; + font-size: 10px; + line-height: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #8c83c0; + margin-bottom: 12px; + display: flex; + justify-content: space-between; + align-items: center; +` + +export const LimitLabelGroup = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +` + +export const SetToMarket = styled.button` + font-size: 10px; + color: #000; + border: none; + cursor: pointer; + background-color: ${({ theme }) => theme.green2}; + border-radius: 5px; + text-transform: uppercase; + padding: 3px 5px; + &:hover { + background-color: #03d48d; + } +` + +export const MarketPrice = styled.button` + display: flex; + justify-content: center; + align-items: center; + font-size: 11px; + color: ${({ theme }) => theme.green2}; + border: 1px solid ${({ theme }) => theme.green2}; + background-color: transparent; + border-radius: 5px; + text-transform: uppercase; + padding: 3px 4px; +` + +export const SwapTokenIconWrapper = styled.div` + margin-left: 4px; + display: flex; + justify-content: center; + align-items: center; + padding: 5px; + width: 24px; + height: 22px; + background: rgba(104, 110, 148, 0.3); + border-radius: 4.25926px; + color: #bcb3f0; +` + +export const SwapTokenWrapper = styled(Flex)` + color: #8780bf; + align-items: center; + &:hover { + color: #736f96; + & > div { + color: #736f96; + } + } +` +export const ToggleCurrencyButton = styled.span` + color: #464366; + cursor: pointer; +` + +export const MarketPriceDiff = styled.span<{ isPositive: boolean }>` + color: ${({ isPositive, theme }) => (isPositive ? theme.green2 : theme.red1)}; +` + +export const StyledProgressCircle = styled(ProgressCircle)` + width: 12px; + height: 12px; + margin-left: 4px; + transform: rotate(-90deg); + + .move { + stroke-dasharray: 100; + stroke-dashoffset: 100; + animation: dash 15s 0s infinite linear forwards; + + @keyframes dash { + to { + stroke-dashoffset: 0; + } + } + } +` diff --git a/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/utils.ts b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/utils.ts new file mode 100644 index 000000000..10db4bc55 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/OrderLimitPriceField/utils.ts @@ -0,0 +1,59 @@ +import { Token, TokenAmount } from '@swapr/sdk' + +import { formatUnits, parseUnits } from 'ethers/lib/utils' + +import { Kind } from '../../../../../services/LimitOrders' + +export interface IComputeNewAmount { + amount: number + buyAmountWei: string + sellAmountWei: string + newBuyTokenAmount: TokenAmount + newSellTokenAmount: TokenAmount +} + +const multiplyPrice = (tokenAmount: number, limitPrice: number) => tokenAmount * limitPrice +const dividePrice = (tokenAmount: number, limitPrice: number) => (limitPrice === 0 ? 0 : tokenAmount / limitPrice) + +const newAmountCalculationSellLimitOrder: Record = { + [Kind.Sell]: multiplyPrice, + [Kind.Buy]: dividePrice, +} +const newAmountCalculationBuyLimitOrder: Record = { + [Kind.Sell]: dividePrice, + [Kind.Buy]: multiplyPrice, +} + +export const computeNewAmount = ( + buyTokenAmount: TokenAmount, + sellTokenAmount: TokenAmount, + limitPrice: number, + limitOrderKind: Kind +): IComputeNewAmount => { + const buyAmountFloat = parseFloat(formatUnits(buyTokenAmount.raw.toString(), buyTokenAmount.currency.decimals)) + const sellAmountFloat = parseFloat(formatUnits(sellTokenAmount.raw.toString(), sellTokenAmount.currency.decimals)) + + let amount = 0 + let newBuyTokenAmount = buyTokenAmount + let newSellTokenAmount = sellTokenAmount + let buyAmountWei = '0' + let sellAmountWei = '0' + + if (limitOrderKind === Kind.Sell) { + amount = newAmountCalculationSellLimitOrder[limitOrderKind](sellAmountFloat, limitPrice) + buyAmountWei = parseUnits(amount.toFixed(6), buyTokenAmount?.currency?.decimals).toString() + newBuyTokenAmount = new TokenAmount(buyTokenAmount.currency as Token, buyAmountWei) + } else { + amount = newAmountCalculationBuyLimitOrder[limitOrderKind](buyAmountFloat, limitPrice) + sellAmountWei = parseUnits(amount.toFixed(6), sellTokenAmount?.currency?.decimals).toString() + newSellTokenAmount = new TokenAmount(sellTokenAmount.currency as Token, sellAmountWei) + } + + return { + amount, + buyAmountWei, + sellAmountWei, + newBuyTokenAmount, + newSellTokenAmount, + } +} diff --git a/src/pages/Swap/LimitOrder/Components/SwapTokens.tsx b/src/pages/Swap/LimitOrder/Components/SwapTokens.tsx new file mode 100644 index 000000000..63630532a --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/SwapTokens.tsx @@ -0,0 +1,20 @@ +import { ReactComponent as SwapIcon } from '../../../../assets/images/swap-icon.svg' +import { ArrowWrapper, SwitchIconContainer, SwitchTokensAmountsContainer } from '../../Components/styles' + +export default function SwapTokens({ swapTokens, loading }: { swapTokens: any; loading: boolean }) { + return ( + { + if (!loading) { + swapTokens() + } + }} + > + + + + + + + ) +} diff --git a/src/pages/Swap/LimitOrder/Components/utils.ts b/src/pages/Swap/LimitOrder/Components/utils.ts new file mode 100644 index 000000000..de289a794 --- /dev/null +++ b/src/pages/Swap/LimitOrder/Components/utils.ts @@ -0,0 +1,50 @@ +import { formatUnits } from '@ethersproject/units' + +import { Kind, MarketPrices } from '../../../../services/LimitOrders' + +export const formatMaxValue = (value: number) => { + if (value === 0) return 0 + else if (value < 10) return value.toFixed(5) + else if (value < 100) return value.toFixed(4) + else if (value < 1000) return value.toFixed(3) + return value.toFixed(2) +} + +export const formatMarketPrice = (amount: string, decimals: number, tokenAmount: string): number => + parseFloat(formatUnits(amount, decimals) ?? 0) / Number(tokenAmount) + +export const toFixedSix = (price: number): string => { + if (Number(price.toFixed(6)) === 0) return price.toString() + + return price.toFixed(6) +} + +export function calculateMarketPriceDiffPercentage( + limitOrderKind: Kind, + marketPrices: MarketPrices, + formattedLimitPrice?: string +) { + const nextLimitPriceFloat = limitOrderKind === Kind.Sell ? marketPrices.buy : marketPrices.sell + let marketPriceDiffPercentage = 0 + let isDiffPositive = false + + if (Boolean(Number(nextLimitPriceFloat)) && formattedLimitPrice) { + if (limitOrderKind === Kind.Sell) { + marketPriceDiffPercentage = (Number(formattedLimitPrice) / Number(nextLimitPriceFloat.toFixed(6)) - 1) * 100 + isDiffPositive = Math.sign(Number(marketPriceDiffPercentage)) > 0 + } else { + marketPriceDiffPercentage = (Number(nextLimitPriceFloat.toFixed(6)) / Number(formattedLimitPrice) - 1) * 100 + + if (marketPriceDiffPercentage < 0) { + marketPriceDiffPercentage = Math.abs(marketPriceDiffPercentage) + } else { + marketPriceDiffPercentage = Math.min(marketPriceDiffPercentage, 999) + marketPriceDiffPercentage = marketPriceDiffPercentage * -1 + } + isDiffPositive = Math.sign(Number(marketPriceDiffPercentage)) < 0 + } + } + + marketPriceDiffPercentage = Math.min(marketPriceDiffPercentage, 999) + return { marketPriceDiffPercentage, isDiffPositive } +} diff --git a/src/pages/Swap/LimitOrder/LimitOrder.tsx b/src/pages/Swap/LimitOrder/LimitOrder.tsx new file mode 100644 index 000000000..ec365a354 --- /dev/null +++ b/src/pages/Swap/LimitOrder/LimitOrder.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' + +import { PageMetaData } from '../../../components/PageMetaData' +import { useActiveWeb3React } from '../../../hooks' +import LimitOrder, { WalletData } from '../../../services/LimitOrders' +import AppBody from '../../AppBody' + +import LimitOrderFallback from './Components/LimitFallback' +import LimitOrderForm from './LimitOrderForm' + +let limitSdk: LimitOrder = new LimitOrder() + +export default function LimitOrderUI() { + const { chainId, account, library: provider } = useActiveWeb3React() + + const [protocol, setProtocol] = useState(limitSdk.getActiveProtocol()) + + useEffect(() => { + limitSdk = new LimitOrder() + }, []) + + useEffect(() => { + async function updateSigner(signerData: WalletData) { + await limitSdk.updateSigner(signerData) + setProtocol(limitSdk.getActiveProtocol()) + } + if (chainId && account && provider) { + updateSigner({ activeChainId: chainId, account, provider }) + } + }, [account, chainId, provider]) + + return ( + <> + + + {protocol && } + {!protocol && } + + > + ) +} diff --git a/src/pages/Swap/LimitOrder/LimitOrderForm.tsx b/src/pages/Swap/LimitOrder/LimitOrderForm.tsx new file mode 100644 index 000000000..402ecf0a7 --- /dev/null +++ b/src/pages/Swap/LimitOrder/LimitOrderForm.tsx @@ -0,0 +1,435 @@ +import { Currency, Token, TokenAmount } from '@swapr/sdk' + +import { parseUnits } from 'ethers/lib/utils' +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { Flex } from 'rebass' + +import { ButtonPrimary } from '../../../components/Button' +import { AutoColumn } from '../../../components/Column' +import { CurrencyInputPanel } from '../../../components/CurrencyInputPanel' +import { ApprovalState, useApproveCallback } from '../../../hooks/useApproveCallback' +import { useHigherUSDValue } from '../../../hooks/useUSDValue' +import { Kind, LimitOrderBase, MarketPrices } from '../../../services/LimitOrders' +import { getVaultRelayerAddress } from '../../../services/LimitOrders/CoW/api/cow' +import { useNotificationPopup } from '../../../state/application/hooks' +import { useCurrencyBalances } from '../../../state/wallet/hooks' +import { maxAmountSpend } from '../../../utils/maxAmountSpend' + +import { ApprovalFlow } from './Components/ApprovalFlow' +import { AutoRow } from './Components/AutoRow' +import ConfirmLimitOrderModal from './Components/ConfirmLimitOrderModal' +import { MaxAlert } from './Components/MaxAlert' +import { OrderExpiryField } from './Components/OrderExpiryField' +import { OrderLimitPriceField } from './Components/OrderLimitPriceField' +import { SetToMarket } from './Components/OrderLimitPriceField/styles' +import SwapTokens from './Components/SwapTokens' +import { formatMarketPrice, formatMaxValue } from './Components/utils' + +export default function LimitOrderForm({ protocol }: { protocol: LimitOrderBase }) { + const notify = useNotificationPopup() + + const [buyAmount, setBuyAmount] = useState(protocol.buyAmount) + const [sellAmount, setSellAmount] = useState(protocol.sellAmount) + + const [sellToken, setSellToken] = useState(protocol.sellToken) + const [buyToken, setBuyToken] = useState(protocol.buyToken) + const [loading, setLoading] = useState(protocol.loading) + + const [isPossibleToOrder, setIsPossibleToOrder] = useState({ + status: false, + value: 0, + }) + + const [sellCurrencyBalance, buyCurrencyBalance] = useCurrencyBalances(protocol.userAddress, [ + sellAmount.currency, + buyAmount?.currency, + ]) + + const sellCurrencyMaxAmount = maxAmountSpend(sellCurrencyBalance, protocol.activeChainId) + const buyCurrencyMaxAmount = maxAmountSpend(buyCurrencyBalance, protocol.activeChainId, false) + + const { fiatValueInput, fiatValueOutput, isFallbackFiatValueInput, isFallbackFiatValueOutput } = useHigherUSDValue({ + inputCurrencyAmount: sellAmount, + outputCurrencyAmount: buyAmount, + }) + + const [tokenInApproval, tokenInApprovalCallback] = useApproveCallback( + sellAmount, + getVaultRelayerAddress(protocol.activeChainId!) + ) + + useEffect(() => { + let totalSellAmount = Number(sellAmount.toExact() ?? 0) + const maxAmountAvailable = Number(sellCurrencyMaxAmount?.toExact() ?? 0) + + if (totalSellAmount > 0 && maxAmountAvailable >= 0) { + // if (protocol.quoteSellAmount && sellAmount.token.address === protocol.quoteSellAmount.token.address) { + // const quoteAmount = Number(protocol.quoteSellAmount.toExact() ?? 0) + // if (quoteAmount < totalSellAmount) { + // totalSellAmount = quoteAmount + // } + // } + + if (totalSellAmount > maxAmountAvailable) { + const maxSellAmountPossible = maxAmountAvailable < 0 ? 0 : maxAmountAvailable + if (isPossibleToOrder.value !== maxSellAmountPossible || !isPossibleToOrder.status) { + setIsPossibleToOrder({ + status: true, + value: maxSellAmountPossible, + }) + } + } else { + if (isPossibleToOrder.value !== 0 || isPossibleToOrder.status) { + setIsPossibleToOrder({ + status: false, + value: 0, + }) + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sellAmount, buyAmount, sellCurrencyMaxAmount, sellToken, buyToken]) + + // Determine if the token has to be approved first + const showApproveFlow = tokenInApproval === ApprovalState.NOT_APPROVED || tokenInApproval === ApprovalState.PENDING + + const [kind, setKind] = useState(protocol?.kind || Kind.Sell) + + const [isModalOpen, setIsModalOpen] = useState(false) + + const onModalDismiss = () => { + setIsModalOpen(false) + } + + useEffect(() => { + async function getMarketPrice() { + try { + await protocol.getMarketPrice() + setBuyAmount(protocol.buyAmount) + } finally { + setLoading(false) + } + } + + setLoading(true) + setSellToken(protocol.sellToken) + setBuyToken(protocol.buyToken) + setSellAmount(protocol.sellAmount) + + getMarketPrice() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [protocol.activeChainId]) + + useEffect(() => { + setLoading(protocol.loading) + }, [protocol.loading]) + + useEffect(() => { + protocol?.onKindChange(kind) + }, [protocol, kind]) + + const handleSellTokenChange = useCallback(async (currency: Currency, _isMaxAmount?: boolean) => { + setLoading(true) + const newSellToken = protocol.getTokenFromCurrency(currency) + + setSellToken(newSellToken) + + setKind(Kind.Sell) + + protocol.onKindChange(Kind.Sell) + try { + await protocol?.onSellTokenChange(newSellToken) + + protocol.onLimitPriceChange(protocol.getLimitPrice()) + + setSellAmount(protocol.sellAmount) + setBuyAmount(protocol.buyAmount) + } finally { + setIsPossibleToOrder({ + status: false, + value: 0, + }) + setLoading(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleBuyTokenChange = useCallback(async (currency: Currency) => { + setLoading(true) + const newBuyToken = protocol.getTokenFromCurrency(currency) + + setBuyToken(newBuyToken) + + setKind(Kind.Buy) + + protocol.onKindChange(Kind.Buy) + try { + await protocol?.onBuyTokenChange(newBuyToken) + + protocol.onLimitPriceChange(protocol.getLimitPrice()) + + setSellAmount(protocol.sellAmount) + setBuyAmount(protocol.buyAmount) + } finally { + setLoading(false) + setIsPossibleToOrder({ + status: false, + value: 0, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleSellAmountChange = useCallback(async (value: string) => { + if (value.trim() !== '' && value.trim() !== '0') { + const amountWei = parseUnits(value, protocol.sellToken.decimals).toString() + const newSellAmount = new TokenAmount(protocol.sellToken, amountWei) + + setLoading(true) + setSellAmount(newSellAmount) + + protocol.onKindChange(Kind.Sell) + const limitPrice = protocol.getLimitPrice() + protocol.onLimitPriceChange(limitPrice) + + setKind(Kind.Sell) + + await protocol?.onSellAmountChange(newSellAmount) + + setBuyAmount(protocol.buyAmount) + } + setLoading(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleBuyAmountChange = useCallback(async (value: string) => { + if (value.trim() !== '' && value !== '0') { + const amountWei = parseUnits(value, protocol.buyToken.decimals).toString() + const newBuyAmount = new TokenAmount(protocol.buyToken, amountWei) + + setLoading(true) + setBuyAmount(newBuyAmount) + + protocol.onKindChange(Kind.Buy) + const limitPrice = protocol.getLimitPrice() + protocol.onLimitPriceChange(limitPrice) + + setKind(Kind.Buy) + + await protocol?.onBuyAmountChange(newBuyAmount) + + setSellAmount(protocol.sellAmount) + } + setLoading(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleSwap = async () => { + setLoading(true) + const buyToken = protocol.buyToken + const sellToken = protocol.sellToken + + protocol.onSellTokenChange(buyToken) + protocol.onBuyTokenChange(sellToken) + + setSellToken(buyToken) + setBuyToken(sellToken) + setKind(Kind.Sell) + try { + await protocol.getQuote() + setBuyAmount(protocol.buyAmount) + setSellAmount(protocol.sellAmount) + } finally { + setLoading(false) + } + } + + const [marketPrices, setMarketPrices] = useState({ buy: 0, sell: 0 }) + + const getMarketPrices = useCallback(async () => { + const tokenSellAmount = Number(sellAmount.toExact()) > 1 ? sellAmount.toExact() : '1' + const tokenBuyAmount = Number(buyAmount.toExact()) > 1 ? buyAmount.toExact() : '1' + + const cowQuote = await protocol.getRawQuote() + + if (cowQuote) { + const { buyAmount: quoteBuyAmount, sellAmount: quoteSellAmount } = cowQuote + + if (protocol.kind === Kind.Sell) { + setMarketPrices(marketPrice => ({ + ...marketPrice, + buy: formatMarketPrice(quoteBuyAmount, buyAmount.currency.decimals, tokenSellAmount), + })) + } else { + setMarketPrices(marketPrice => ({ + ...marketPrice, + sell: formatMarketPrice(quoteSellAmount, sellAmount.currency.decimals, tokenBuyAmount), + })) + } + } + }, [buyAmount, protocol, sellAmount]) + + useEffect(() => { + getMarketPrices() + }, [getMarketPrices, kind]) + + useEffect(() => { + setMarketPrices({ buy: 0, sell: 0 }) + }, [sellToken, buyToken]) + + // Form submission handler + const createLimitOrder = async () => { + setLoading(true) + + const successCallback = () => { + notify( + <> + Successfully created limit order. Please check user account for details + > + ) + setLoading(false) + } + + const errorCallback = (error: Error) => { + console.error(error) + notify('Failed to place limit order. Try again.', false) + } + + const final = () => setLoading(false) + + const response = await protocol.createOrder(successCallback, errorCallback, final) + console.dir(response) + } + + const handleGetMarketPrice = async () => { + protocol.loading = true + setLoading(true) + + protocol.onUserUpadtedLimitPrice(false) + await protocol.getQuote() + protocol.loading = false + setLoading(false) + if (kind === Kind.Sell) { + setBuyAmount(protocol.buyAmount) + } else { + setSellAmount(protocol.sellAmount) + } + const limitPrice = protocol.getLimitPrice() + protocol.onLimitPriceChange(limitPrice) + return limitPrice + } + + return ( + <> + + + + { + if (sellCurrencyMaxAmount !== undefined && parseFloat(sellCurrencyMaxAmount.toExact() ?? '0') > 0) { + handleSellAmountChange(parseFloat(sellCurrencyMaxAmount?.toExact()).toFixed(6)) + } + }} + fiatValue={fiatValueInput} + isFallbackFiatValue={isFallbackFiatValueInput} + /> + + { + if (buyCurrencyMaxAmount !== undefined && parseFloat(buyCurrencyMaxAmount.toExact() ?? '0') > 0) { + handleBuyAmountChange(parseFloat(buyCurrencyMaxAmount.toExact()).toFixed(6)) + } + }} + showNativeCurrency={false} + onCurrencySelect={handleBuyTokenChange} + currencyOmitList={[sellToken.address]} + disabled={loading} + fiatValue={fiatValueOutput} + isFallbackFiatValue={isFallbackFiatValueOutput} + /> + + + + + + + + + + {showApproveFlow ? ( + + ) : ( + <> + {protocol.quoteErrorMessage && ( + + + {protocol.quoteErrorMessage} + {' '} + Please try again + + + )} + {isPossibleToOrder.status && ( + + {isPossibleToOrder.value > 0 ? ( + `Max possible amount with fees for ${sellToken.symbol} is ${formatMaxValue(isPossibleToOrder.value)}.` + ) : isPossibleToOrder.value === 0 ? ( + `You dont have a positive balance for ${sellToken.symbol}.` + ) : ( + + Some error occurred. + {' '} + Please try again + + )} + + )} + setIsModalOpen(true)} disabled={isPossibleToOrder.status}> + Place Limit Order + + > + )} + + > + ) +} diff --git a/src/pages/Swap/LimitOrder/generated/cow-app-data/app-data.json b/src/pages/Swap/LimitOrder/generated/cow-app-data/app-data.json new file mode 100644 index 000000000..b18bb3ed8 --- /dev/null +++ b/src/pages/Swap/LimitOrder/generated/cow-app-data/app-data.json @@ -0,0 +1,34 @@ +{ + "1": { + "ipfsHashInfo": { + "cidV0": "Qmb2FoD4hCo8UPoZwdoXjTP2JEsFWzLCe7JrSZY3tFiZoc", + "appDataHash": "0xbc71919a6c87619a0d852499f1976ed641869478f7fcf618756650310187ad37" + }, + "content": { + "version": "0.4.0", + "appCode": "Swapr Limit Orders", + "metadata": { + "referrer": { + "address": "0x519b70055af55A007110B4Ff99b0eA33071c720a", + "version": "0.1.0" + } + } + } + }, + "100": { + "ipfsHashInfo": { + "cidV0": "QmWKg6D3JzzQNRtw1qDqrapopzJsjQBn2wKWhN6tx6gUnB", + "appDataHash": "0x769d79e6b78c7b56abed79dbeb1253e19416856ed1626c25b9b299430cc34c10" + }, + "content": { + "version": "0.4.0", + "appCode": "Swapr Limit Orders", + "metadata": { + "referrer": { + "address": "0xe716ec63c5673b3a4732d22909b38d779fa47c3f", + "version": "0.1.0" + } + } + } + } +} diff --git a/src/pages/Swap/LimitOrder/index.ts b/src/pages/Swap/LimitOrder/index.ts new file mode 100644 index 000000000..d53cdb6a8 --- /dev/null +++ b/src/pages/Swap/LimitOrder/index.ts @@ -0,0 +1 @@ +export { default } from './LimitOrder' diff --git a/src/pages/Swap/Swap.tsx b/src/pages/Swap/Swap.tsx index 9eeb78690..3a5035340 100644 --- a/src/pages/Swap/Swap.tsx +++ b/src/pages/Swap/Swap.tsx @@ -12,6 +12,7 @@ import { ChartOption, SwapTab } from '../../state/user/reducer' import { AdvancedTradingViewBox } from './AdvancedTradingViewBox' import { Tabs } from './Components/Tabs' import { LandingSections } from './LandingSections' +import LimitOrderUI from './LimitOrder' import { LimitOrderBox } from './LimitOrderBox' import { supportedChainIdList } from './LimitOrderBox/constants' import { SwapBox } from './SwapBox/SwapBox.component' @@ -28,7 +29,7 @@ const AppBodyContainer = styled.section` * Swap page component */ export function Swap() { - const { SWAP, LIMIT_ORDER } = SwapTab + const { SWAP, LIMIT_ORDER, LIMIT_ORDER_NEW } = SwapTab const { account, chainId } = useActiveWeb3React() // Control the active tab @@ -71,6 +72,7 @@ export function Swap() { {activeTab === SWAP && } {activeTab === LIMIT_ORDER && } + {activeTab === LIMIT_ORDER_NEW && } diff --git a/src/services/LimitOrders/1Inch/OneInch.ts b/src/services/LimitOrders/1Inch/OneInch.ts new file mode 100644 index 000000000..26d7df071 --- /dev/null +++ b/src/services/LimitOrders/1Inch/OneInch.ts @@ -0,0 +1,92 @@ +import { Currency, TokenAmount } from '@swapr/sdk' + +import { parseUnits } from 'ethers/lib/utils' + +import { getDefaultTokens } from '../LimitOrder.config' +import { Kind, WalletData, OrderExpiresInUnit, ProtocolContructor } from '../LimitOrder.types' +import { LimitOrderBase } from '../LimitOrder.utils' + +export class OneInch extends LimitOrderBase { + constructor({ supportedChains, protocol, sellToken, buyToken }: ProtocolContructor) { + super({ + supportedChains, + protocol, + kind: Kind.Sell, + expiresAt: 20, + sellToken, + buyToken, + }) + } + + onSellTokenChange(sellToken: Currency) { + this.sellToken = this.getTokenFromCurrency(sellToken) + } + + onBuyTokenChange(buyToken: Currency) { + this.buyToken = this.getTokenFromCurrency(buyToken) + } + + onSellAmountChange(sellAmount: TokenAmount) { + this.sellAmount = sellAmount + } + + onBuyAmountChange(buyAmount: TokenAmount) { + this.buyAmount = buyAmount + } + + onLimitOrderChange(limitOrder: any): void { + this.limitOrder = limitOrder + } + + onExpireChange(expiresAt: number): void { + this.expiresAt = expiresAt + } + + onExpireUnitChange(unit: OrderExpiresInUnit): void { + this.expiresAtUnit = unit + } + + onKindChange(kind: Kind): void { + this.kind = kind + } + + onLimitPriceChange(limitPrice: string): void { + this.limitPrice = limitPrice + } + + getQuote(): Promise { + throw new Error('Method not implemented.') + } + + getRawQuote(): Promise { + throw new Error('Method not implemented.') + } + + init(): Promise { + throw new Error('Method not implemented.') + } + + onUserUpadtedLimitPrice(status: boolean) { + this.userUpdatedLimitPrice = status + } + + async onSignerChange({ activeChainId }: WalletData) { + const { sellToken, buyToken } = getDefaultTokens(activeChainId) + this.onSellTokenChange(sellToken) + this.onBuyTokenChange(buyToken) + this.onSellAmountChange(new TokenAmount(sellToken, parseUnits('1', sellToken.decimals).toString())) + this.onBuyAmountChange(new TokenAmount(buyToken, parseUnits('1', buyToken.decimals).toString())) + } + approve(): Promise { + throw new Error('Method not implemented.') + } + createOrder(): Promise { + throw new Error('Method not implemented.') + } + + async getMarketPrice() {} + + getLimitPrice() { + return '0' + } +} diff --git a/src/services/LimitOrders/CoW/CoW.constants.ts b/src/services/LimitOrders/CoW/CoW.constants.ts new file mode 100644 index 000000000..bc0788b7e --- /dev/null +++ b/src/services/LimitOrders/CoW/CoW.constants.ts @@ -0,0 +1,19 @@ +import { ChainId, DAI, USDT, WETH, WXDAI } from '@swapr/sdk' + +export const DefaultTokens = { + [ChainId.MAINNET]: { + sellToken: WETH[ChainId.MAINNET], + buyToken: DAI[ChainId.MAINNET], + }, + [ChainId.GNOSIS]: { + sellToken: WXDAI[ChainId.GNOSIS], + buy: USDT[ChainId.GNOSIS], + }, +} + +export const ErrorCodes = [ + 'InsufficientLiquidity', + 'UnsupportedToken', + 'NoLiquidity', + 'SellAmountDoesNotCoverFee', +] as const diff --git a/src/services/LimitOrders/CoW/CoW.ts b/src/services/LimitOrders/CoW/CoW.ts new file mode 100644 index 000000000..952f0f964 --- /dev/null +++ b/src/services/LimitOrders/CoW/CoW.ts @@ -0,0 +1,447 @@ +import { ChainId, Currency, TokenAmount } from '@swapr/sdk' + +import dayjs from 'dayjs' +import { parseUnits } from 'ethers/lib/utils' + +import { getDefaultTokens } from '../LimitOrder.config' +import { Kind, WalletData, OrderExpiresInUnit, ProtocolContructor, LimitOrder, Callback } from '../LimitOrder.types' +import { LimitOrderBase } from '../LimitOrder.utils' + +import { createCoWLimitOrder, getQuote } from './api/cow' +import { ErrorCodes } from './CoW.constants' +import { CoWError, type CoWQuote } from './CoW.types' + +let quoteCounter = 0 + +export class CoW extends LimitOrderBase { + constructor({ supportedChains, protocol, sellToken, buyToken }: ProtocolContructor) { + super({ + supportedChains, + protocol, + kind: Kind.Sell, + expiresAt: 20, + sellToken, + buyToken, + }) + } + + #abortControllers: { [id: string]: AbortController } = {} + + #renewAbortController = (key: string) => { + console.log(this.#abortControllers[key]) + if (this.#abortControllers[key]) { + this.#abortControllers[key].abort() + } + + this.#abortControllers[key] = new AbortController() + + return this.#abortControllers[key].signal + } + + #validateAmount(num: string | number): string { + const numberValue = parseFloat(num.toString()) + return !isNaN(numberValue) && numberValue > 0 ? `${numberValue}` : '1' + } + + async onSellTokenChange(sellToken: Currency) { + this.loading = true + this.sellToken = this.getTokenFromCurrency(sellToken) + const exactAmount = this.#validateAmount(this.sellAmount.toExact()) + const sellAmountinWei = parseUnits(Number(exactAmount).toFixed(6), this.sellToken.decimals).toString() + const sellAmount = new TokenAmount(this.sellToken, sellAmountinWei) + this.#setSellAmount(sellAmount) + + this.onLimitOrderChange({ + sellToken: this.sellToken.address, + }) + + // this.onKindChange(Kind.Sell) + + await this.getQuote() + this.loading = false + this.logger.log(`Sell Token Change ${this.sellToken.symbol}`) + } + + async onBuyTokenChange(buyToken: Currency) { + this.loading = true + this.buyToken = this.getTokenFromCurrency(buyToken) + const exactAmount = this.#validateAmount(this.buyAmount.toExact()) + const buyAmountinWei = parseUnits(Number(exactAmount).toFixed(6), this.buyToken.decimals).toString() + const buyAmount = new TokenAmount(this.buyToken, buyAmountinWei) + this.#setBuyAmount(buyAmount) + + this.onLimitOrderChange({ + buyToken: this.buyToken.address, + }) + + // this.onKindChange(Kind.Buy) + + await this.getQuote() + this.loading = false + this.logger.log(`Buy Token Change ${this.buyToken.symbol}`) + } + + async #onAmountChange() { + if (this.buyToken === undefined || this.sellToken === undefined) return + + if (Number(this.limitPrice?.toString().trim()) > 0) { + return + } + if ( + (this.kind === Kind.Sell && Number(this.sellAmount.toExact()) > 0) || + (this.kind === Kind.Buy && Number(this.buyAmount.toExact()) > 0) + ) { + await this.getQuote() + } + } + + #setSellAmount(sellAmount: TokenAmount) { + this.sellAmount = sellAmount + + this.onLimitOrderChange({ + sellAmount: sellAmount.raw.toString(), + }) + } + + async onSellAmountChange(sellAmount: TokenAmount) { + this.#setSellAmount(sellAmount) + + await this.#onAmountChange() + + if (this.limitPrice && Number(this.limitPrice) > 0 && this.kind === Kind.Sell) { + const buyAmount = parseFloat(this.sellAmount.toExact()) * parseFloat(this.limitPrice) + this.#setBuyAmount( + new TokenAmount(this.buyToken, parseUnits(buyAmount.toFixed(6), this.buyToken.decimals).toString()) + ) + } + + this.logger.log(`Sell Amount Change ${this.sellAmount.raw.toString()}`) + } + + #setBuyAmount(buyAmount: TokenAmount) { + this.buyAmount = buyAmount + this.onLimitOrderChange({ + buyAmount: buyAmount.raw.toString(), + }) + } + async onBuyAmountChange(buyAmount: TokenAmount) { + this.#setBuyAmount(buyAmount) + + await this.#onAmountChange() + + if (this.limitPrice && Number(this.limitPrice) > 0 && this.kind === Kind.Buy) { + const sellAmount = parseFloat(this.buyAmount.toExact()) * parseFloat(this.limitPrice) + this.#setSellAmount( + new TokenAmount(this.sellToken, parseUnits(sellAmount.toFixed(6), this.sellToken.decimals).toString()) + ) + } + + this.logger.log(`Buy Amount Change ${this.sellAmount.raw.toString()}`) + } + + onLimitOrderChange(limitOrder: Partial): void { + const fullLimitOrder = this.#getLimitOrder(limitOrder) + this.limitOrder = fullLimitOrder + } + + onExpireChange(expiresAt: number): void { + this.expiresAt = expiresAt + this.onLimitOrderChange({ + expiresAt: dayjs().add(expiresAt, this.expiresAtUnit).unix(), + }) + } + + onExpireUnitChange(unit: OrderExpiresInUnit): void { + this.expiresAtUnit = unit + } + + onKindChange(kind: Kind): void { + this.kind = kind + this.onLimitOrderChange({ + kind, + }) + } + + onLimitPriceChange(limitPrice: string): void { + this.limitPrice = limitPrice + this.onLimitOrderChange({ + limitPrice, + }) + } + + onUserUpadtedLimitPrice(status: boolean) { + this.userUpdatedLimitPrice = status + } + + async getRawQuote(limitOrder?: LimitOrder) { + const signer = this.provider?.getSigner() + const chainId = this.activeChainId + const order = limitOrder ?? this.limitOrder + const kind = this.kind + if (!signer || !chainId || !order || !kind) { + throw new Error('Missing required params') + } + if (order.sellAmount !== this.sellAmount.raw.toString()) { + order.sellAmount = this.sellAmount.raw.toString() + } + if (order.buyAmount !== this.buyAmount.raw.toString()) { + order.buyAmount = this.buyAmount.raw.toString() + } + // If token is xDAI for CoW on GNOSIS ignore it. It happens if we switch from Swap to Limit order and XDai was already selected + if ( + chainId === ChainId.GNOSIS && + (order.sellToken.toLowerCase() === '0x6b175474e89094c44da98b954eedeac495271d0f' || + order.buyToken.toLowerCase() === '0x6b175474e89094c44da98b954eedeac495271d0f') + ) { + return + } + + order.kind = kind + + try { + const cowQuote = await getQuote({ + chainId, + signer, + order: { ...order, expiresAt: dayjs().add(this.expiresAt, OrderExpiresInUnit.Minutes).unix() }, + signal: this.#renewAbortController('getQuote'), + }) + + this.quote = cowQuote as CoWQuote + + const response = cowQuote + if (!response) { + throw new Error('Quote error') + } + + const { + id, + quote: { buyAmount, sellAmount }, + } = response + + return { id, buyAmount, sellAmount } + } catch (error: any) { + quoteCounter = ++quoteCounter + if (quoteCounter < 3) { + if (this.#validateError(error)) { + this.getQuote() + } + } + this.logger.error(error.message) + } + } + + async getQuote(limitOrder?: LimitOrder) { + this.quoteErrorMessage = undefined + if (this.userUpdatedLimitPrice) { + return + } + + const response = await this.getRawQuote(limitOrder) + if (response) { + const { id, sellAmount, buyAmount } = response + this.onLimitOrderChange({ quoteId: id }) + const buyTokenAmount = new TokenAmount(this.buyToken, buyAmount) + const sellTokenAmount = new TokenAmount(this.sellToken, sellAmount) + this.quoteBuyAmount = buyTokenAmount + this.quoteSellAmount = sellTokenAmount + this.limitPrice = this.getLimitPrice() + + if (this.kind === Kind.Sell) { + this.buyAmount = buyTokenAmount + this.onLimitOrderChange({ + buyAmount: buyTokenAmount.raw.toString(), + }) + } else { + this.sellAmount = sellTokenAmount + this.onLimitOrderChange({ + sellAmount: sellTokenAmount.raw.toString(), + }) + } + } + } + + async onSignerChange({ activeChainId, account }: WalletData) { + const { sellToken, buyToken } = getDefaultTokens(activeChainId) + // Setting default tokens for ChainId's + this.onSellTokenChange(sellToken) + this.onBuyTokenChange(buyToken) + // Setting default amounts for Tokens + this.#setSellAmount(new TokenAmount(sellToken, parseUnits('1', sellToken.decimals).toString())) + this.#setBuyAmount(new TokenAmount(buyToken, parseUnits('1', buyToken.decimals).toString())) + this.onLimitOrderChange({ + kind: this.kind, + expiresAt: this.expiresAt, + sellToken: this.sellToken.address, + buyToken: this.buyToken.address, + sellAmount: this.sellAmount.raw.toString(), + buyAmount: this.buyAmount.raw.toString(), + userAddress: account, + receiverAddress: account, + }) + } + + approve(): Promise { + throw new Error('Method not implemented.') + } + + async createOrder(successCallback: Callback, errorCallback: Callback, final: Callback) { + try { + const signer = this.provider?.getSigner() + const chainId = this.activeChainId + if (signer && chainId && this.limitOrder) { + const finalizedLimitOrder = { + ...this.limitOrder, + expiresAt: dayjs().add(this.expiresAt, this.expiresAtUnit).unix(), + } + + const response = await createCoWLimitOrder({ + chainId, + signer, + order: finalizedLimitOrder, + }) + + if (response) { + successCallback?.() + return response + } else { + throw new Error(response) + } + } + } catch (error) { + errorCallback?.(error) + } finally { + final?.() + } + } + + async getMarketPrice() { + const { buyToken, sellToken, provider, limitOrder, kind, activeChainId } = this + + if (buyToken && sellToken && provider && limitOrder && activeChainId) { + this.loading = true + + const tokenAmountSelected = kind === Kind.Sell ? this.sellAmount : this.buyAmount + + const order = structuredClone(limitOrder) + const tokenSelected = kind === Kind.Sell ? sellToken : buyToken + + const tokenAmount = + tokenAmountSelected && Number(tokenAmountSelected.toExact()) > 0 ? tokenAmountSelected.toExact() : '1' + order.sellAmount = parseUnits(tokenAmount, tokenSelected.decimals).toString() + + await this.getQuote(order) + } + } + + #getLimitOrder(props: Partial): LimitOrder { + const order = { + ...this.limitOrder, + } + if (props.sellToken !== undefined) { + order.sellToken = props.sellToken + } + if (props.buyToken !== undefined) { + order.buyToken = props.buyToken + } + if (props.sellAmount !== undefined) { + order.sellAmount = props.sellAmount + } + if (props.buyAmount !== undefined) { + order.buyAmount = props.buyAmount + } + + if (props.userAddress !== undefined) { + order.userAddress = props.userAddress + } + if (props.receiverAddress !== undefined) { + order.receiverAddress = props.receiverAddress + } + if (props.kind !== undefined) { + order.kind = props.kind + } + if (props.expiresAt !== undefined) { + order.expiresAt = props.expiresAt + } + if (props.limitPrice !== undefined) { + order.limitPrice = props.limitPrice + } + + if (props.feeAmount !== undefined) { + order.feeAmount = props.feeAmount + } + if (props.createdAt !== undefined) { + order.createdAt = props.createdAt + } + + const { + sellToken = this.sellToken.address, + buyToken = this.buyToken.address, + sellAmount = this.sellAmount.raw.toString() ?? '1000000000000000000', + buyAmount = this.buyAmount.raw.toString() ?? '1000000000000000000', + userAddress = this.userAddress, + receiverAddress = this.userAddress, + kind = this.kind, + expiresAt = dayjs().add(this.expiresAt, OrderExpiresInUnit.Minutes).unix(), + limitPrice = '1', + feeAmount = '0', + createdAt = dayjs().unix(), + } = order + + if ( + !sellToken || + !buyToken || + !sellAmount || + !buyAmount || + !userAddress || + !receiverAddress || + !kind || + !expiresAt || + !limitPrice || + !feeAmount || + !createdAt + ) { + throw new Error('Missing properties in Limit Order params') + } + + return { + sellToken, + buyToken, + sellAmount, + buyAmount, + userAddress, + receiverAddress, + kind, + expiresAt, + limitPrice, + feeAmount, + createdAt, + } as LimitOrder + } + + getLimitPrice() { + const [baseAmount, quoteAmount] = + this.kind === Kind.Sell + ? [this.quoteSellAmount, this.quoteBuyAmount] + : [this.quoteBuyAmount, this.quoteSellAmount] + const quoteAmountInUnits = parseFloat(quoteAmount.toExact()) + const baseAmountInUnits = parseFloat(baseAmount.toExact()) + if ( + !Number.isNaN(quoteAmountInUnits) && + quoteAmountInUnits > 0 && + !Number.isNaN(baseAmountInUnits) && + baseAmountInUnits > 0 + ) { + return (parseFloat(quoteAmount.toExact()) / parseFloat(baseAmount.toExact())).toFixed(6) + } + this.getQuote() + return '1' + } + + #validateError(error: CoWError) { + if (ErrorCodes.includes(error.error_code)) { + this.quoteErrorMessage = error.message + return false + } + return true + } +} diff --git a/src/services/LimitOrders/CoW/CoW.types.ts b/src/services/LimitOrders/CoW/CoW.types.ts new file mode 100644 index 000000000..ae3d3f749 --- /dev/null +++ b/src/services/LimitOrders/CoW/CoW.types.ts @@ -0,0 +1,24 @@ +import { SigningResult } from '@cowprotocol/cow-sdk/dist/utils/sign' + +import { LimitOrder } from '../LimitOrder.types' + +import { getQuote } from './api/cow' + +export interface SignedLimitOrder extends LimitOrder { + signature: string + signingScheme: SigningResult['signingScheme'] +} + +export type CoWQuote = Awaited> + +enum CoWErrorCodes { + InsufficientLiquidity = 'InsufficientLiquidity', + UnsupportedToken = 'UnsupportedToken', + NoLiquidity = 'NoLiquidity', + SellAmountDoesNotCoverFee = 'SellAmountDoesNotCoverFee', +} + +export type CoWError = { + error_code: CoWErrorCodes + description: string +} & Error diff --git a/src/services/LimitOrders/CoW/api/cow.ts b/src/services/LimitOrders/CoW/api/cow.ts new file mode 100644 index 000000000..5fb011392 --- /dev/null +++ b/src/services/LimitOrders/CoW/api/cow.ts @@ -0,0 +1,170 @@ +import type { Signer } from '@ethersproject/abstract-signer' +import { ChainId, CoWTrade } from '@swapr/sdk' + +import contractNetworks from '@cowprotocol/contracts/networks.json' +import { OrderKind as CoWOrderKind } from '@cowprotocol/cow-sdk' +import type { UnsignedOrder } from '@cowprotocol/cow-sdk/dist/utils/sign' + +import { Kind, LimitOrder } from '../..' +import cowAppData from '../../../../pages/Swap/LimitOrder/generated/cow-app-data/app-data.json' +import { SignedLimitOrder } from '../CoW.types' +// import { LimitOrderKind, SerializableLimitOrder, SerializableSignedLimitOrder } from '../interfaces' + +export const COW_APP_DATA = cowAppData + +/** + * Returns the IPFS hash of the appData for the current chainId + * @param chainId The chainId of the network + * @returns + */ +export function getAppDataIPFSHash(chainId: number): string { + // @ts-ignore + return cowAppData[chainId as any].ipfsHashInfo.hash +} + +export interface SignLimitOrderParams { + order: LimitOrder + signer: Signer + chainId: number + signal?: AbortSignal +} + +type GetLimitOrderQuoteParams = SignLimitOrderParams + +/** + * Fetches a quote from the CoW API + * @returns + */ +export async function getQuote({ order, signer, chainId, signal }: GetLimitOrderQuoteParams) { + const { buyToken, receiverAddress, userAddress, expiresAt, sellAmount, sellToken, kind, buyAmount } = order + + // const cowSdk = getCoWSdk(chainId, signer) + const cowSdk = CoWTrade.getCowSdk(chainId, { + signer, + appDataHash: getAppDataIPFSHash(chainId), + }) + + let cowQuote: Awaited> + + if (kind === Kind.Buy) { + cowQuote = await cowSdk.cowApi.getQuote( + { + appData: getAppDataIPFSHash(chainId), + buyAmountAfterFee: buyAmount, + buyToken, + from: userAddress, + kind: CoWOrderKind.BUY, + partiallyFillable: false, + receiver: receiverAddress, + sellToken, + validTo: expiresAt, + }, + { requestOptions: { signal } } + ) + } else { + cowQuote = await cowSdk.cowApi.getQuote( + { + appData: getAppDataIPFSHash(chainId), + buyToken, + sellAmountBeforeFee: sellAmount, + from: userAddress, + kind: CoWOrderKind.SELL, + partiallyFillable: false, + receiver: receiverAddress, + sellToken, + validTo: expiresAt, + }, + { requestOptions: { signal } } + ) + } + + return cowQuote +} + +/** + * Signs a limit order to produce a EIP712-compliant signature + */ +export async function signLimitOrder({ order, signer, chainId }: SignLimitOrderParams): Promise { + const cowSdk = CoWTrade.getCowSdk(chainId, { + signer, + appDataHash: getAppDataIPFSHash(chainId), + }) + + // Get feeAmount from CoW + const { buyAmount, buyToken, receiverAddress, feeAmount, expiresAt, sellAmount, sellToken, kind } = order + + const signedResult = await cowSdk.signOrder({ + buyAmount, + buyToken, + sellAmount, + sellToken, + feeAmount, // from CoW APIs + receiver: receiverAddress, // the account that will receive the order + validTo: expiresAt, + kind: kind === Kind.Buy ? CoWOrderKind.BUY : CoWOrderKind.SELL, + partiallyFillable: false, + }) + + if (!signedResult || !signedResult.signature) { + throw new Error('Failed to sign order') + } + + return { + ...order, + feeAmount, // from CoW APIs + signature: signedResult.signature, + signingScheme: signedResult.signingScheme, + } +} + +export async function createCoWLimitOrder({ order, signer, chainId }: GetLimitOrderQuoteParams) { + const cowSdk = CoWTrade.getCowSdk(chainId, { + signer, + appDataHash: getAppDataIPFSHash(chainId), + }) + + const cowUnsignedOrder: Omit = { + buyAmount: order.buyAmount, + buyToken: order.buyToken, + sellAmount: order.sellAmount, + sellToken: order.sellToken, + feeAmount: '0', // from CoW APIs + receiver: order.receiverAddress, // the account that will receive the order + validTo: order.expiresAt, + kind: order.kind === Kind.Buy ? CoWOrderKind.BUY : CoWOrderKind.SELL, + partiallyFillable: false, + } + + // Sign the order + const signingResult = await cowSdk.signOrder(cowUnsignedOrder) + + if (!signingResult || !signingResult.signature) { + throw new Error('Failed to sign order') + } + + return cowSdk.cowApi.sendOrder({ + order: { + // Unsigned order + ...cowUnsignedOrder, + // signature part + signature: signingResult.signature, + signingScheme: signingResult.signingScheme, + }, + owner: order.userAddress, + }) +} + +/** + * Returns the vault relayer contract address for the given chain. + * ERC20 tokens must approve this address. + * @param chainId The chain Id + * @returns The vault relayer address + */ +export function getVaultRelayerAddress(chainId: ChainId) { + const GPv2VaultRelayer = contractNetworks.GPv2VaultRelayer as Record< + ChainId, + Record<'transactionHash' | 'address', string> + > + + return GPv2VaultRelayer[chainId]?.address +} diff --git a/src/services/LimitOrders/CoW/api/index.ts b/src/services/LimitOrders/CoW/api/index.ts new file mode 100644 index 000000000..f5c82707c --- /dev/null +++ b/src/services/LimitOrders/CoW/api/index.ts @@ -0,0 +1,90 @@ +// eslint-disable-next-line no-restricted-imports +/** + * @module limit-orders/api + * @description LimitOrders API: a set of generic functions to create and manage limit orders. + * This module API should not change. + */ + +import type { Signer } from '@ethersproject/abstract-signer' +import { ChainId, CoWTrade } from '@swapr/sdk' + +import { SigningScheme } from '@cowprotocol/contracts' +import { OrderKind as CoWOrderKind } from '@cowprotocol/cow-sdk' + +import { LimitOrderKind, SerializableSignedLimitOrder } from '../../../../pages/Swap/LimitOrderBox/interfaces' + +// import { LimitOrderKind, SerializableSignedLimitOrder } from '../interfaces' + +export interface SubmitLimitOrderParams { + order: SerializableSignedLimitOrder + signer: Signer + chainId: number +} + +/** + * Submits a limit order, producing a order ID hash. + */ +export async function submitLimitOrder({ order, signer, chainId }: SubmitLimitOrderParams): Promise { + // create an order via CoW API + const cowSdk = CoWTrade.getCowSdk(chainId, { + signer, + }) + + const { + buyAmount, + buyToken, + receiverAddress, + expiresAt, + sellAmount, + sellToken, + kind, + signature, + signingScheme, + userAddress, + feeAmount, + quoteId, + } = order + + return cowSdk.cowApi.sendOrder({ + order: { + quoteId, + buyAmount, + buyToken, + sellAmount, + sellToken, + feeAmount, + kind: kind === LimitOrderKind.BUY ? CoWOrderKind.BUY : CoWOrderKind.SELL, + receiver: receiverAddress, // the account that will receive the order + validTo: expiresAt, + partiallyFillable: false, // @todo: support partially fillable orders + signature, + signingScheme, + }, + owner: userAddress, + }) +} + +export async function getOwnerOrders(chainId: ChainId, owner: string) { + const cowSdk = CoWTrade.getCowSdk(chainId) + return cowSdk.cowApi.getOrders({ owner }) +} + +export async function deleteOpenOrders(chainId: ChainId, uid: string, signer: Signer) { + const cowSdk = CoWTrade.getCowSdk(chainId, { signer }) + const { signature } = await cowSdk.signOrderCancellation(uid) + if (signature) { + try { + await cowSdk.cowApi.sendSignedOrderCancellation({ + //@ts-ignore + chainId, + cancellation: { orderUid: uid, signature, signingScheme: SigningScheme.EIP712 }, + }) + const response = await cowSdk.cowApi.getOrder(uid) + if (response) { + return response.status + } + } catch (error) { + console.error('There was an error!', error) + } + } +} diff --git a/src/services/LimitOrders/LimitOrder.config.ts b/src/services/LimitOrders/LimitOrder.config.ts new file mode 100644 index 000000000..2538cedf3 --- /dev/null +++ b/src/services/LimitOrders/LimitOrder.config.ts @@ -0,0 +1,54 @@ +import { ChainId, Currency, DAI, GNO, Token, USDT, WBNB, WETH, WMATIC, WXDAI } from '@swapr/sdk' + +import { OneInch } from './1Inch/OneInch' +import { CoW } from './CoW/CoW' +import { Providers } from './LimitOrder.types' +import { LimitOrderBase } from './LimitOrder.utils' + +export const DefaultTokens: Record = { + [ChainId.MAINNET]: { + sellToken: WETH[ChainId.MAINNET], + buyToken: DAI[ChainId.MAINNET], + }, + [ChainId.GNOSIS]: { + sellToken: WXDAI[ChainId.GNOSIS], + buyToken: GNO, + }, + [ChainId.POLYGON]: { + sellToken: WMATIC[ChainId.POLYGON], + buyToken: DAI[ChainId.POLYGON], + }, + [ChainId.OPTIMISM_MAINNET]: { + sellToken: USDT[ChainId.OPTIMISM_MAINNET], + buyToken: DAI[ChainId.OPTIMISM_MAINNET], + }, + [ChainId.ARBITRUM_ONE]: { + sellToken: WETH[ChainId.ARBITRUM_ONE], + buyToken: DAI[ChainId.ARBITRUM_ONE], + }, + [ChainId.BSC_MAINNET]: { + sellToken: WBNB[ChainId.BSC_MAINNET], + buyToken: DAI[ChainId.BSC_MAINNET], + }, +} + +export const getDefaultTokens = (chainId: ChainId) => { + const defaultSellToken = DefaultTokens[chainId].sellToken + const defaultBuyToken = DefaultTokens[chainId].buyToken + const sellToken = new Token(chainId, defaultSellToken.address!, defaultSellToken.decimals, defaultSellToken.symbol) + const buyToken = new Token(chainId, defaultBuyToken.address!, defaultBuyToken.decimals, defaultBuyToken.symbol) + return { sellToken, buyToken } +} + +export const getLimitOrderCofig = (): LimitOrderBase[] => [ + new CoW({ + supportedChains: [ChainId.MAINNET, ChainId.GNOSIS], + protocol: Providers.COW, + ...getDefaultTokens(ChainId.MAINNET), + }), + new OneInch({ + supportedChains: [ChainId.POLYGON, ChainId.OPTIMISM_MAINNET, ChainId.ARBITRUM_ONE, ChainId.BSC_MAINNET], + protocol: Providers.ONEINCH, + ...getDefaultTokens(ChainId.POLYGON), + }), +] diff --git a/src/services/LimitOrders/LimitOrder.ts b/src/services/LimitOrders/LimitOrder.ts new file mode 100644 index 000000000..2f44904c0 --- /dev/null +++ b/src/services/LimitOrders/LimitOrder.ts @@ -0,0 +1,36 @@ +import { getLimitOrderCofig } from './LimitOrder.config' +import { WalletData } from './LimitOrder.types' +import { LimitOrderBase, logger } from './LimitOrder.utils' + +export default class LimitOrder { + #protocols: LimitOrderBase[] + activeProtocol?: LimitOrderBase + + constructor() { + logger('LimitOrder constructor') + this.#protocols = getLimitOrderCofig() + } + + updateSigner = async (signerData: WalletData) => { + logger('LimitOrder updateSigner') + await Promise.all( + Object.values(this.#protocols).map(async protocol => { + await protocol.setSignerData(signerData) + await protocol.onSignerChange(signerData) + }) + ) + this.#setActiveProtocol() + } + + #setActiveProtocol() { + logger('LimitOrder set Active Protocol') + this.activeProtocol = this.#protocols.find( + protocol => protocol.activeChainId && protocol.supportedChanins.includes(protocol.activeChainId) + ) + } + + getActiveProtocol = () => { + logger('LimitOrder get Active Protocol') + return this.activeProtocol + } +} diff --git a/src/services/LimitOrders/LimitOrder.types.ts b/src/services/LimitOrders/LimitOrder.types.ts new file mode 100644 index 000000000..8ddc48e35 --- /dev/null +++ b/src/services/LimitOrders/LimitOrder.types.ts @@ -0,0 +1,98 @@ +import { Web3Provider } from '@ethersproject/providers' +import { ChainId, Token } from '@swapr/sdk' + +import { LimitOrderBase } from './LimitOrder.utils' + +export enum OrderExpiresInUnit { + Minutes = 'minutes', + Days = 'days', +} + +export enum Kind { + Buy = 'Buy', + Sell = 'Sell', +} + +export enum Providers { + COW = 'CoW', + ONEINCH = '1Inch', +} + +export type MarketPrices = { + buy: number + sell: number +} + +export type Callback = (val?: any) => void + +export type LimitOrderBaseConstructor = { + protocol: Providers + supportedChains: ChainId[] + kind: Kind + expiresAt: number + sellToken: Token + buyToken: Token +} + +export type ProtocolContructor = Omit + +export type LimitOrderProviders = { [key in Providers]: LimitOrderBase } + +export type WalletData = { + account: string + provider: Web3Provider + activeChainId: ChainId +} + +type EVMAddress = string + +export type LimitOrder = { + /** + * The user Address. + */ + userAddress: EVMAddress + /** + * receiver Address. + */ + receiverAddress: EVMAddress + /** + * The sell token Address. The sellToken cannot be native token of the network. + */ + sellToken: EVMAddress + /** + * The buy token address. The native token of the network is represented by `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` + */ + buyToken: EVMAddress + /** + * The sell amount. + */ + sellAmount: string + /** + * The buy amount. + */ + buyAmount: string + /** + * Fee amount. + */ + feeAmount: string + /** + * The buy amount. + */ + limitPrice: string + /** + * Order timestamp as epoh seconds. + */ + createdAt: number + /** + * Order expiration time in seconds. + */ + expiresAt: number + /** + * Order kind + */ + kind: Kind + /** + * Quote Id + */ + quoteId?: number | null +} diff --git a/src/services/LimitOrders/LimitOrder.utils.ts b/src/services/LimitOrders/LimitOrder.utils.ts new file mode 100644 index 000000000..504aeed27 --- /dev/null +++ b/src/services/LimitOrders/LimitOrder.utils.ts @@ -0,0 +1,117 @@ +import { Web3Provider } from '@ethersproject/providers' +import { ChainId, Currency, Token, TokenAmount } from '@swapr/sdk' + +import { formatUnits, parseUnits } from 'ethers/lib/utils' + +import { CoWQuote } from './CoW/CoW.types' +import { + LimitOrderBaseConstructor, + WalletData, + OrderExpiresInUnit, + Kind, + LimitOrder, + Providers, + Callback, +} from './LimitOrder.types' + +export const logger = (message?: any, ...optionalParams: any[]) => { + process.env.NODE_ENV === 'development' && console.log(message, ...optionalParams) +} + +export abstract class LimitOrderBase { + limitOrder: LimitOrder | undefined + quote: CoWQuote | unknown + userAddress: string | undefined + receiverAddres: string | undefined + sellToken: Token + buyToken: Token + sellAmount: TokenAmount + buyAmount: TokenAmount + limitPrice: string | undefined + orderType?: 'partial' | 'full' + quoteId: string | undefined + kind?: Kind = Kind.Sell + quoteSellAmount: TokenAmount + quoteBuyAmount: TokenAmount + + provider: Web3Provider | undefined + expiresAt: number + expiresAtUnit: OrderExpiresInUnit = OrderExpiresInUnit.Minutes + createdAt: number | undefined + limitOrderProtocol: Providers + supportedChanins: ChainId[] + activeChainId: ChainId | undefined + loading: boolean = false + userUpdatedLimitPrice: boolean + quoteErrorMessage: string | undefined + + constructor({ protocol, supportedChains, kind, expiresAt, sellToken, buyToken }: LimitOrderBaseConstructor) { + this.limitOrderProtocol = protocol + this.supportedChanins = supportedChains + this.kind = kind + this.expiresAt = expiresAt + this.sellToken = sellToken + this.buyToken = buyToken + this.sellAmount = new TokenAmount(sellToken, parseUnits('1', sellToken.decimals).toString()) + this.buyAmount = new TokenAmount(buyToken, parseUnits('1', buyToken.decimals).toString()) + this.quoteSellAmount = new TokenAmount(sellToken, parseUnits('1', sellToken.decimals).toString()) + this.quoteBuyAmount = new TokenAmount(buyToken, parseUnits('1', buyToken.decimals).toString()) + this.userUpdatedLimitPrice = false + this.quoteErrorMessage = undefined + } + + #logFormat = (message: string) => `LimitOrder:: ${this.limitOrderProtocol} : ${message}` + + logger = { + log: (message: string, ...props: any[]) => logger(this.#logFormat(message), ...props), + error: (message: string, ...props: any[]) => logger(this.#logFormat(message), ...props), + } + + getTokenFromCurrency(currency: Currency): Token { + let token = currency as Token + if (this.activeChainId) { + token = new Token(this.activeChainId, currency.address!, currency.decimals, currency.symbol) + } + return token + } + + getFormattedAmount(amount: TokenAmount) { + const rawAmount = formatUnits(amount.raw.toString(), amount.currency.decimals) || '0' + const formattedAmount = parseFloat(rawAmount).toFixed(6) + if (Number(formattedAmount) === 0) return Number(formattedAmount).toString() + return formattedAmount.replace(/\.?0+$/, '') + } + + async setSignerData({ account, activeChainId, provider }: WalletData) { + this.userAddress = account + this.activeChainId = activeChainId + this.provider = provider + this.logger.log(`Signer data set for ${this.limitOrderProtocol}`) + } + + abstract onSellTokenChange(sellToken: Currency): void + abstract onBuyTokenChange(buyToken: Currency): void + abstract onSellAmountChange(sellAmount: TokenAmount): void + abstract onBuyAmountChange(buyAmount: TokenAmount): void + abstract onLimitOrderChange(limitOrder: any): void + abstract onExpireChange(expiresAt: number): void + abstract onExpireUnitChange(unit: OrderExpiresInUnit): void + abstract onKindChange(kind: Kind): void + abstract onLimitPriceChange(limitPrice: string): void + abstract onUserUpadtedLimitPrice(status: boolean): void + + abstract getQuote(limitOrder?: LimitOrder): Promise + abstract getRawQuote(limitOrder?: LimitOrder): Promise< + | { + id: number | null + buyAmount: string + sellAmount: string + } + | undefined + > + abstract getMarketPrice(): Promise + abstract getLimitPrice(): string + abstract onSignerChange({ account, activeChainId, provider }: WalletData): Promise + abstract approve(): Promise + abstract createOrder(successCallback?: Callback, errorCallback?: Callback, final?: Callback): Promise +} diff --git a/src/services/LimitOrders/index.ts b/src/services/LimitOrders/index.ts new file mode 100644 index 000000000..57e314e25 --- /dev/null +++ b/src/services/LimitOrders/index.ts @@ -0,0 +1,4 @@ +export { default } from './LimitOrder' +export * from './LimitOrder.types' +export * from './LimitOrder.utils' +export * from './LimitOrder.config' diff --git a/src/state/user/reducer.ts b/src/state/user/reducer.ts index 707168ea2..b4ad20ee6 100644 --- a/src/state/user/reducer.ts +++ b/src/state/user/reducer.ts @@ -30,6 +30,7 @@ const currentTimestamp = () => new Date().getTime() export enum SwapTab { SWAP = 'SWAP', LIMIT_ORDER = 'LIMIT_ORDER', + LIMIT_ORDER_NEW = 'LIMIT_ORDER_NEW', BRIDGE_SWAP = 'BRIDGE_SWAP', }
+ {inputGroupLabel} + {showPercentage && ( + ({marketPriceDiffPercentage.toFixed(2)}%) + )} +