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

268 lines
8.4 KiB
TypeScript

import {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import mangoStore from '@store/mangoStore'
import { useTranslation } from 'next-i18next'
import {
PerpOrderSide,
PerpOrderType,
PerpPosition,
} from '@blockworks-foundation/mango-v4'
import Modal from '@components/shared/Modal'
import Button, { LinkButton } from '@components/shared/Button'
import { notify } from 'utils/notifications'
import Loading from '@components/shared/Loading'
import useLocalStorageState from 'hooks/useLocalStorageState'
import { MAX_PERP_SLIPPAGE, SOUND_SETTINGS_KEY } from 'utils/constants'
import { INITIAL_SOUND_SETTINGS } from '@components/settings/SoundSettings'
import { Howl } from 'howler'
import { isMangoError } from 'types'
import { decodeBook, decodeBookL2 } from 'utils/orderbook'
import InlineNotification from '@components/shared/InlineNotification'
import { getDecimalCount } from 'utils/numbers'
import useOpenPerpPositions from 'hooks/useOpenPerpPositions'
interface MarketCloseModalProps {
onClose: () => void
isOpen: boolean
position: PerpPosition
}
const set = mangoStore.getState().set
const successSound = new Howl({
src: ['/sounds/swap-success.mp3'],
volume: 0.5,
})
type BidsAndAsks = number[][] | null
const MarketCloseModal: FunctionComponent<MarketCloseModalProps> = ({
onClose,
isOpen,
position,
}) => {
const { t } = useTranslation(['common', 'trade'])
const [submitting, setSubmitting] = useState(false)
const { poolIsPerpReadyForRefresh } = useOpenPerpPositions()
const connection = mangoStore((s) => s.connection)
const group = mangoStore((s) => s.group)
const [soundSettings] = useLocalStorageState(
SOUND_SETTINGS_KEY,
INITIAL_SOUND_SETTINGS,
)
const [asks, setAsks] = useState<BidsAndAsks>(null)
const [bids, setBids] = useState<BidsAndAsks>(null)
const perpMarket = useMemo(() => {
return group?.getPerpMarketByMarketIndex(position.marketIndex)
}, [group, position?.marketIndex])
// subscribe to the bids and asks orderbook accounts
useEffect(() => {
console.log('setting up orderbook websockets')
const client = mangoStore.getState().client
if (!group || !perpMarket) return
let bidSubscriptionId: number | undefined = undefined
let askSubscriptionId: number | undefined = undefined
let lastSeenBidsSlot: number
let lastSeenAsksSlot: number
const bidsPk = perpMarket.bids
if (bidsPk) {
connection
.getAccountInfoAndContext(bidsPk)
.then(({ context, value: info }) => {
if (!info) return
const decodedBook = decodeBook(client, perpMarket, info, 'bids')
setBids(decodeBookL2(decodedBook))
lastSeenBidsSlot = context.slot
})
bidSubscriptionId = connection.onAccountChange(
bidsPk,
(info, context) => {
if (context.slot > lastSeenBidsSlot) {
const decodedBook = decodeBook(client, perpMarket, info, 'bids')
setBids(decodeBookL2(decodedBook))
}
},
'processed',
)
}
const asksPk = perpMarket.asks
if (asksPk) {
connection
.getAccountInfoAndContext(asksPk)
.then(({ context, value: info }) => {
if (!info) return
const decodedBook = decodeBook(client, perpMarket, info, 'asks')
setAsks(decodeBookL2(decodedBook))
lastSeenAsksSlot = context.slot
})
askSubscriptionId = connection.onAccountChange(
asksPk,
(info, context) => {
if (context.slot > lastSeenAsksSlot) {
const decodedBook = decodeBook(client, perpMarket, info, 'asks')
setAsks(decodeBookL2(decodedBook))
}
},
'processed',
)
}
return () => {
if (typeof bidSubscriptionId !== 'undefined') {
connection.removeAccountChangeListener(bidSubscriptionId)
}
if (typeof askSubscriptionId !== 'undefined') {
connection.removeAccountChangeListener(askSubscriptionId)
}
}
}, [connection, perpMarket, group])
const insufficientLiquidity = useMemo(() => {
if (!perpMarket) return false
const baseSize = position.getBasePositionUi(perpMarket)
const side = baseSize < 0 ? 'buy' : 'sell'
if (side === 'sell') {
if (!bids || !bids.length) return false
const marketPrice = Math.max(perpMarket.uiPrice, bids[0][0])
const limitPrice = marketPrice * (1 - MAX_PERP_SLIPPAGE)
const filteredBidsForPrice = bids.filter((bid) => bid[0] >= limitPrice)
const liquidityMax = filteredBidsForPrice.reduce((a, c) => a + c[1], 0)
return liquidityMax < baseSize
} else {
if (!asks || !asks.length) return false
const marketPrice = Math.min(perpMarket.uiPrice, asks[0][0])
const limitPrice = marketPrice * (1 + MAX_PERP_SLIPPAGE)
const filteredAsksForPrice = asks.filter((ask) => ask[0] <= limitPrice)
const liquidityMax = filteredAsksForPrice.reduce((a, c) => a + c[1], 0)
return liquidityMax < baseSize
}
}, [perpMarket, position, bids, asks])
const handleMarketClose = useCallback(async () => {
const client = mangoStore.getState().client
const mangoAccount = mangoStore.getState().mangoAccount.current
const actions = mangoStore.getState().actions
if (!group || !mangoAccount || !perpMarket || !bids || !asks) {
notify({
title: 'Something went wrong. Try again later',
type: 'error',
})
return
}
setSubmitting(true)
try {
const baseSize = position.getBasePositionUi(perpMarket)
const side = baseSize > 0 ? 'sell' : 'buy'
let price = perpMarket.uiPrice
if (side === 'sell') {
const marketPrice = Math.max(price, bids?.[0]?.[0] || 0)
price = marketPrice * (1 - MAX_PERP_SLIPPAGE)
} else {
const marketPrice = Math.min(price, asks?.[0]?.[0] || Infinity)
price = marketPrice * (1 + MAX_PERP_SLIPPAGE)
}
notify({
type: 'info',
title: t('trade:max-slippage-price-notification', {
price: `$${price.toFixed(getDecimalCount(perpMarket.tickSize))}`,
}),
})
const { signature: tx } = await client.perpPlaceOrder(
group,
mangoAccount,
perpMarket.perpMarketIndex,
side === 'buy' ? PerpOrderSide.bid : PerpOrderSide.ask,
price,
Math.abs(baseSize) * 2, // send a larger size to ensure full order is closed
undefined, // maxQuoteQuantity
Date.now(),
PerpOrderType.immediateOrCancel,
true, // reduce only
undefined,
undefined,
)
await poolIsPerpReadyForRefresh(
() => {
actions.fetchOpenOrders(true)
notify({
type: 'success',
title: 'Transaction successful',
txid: tx,
})
},
() => {
notify({
type: 'error',
title: 'Please try again to close position',
})
},
)
if (soundSettings['swap-success']) {
successSound.play()
}
set((s) => {
s.successAnimation.trade = true
})
} catch (e) {
if (isMangoError(e)) {
notify({
title: 'There was an issue.',
description: e.message,
txid: e?.txid,
type: 'error',
})
}
console.error('Place trade error:', e)
} finally {
setSubmitting(false)
onClose()
}
}, [perpMarket, position, group, onClose, soundSettings, bids, asks])
return (
<Modal onClose={onClose} isOpen={isOpen}>
<h3 className="mb-2 text-center">
{t('trade:close-confirm', { config_name: perpMarket?.name })}
</h3>
<div className="pb-6 text-th-fgd-3">{t('trade:price-expect')}</div>
{insufficientLiquidity ? (
<div className="mb-3">
<InlineNotification
type="error"
desc={t('trade:insufficient-perp-liquidity')}
/>
</div>
) : null}
<Button
className="mb-4 flex w-full items-center justify-center"
disabled={insufficientLiquidity}
onClick={handleMarketClose}
size="large"
>
{submitting ? <Loading /> : <span>{t('trade:close-position')}</span>}
</Button>
<LinkButton
className="inline-flex w-full items-center justify-center"
onClick={onClose}
>
{t('cancel')}
</LinkButton>
</Modal>
)
}
export default MarketCloseModal