mango-v4-ui/components/trade/SpotMarketOrderSwapForm.tsx

682 lines
24 KiB
TypeScript
Raw Normal View History

2023-07-16 20:41:13 -07:00
import mangoStore from '@store/mangoStore'
import NumberFormat, {
NumberFormatValues,
SourceInfo,
} from 'react-number-format'
import {
DEFAULT_CHECKBOX_SETTINGS,
2023-07-16 20:41:13 -07:00
INPUT_PREFIX_CLASSNAMES,
INPUT_SUFFIX_CLASSNAMES,
} from './AdvancedTradeForm'
import LogoWithFallback from '@components/shared/LogoWithFallback'
import { LinkIcon, QuestionMarkCircleIcon } from '@heroicons/react/20/solid'
import useSelectedMarket from 'hooks/useSelectedMarket'
import { useWallet } from '@solana/wallet-adapter-react'
import useIpAddress from 'hooks/useIpAddress'
import { useTranslation } from 'next-i18next'
import { FormEvent, useCallback, useMemo, useState } from 'react'
import Loading from '@components/shared/Loading'
import Button from '@components/shared/Button'
import Image from 'next/image'
import useQuoteRoutes from '@components/swap/useQuoteRoutes'
import {
2023-07-18 11:55:13 -07:00
HealthType,
2023-07-16 20:41:13 -07:00
Serum3Market,
fetchJupiterTransaction,
} from '@blockworks-foundation/mango-v4'
import Decimal from 'decimal.js'
import { notify } from 'utils/notifications'
import * as sentry from '@sentry/nextjs'
import { isMangoError } from 'types'
import SwapSlider from '@components/swap/SwapSlider'
import PercentageSelectButtons from '@components/swap/PercentageSelectButtons'
import { SIZE_INPUT_UI_KEY, TRADE_CHECKBOXES_KEY } from 'utils/constants'
2023-07-16 20:41:13 -07:00
import useLocalStorageState from 'hooks/useLocalStorageState'
2023-07-18 11:55:13 -07:00
import useUnownedAccount from 'hooks/useUnownedAccount'
import HealthImpact from '@components/shared/HealthImpact'
import Tooltip from '@components/shared/Tooltip'
import Checkbox from '@components/forms/Checkbox'
import MaxMarketSwapAmount from './MaxMarketSwapAmount'
import { floorToDecimal, formatNumericValue } from 'utils/numbers'
import { formatTokenSymbol } from 'utils/tokens'
import FormatNumericValue from '@components/shared/FormatNumericValue'
2023-07-24 19:56:28 -07:00
import { useTokenMax } from '@components/swap/useTokenMax'
2023-07-24 20:15:00 -07:00
import SheenLoader from '@components/shared/SheenLoader'
2023-07-16 20:41:13 -07:00
const set = mangoStore.getState().set
const slippage = 100
function stringToNumberOrZero(s: string): number {
const n = parseFloat(s)
if (isNaN(n)) {
return 0
}
return n
}
export default function SpotMarketOrderSwapForm() {
2023-07-18 11:55:13 -07:00
const { t } = useTranslation()
2023-07-24 19:56:28 -07:00
const { baseSize, quoteSize, side } = mangoStore((s) => s.tradeForm)
2023-07-18 11:55:13 -07:00
const { isUnownedAccount } = useUnownedAccount()
2023-07-16 20:41:13 -07:00
const [placingOrder, setPlacingOrder] = useState(false)
const { ipAllowed, ipCountry } = useIpAddress()
const { connected, publicKey, connect } = useWallet()
2023-07-16 20:41:13 -07:00
const [swapFormSizeUi] = useLocalStorageState(SIZE_INPUT_UI_KEY, 'slider')
const [savedCheckboxSettings, setSavedCheckboxSettings] =
useLocalStorageState(TRADE_CHECKBOXES_KEY, DEFAULT_CHECKBOX_SETTINGS)
2023-07-16 20:41:13 -07:00
const {
selectedMarket,
price: oraclePrice,
baseLogoURI,
baseSymbol,
quoteLogoURI,
quoteSymbol,
serumOrPerpMarket,
} = useSelectedMarket()
2023-07-24 19:56:28 -07:00
const { amount: tokenMax, amountWithBorrow } = useTokenMax(
savedCheckboxSettings.margin,
)
2023-07-16 20:41:13 -07:00
const handleBaseSizeChange = useCallback(
(e: NumberFormatValues, info: SourceInfo) => {
if (info.source !== 'event') return
set((s) => {
const price =
s.tradeForm.tradeType === 'Market'
? oraclePrice
: Number(s.tradeForm.price)
s.tradeForm.baseSize = e.value
if (price && e.value !== '' && !Number.isNaN(Number(e.value))) {
s.tradeForm.quoteSize = new Decimal(price).mul(e.value).toFixed()
} else {
s.tradeForm.quoteSize = ''
}
})
},
2023-07-21 11:47:53 -07:00
[oraclePrice],
2023-07-16 20:41:13 -07:00
)
const handleQuoteSizeChange = useCallback(
(e: NumberFormatValues, info: SourceInfo) => {
if (info.source !== 'event') return
set((s) => {
const price =
s.tradeForm.tradeType === 'Market'
? oraclePrice
: Number(s.tradeForm.price)
s.tradeForm.quoteSize = e.value
if (price && e.value !== '' && !Number.isNaN(Number(e.value))) {
s.tradeForm.baseSize = new Decimal(e.value).div(price).toFixed()
} else {
s.tradeForm.baseSize = ''
}
})
},
2023-07-21 11:47:53 -07:00
[oraclePrice],
2023-07-16 20:41:13 -07:00
)
const setAmountFromSlider = useCallback(
(amount: string) => {
if (side === 'buy') {
handleQuoteSizeChange(
{ value: amount } as NumberFormatValues,
2023-07-21 11:47:53 -07:00
{ source: 'event' } as SourceInfo,
2023-07-16 20:41:13 -07:00
)
} else {
handleBaseSizeChange(
{ value: amount } as NumberFormatValues,
2023-07-21 11:47:53 -07:00
{ source: 'event' } as SourceInfo,
2023-07-16 20:41:13 -07:00
)
}
},
2023-07-21 11:47:53 -07:00
[side, handleBaseSizeChange, handleQuoteSizeChange],
2023-07-16 20:41:13 -07:00
)
const [inputBank, outputBank] = useMemo(() => {
const group = mangoStore.getState().group
if (!group || !(selectedMarket instanceof Serum3Market)) return []
const quoteBank = group?.getFirstBankByTokenIndex(
2023-07-21 11:47:53 -07:00
selectedMarket.quoteTokenIndex,
2023-07-16 20:41:13 -07:00
)
const baseBank = group.getFirstBankByTokenIndex(
2023-07-21 11:47:53 -07:00
selectedMarket.baseTokenIndex,
2023-07-16 20:41:13 -07:00
)
if (side === 'buy') {
set((s) => {
s.swap.inputBank = quoteBank
s.swap.outputBank = baseBank
})
return [quoteBank, baseBank]
} else {
set((s) => {
s.swap.inputBank = baseBank
s.swap.outputBank = quoteBank
})
return [baseBank, quoteBank]
}
}, [selectedMarket, side])
const { bestRoute: selectedRoute, isLoading } = useQuoteRoutes({
inputMint: inputBank?.mint.toString() || '',
outputMint: outputBank?.mint.toString() || '',
amount: side === 'buy' ? quoteSize : baseSize,
slippage,
swapMode: 'ExactIn',
wallet: publicKey?.toBase58(),
})
const handlePlaceOrder = useCallback(async () => {
const client = mangoStore.getState().client
const group = mangoStore.getState().group
const mangoAccount = mangoStore.getState().mangoAccount.current
const { baseSize, quoteSize, side } = mangoStore.getState().tradeForm
const actions = mangoStore.getState().actions
const connection = mangoStore.getState().connection
if (!group || !mangoAccount) return
if (
!mangoAccount ||
!group ||
!inputBank ||
!outputBank ||
!publicKey ||
!selectedRoute
2023-07-18 11:55:13 -07:00
)
2023-07-16 20:41:13 -07:00
return
setPlacingOrder(true)
const [ixs, alts] = await fetchJupiterTransaction(
connection,
selectedRoute,
publicKey,
slippage,
inputBank.mint,
2023-07-21 11:47:53 -07:00
outputBank.mint,
2023-07-16 20:41:13 -07:00
)
try {
const tx = await client.marginTrade({
group,
mangoAccount,
inputMintPk: inputBank.mint,
amountIn:
side === 'buy'
? stringToNumberOrZero(quoteSize)
: stringToNumberOrZero(baseSize),
outputMintPk: outputBank.mint,
userDefinedInstructions: ixs,
userDefinedAlts: alts,
flashLoanType: { swap: {} },
})
set((s) => {
s.successAnimation.swap = true
})
// if (soundSettings['swap-success']) {
// successSound.play()
// }
notify({
title: 'Transaction confirmed',
type: 'success',
txid: tx,
noSound: true,
})
actions.fetchGroup()
actions.fetchSwapHistory(mangoAccount.publicKey.toString(), 30000)
await actions.reloadMangoAccount()
set((s) => {
s.tradeForm.baseSize = ''
s.tradeForm.quoteSize = ''
})
} catch (e) {
console.error('onSwap error: ', e)
sentry.captureException(e)
if (isMangoError(e)) {
notify({
title: 'Transaction failed',
description: e.message,
txid: e?.txid,
type: 'error',
})
}
} finally {
setPlacingOrder(false)
}
}, [inputBank, outputBank, publicKey, selectedRoute])
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
connected ? handlePlaceOrder() : connect()
2023-07-16 20:41:13 -07:00
}
2023-07-18 11:55:13 -07:00
const maintProjectedHealth = useMemo(() => {
const group = mangoStore.getState().group
const mangoAccount = mangoStore.getState().mangoAccount.current
if (!inputBank || !mangoAccount || !outputBank || !group) return 0
const simulatedHealthRatio =
mangoAccount.simHealthRatioWithTokenPositionUiChanges(
group,
[
{
mintPk: inputBank.mint,
uiTokenAmount:
(side === 'buy'
? stringToNumberOrZero(quoteSize)
: stringToNumberOrZero(baseSize)) * -1,
},
{
mintPk: outputBank.mint,
uiTokenAmount:
side === 'buy'
? stringToNumberOrZero(baseSize)
: stringToNumberOrZero(quoteSize),
},
],
2023-07-21 11:47:53 -07:00
HealthType.maint,
2023-07-18 11:55:13 -07:00
)
return simulatedHealthRatio > 100
? 100
: simulatedHealthRatio < 0
? 0
: Math.trunc(simulatedHealthRatio)
}, [inputBank, outputBank, baseSize, quoteSize, side])
const [balance, borrowAmount] = useMemo(() => {
if (!inputBank) return [0, 0]
const mangoAccount = mangoStore.getState().mangoAccount.current
if (!mangoAccount) return [0, 0]
let borrowAmount
const balance = mangoAccount.getTokenDepositsUi(inputBank)
if (side === 'buy') {
const remainingBalance = balance - parseFloat(quoteSize)
borrowAmount = remainingBalance < 0 ? Math.abs(remainingBalance) : 0
} else {
const remainingBalance = balance - parseFloat(baseSize)
borrowAmount = remainingBalance < 0 ? Math.abs(remainingBalance) : 0
}
return [balance, borrowAmount]
}, [baseSize, inputBank, quoteSize])
const orderValue = useMemo(() => {
2023-07-24 19:56:28 -07:00
if (
!inputBank ||
!outputBank ||
!oraclePrice ||
!baseSize ||
isNaN(parseFloat(baseSize))
)
return 0
2023-07-24 19:56:28 -07:00
const quotePriceDecimal =
side === 'buy'
? new Decimal(inputBank.uiPrice)
: new Decimal(outputBank.uiPrice)
const basePriceDecimal = new Decimal(oraclePrice)
const sizeDecimal = new Decimal(baseSize)
return floorToDecimal(
basePriceDecimal.mul(quotePriceDecimal).mul(sizeDecimal),
2,
)
2023-07-24 19:56:28 -07:00
}, [baseSize, inputBank, outputBank, oraclePrice, side])
const tooMuchSize = useMemo(() => {
if (!baseSize || !quoteSize || !amountWithBorrow || !tokenMax) return false
const size = side === 'buy' ? new Decimal(quoteSize) : new Decimal(baseSize)
const useMargin = savedCheckboxSettings.margin
return useMargin ? size.gt(amountWithBorrow) : size.gt(tokenMax)
}, [
amountWithBorrow,
baseSize,
quoteSize,
side,
tokenMax,
savedCheckboxSettings.margin,
])
2023-07-16 20:41:13 -07:00
const disabled =
2023-07-24 19:56:28 -07:00
(connected && (!baseSize || !oraclePrice)) ||
2023-07-16 20:41:13 -07:00
!serumOrPerpMarket ||
parseFloat(baseSize) < serumOrPerpMarket.minOrderSize ||
2023-07-24 19:56:28 -07:00
isLoading ||
tooMuchSize
2023-07-16 20:41:13 -07:00
return (
<>
<form onSubmit={(e) => handleSubmit(e)}>
<div className="mt-3 px-3 md:px-4">
{!isUnownedAccount ? (
<MaxMarketSwapAmount
useMargin={savedCheckboxSettings.margin}
setAmountIn={setAmountFromSlider}
/>
) : null}
2023-07-16 20:41:13 -07:00
<div className="flex flex-col">
<div className="relative">
<NumberFormat
inputMode="decimal"
thousandSeparator=","
allowNegative={false}
isNumericString={true}
decimalScale={
side === 'buy'
? outputBank?.mintDecimals
: inputBank?.mintDecimals
}
name="base"
id="base"
className="relative flex w-full items-center rounded-md rounded-b-none border border-th-input-border bg-th-input-bkg p-2 pl-9 font-mono text-sm font-bold text-th-fgd-1 focus:z-10 focus:border-th-fgd-4 focus:outline-none md:hover:z-10 md:hover:border-th-input-border-hover md:hover:focus:border-th-fgd-4 lg:text-base"
placeholder="0.00"
value={baseSize}
onValueChange={handleBaseSizeChange}
/>
<div className={`z-10 ${INPUT_PREFIX_CLASSNAMES}`}>
<LogoWithFallback
alt=""
className="drop-shadow-md"
width={'24'}
height={'24'}
src={baseLogoURI || `/icons/${baseSymbol?.toLowerCase()}.svg`}
fallback={
<QuestionMarkCircleIcon
className={`h-5 w-5 text-th-fgd-3`}
/>
}
/>
</div>
<div className={`z-10 ${INPUT_SUFFIX_CLASSNAMES}`}>
{baseSymbol}
</div>
</div>
<div className="relative">
{quoteLogoURI ? (
<div className={INPUT_PREFIX_CLASSNAMES}>
<Image alt="" width="20" height="20" src={quoteLogoURI} />
</div>
) : (
<div className={INPUT_PREFIX_CLASSNAMES}>
<QuestionMarkCircleIcon className="h-5 w-5 text-th-fgd-3" />
</div>
)}
<NumberFormat
inputMode="decimal"
thousandSeparator=","
allowNegative={false}
isNumericString={true}
decimalScale={
side === 'buy'
? inputBank?.mintDecimals
: outputBank?.mintDecimals
}
name="quote"
id="quote"
className="-mt-[1px] flex w-full items-center rounded-md rounded-t-none border border-th-input-border bg-th-input-bkg p-2 pl-9 font-mono text-sm font-bold text-th-fgd-1 focus:border-th-fgd-4 focus:outline-none md:hover:border-th-input-border-hover md:hover:focus:border-th-fgd-4 lg:text-base"
placeholder="0.00"
value={quoteSize}
onValueChange={handleQuoteSizeChange}
/>
<div className={INPUT_SUFFIX_CLASSNAMES}>{quoteSymbol}</div>
</div>
</div>
{swapFormSizeUi === 'slider' ? (
2023-07-24 20:08:43 -07:00
<div className="mt-2">
<SwapSlider
useMargin={savedCheckboxSettings.margin}
amount={
side === 'buy'
? stringToNumberOrZero(quoteSize)
: stringToNumberOrZero(baseSize)
}
onChange={setAmountFromSlider}
step={1 / 10 ** (inputBank?.mintDecimals || 6)}
/>
</div>
2023-07-16 20:41:13 -07:00
) : (
<PercentageSelectButtons
amountIn={side === 'buy' ? quoteSize : baseSize}
2023-07-18 11:55:13 -07:00
setAmountIn={setAmountFromSlider}
useMargin={savedCheckboxSettings.margin}
values={['25', '50', '75', '100']}
2023-07-16 20:41:13 -07:00
/>
)}
<div className="mt-4">
2023-07-18 11:55:13 -07:00
<Tooltip
className="hidden md:block"
delay={100}
placement="left"
content={t('trade:tooltip-enable-margin')}
2023-07-18 11:55:13 -07:00
>
<Checkbox
checked={savedCheckboxSettings.margin}
onChange={(e) =>
setSavedCheckboxSettings({
...savedCheckboxSettings,
margin: e.target.checked,
})
}
>
{t('trade:margin')}
</Checkbox>
2023-07-18 11:55:13 -07:00
</Tooltip>
</div>
<div className="mt-6 mb-4 flex">
{ipAllowed ? (
<Button
className={`flex w-full items-center justify-center ${
!connected
? ''
: side === 'buy'
? 'bg-th-up-dark text-white md:hover:bg-th-up-dark md:hover:brightness-90'
: 'bg-th-down-dark text-white md:hover:bg-th-down-dark md:hover:brightness-90'
}`}
disabled={disabled}
size="large"
type="submit"
>
{isLoading ? (
<div className="flex items-center space-x-2">
<Loading />
<span className="hidden sm:block">
{t('common:fetching-route')}
</span>
</div>
) : !connected ? (
<div className="flex items-center">
<LinkIcon className="mr-2 h-5 w-5" />
{t('connect')}
</div>
2023-07-24 19:56:28 -07:00
) : tooMuchSize ? (
<span>
{t('swap:insufficient-balance', {
symbol: '',
})}
</span>
) : !placingOrder ? (
<span>
{t('trade:place-order', {
side: side === 'buy' ? t('buy') : t('sell'),
})}
</span>
) : (
<div className="flex items-center space-x-2">
<Loading />
<span className="hidden sm:block">
{t('trade:placing-order')}
</span>
</div>
)}
</Button>
) : (
<Button disabled className="w-full leading-tight" size="large">
{t('country-not-allowed', {
country: ipCountry ? `(${ipCountry})` : '',
2023-07-18 11:55:13 -07:00
})}
</Button>
)}
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<p>{t('trade:order-value')}</p>
<p className="font-mono text-th-fgd-2">
{orderValue ? (
<FormatNumericValue value={orderValue} isUsd />
) : (
''
)}
</p>
</div>
<HealthImpact maintProjectedHealth={maintProjectedHealth} small />
<div className="flex justify-between text-xs">
<Tooltip
content={
<>
<p>
The price impact is the difference observed between the
total value of the entry tokens swapped and the
destination tokens obtained.
</p>
<p className="mt-1">
The bigger the trade is, the bigger the price impact can
be.
</p>
</>
}
>
<p className="tooltip-underline">{t('swap:price-impact')}</p>
</Tooltip>
2023-07-24 20:15:00 -07:00
{isLoading ? (
<SheenLoader>
<div className="h-3.5 w-12 bg-th-bkg-2" />
</SheenLoader>
) : (
<p className="text-right font-mono text-th-fgd-2">
{selectedRoute
? selectedRoute?.priceImpactPct * 100 < 0.1
? '<0.1%'
: `${(selectedRoute?.priceImpactPct * 100).toFixed(2)}%`
: '-'}
</p>
)}
</div>
{borrowAmount && inputBank ? (
<>
<div className="flex justify-between text-xs">
<Tooltip
content={
balance
? t('trade:tooltip-borrow-balance', {
balance: formatNumericValue(balance),
borrowAmount: formatNumericValue(borrowAmount),
token: formatTokenSymbol(inputBank.name),
rate: formatNumericValue(
inputBank.getBorrowRateUi(),
2,
),
})
: t('trade:tooltip-borrow-no-balance', {
borrowAmount: formatNumericValue(borrowAmount),
token: formatTokenSymbol(inputBank.name),
rate: formatNumericValue(
inputBank.getBorrowRateUi(),
2,
),
})
}
delay={100}
>
<p className="tooltip-underline">{t('borrow-amount')}</p>
</Tooltip>
<p className="text-right font-mono text-th-fgd-2">
<FormatNumericValue
value={borrowAmount}
decimals={inputBank.mintDecimals}
/>{' '}
<span className="font-body text-th-fgd-4">
{formatTokenSymbol(inputBank.name)}
</span>
</p>
</div>
<div className="flex justify-between text-xs">
<Tooltip
content={t('loan-origination-fee-tooltip', {
fee: `${(
inputBank.loanOriginationFeeRate.toNumber() * 100
).toFixed(3)}%`,
})}
delay={100}
>
<p className="tooltip-underline">
{t('loan-origination-fee')}
</p>
</Tooltip>
<p className="text-right font-mono text-th-fgd-2">
<FormatNumericValue
value={
borrowAmount *
inputBank.loanOriginationFeeRate.toNumber()
}
decimals={inputBank.mintDecimals}
/>{' '}
<span className="font-body text-th-fgd-4">
{formatTokenSymbol(inputBank.name)}
</span>
</p>
</div>
</>
) : null}
<div className="flex items-center justify-between text-xs">
<p className="pr-2 text-th-fgd-3">{t('common:route')}</p>
2023-07-24 20:15:00 -07:00
{isLoading ? (
<SheenLoader>
<div className="h-3.5 w-20 bg-th-bkg-2" />
</SheenLoader>
) : (
<div className="flex items-center overflow-hidden text-th-fgd-2">
<Tooltip
content={selectedRoute?.marketInfos.map((info, index) => {
let includeSeparator = false
if (
selectedRoute?.marketInfos.length > 1 &&
index !== selectedRoute?.marketInfos.length - 1
) {
includeSeparator = true
}
return (
<span key={index}>{`${info?.label} ${
includeSeparator ? 'x ' : ''
}`}</span>
)
})}
2023-07-24 20:15:00 -07:00
>
<div className="tooltip-underline truncate whitespace-nowrap max-w-[140px]">
{selectedRoute?.marketInfos.map((info, index) => {
let includeSeparator = false
if (
selectedRoute?.marketInfos.length > 1 &&
index !== selectedRoute?.marketInfos.length - 1
) {
includeSeparator = true
}
return (
<span key={index}>{`${info?.label} ${
includeSeparator ? 'x ' : ''
}`}</span>
)
})}
</div>
</Tooltip>
</div>
)}
2023-07-18 11:55:13 -07:00
</div>
</div>
</div>
2023-07-16 20:41:13 -07:00
</form>
</>
)
}