1173 lines
40 KiB
TypeScript
1173 lines
40 KiB
TypeScript
import React, {
|
||
Dispatch,
|
||
SetStateAction,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react'
|
||
import {
|
||
TransactionInstruction,
|
||
PublicKey,
|
||
VersionedTransaction,
|
||
Connection,
|
||
TransactionMessage,
|
||
AddressLookupTableAccount,
|
||
} from '@solana/web3.js'
|
||
import Decimal from 'decimal.js'
|
||
import * as sentry from '@sentry/nextjs'
|
||
|
||
import mangoStore from '@store/mangoStore'
|
||
import Button, { IconButton } from '../shared/Button'
|
||
import Loading from '../shared/Loading'
|
||
import {
|
||
ArrowLeftIcon,
|
||
PencilIcon,
|
||
ArrowsRightLeftIcon,
|
||
ArrowRightIcon,
|
||
ChevronDownIcon,
|
||
ArrowPathIcon,
|
||
} from '@heroicons/react/20/solid'
|
||
import { useTranslation } from 'next-i18next'
|
||
import { formatNumericValue } from '../../utils/numbers'
|
||
import { notify } from '../../utils/notifications'
|
||
import useJupiterMints from '../../hooks/useJupiterMints'
|
||
import { JupiterV6RouteInfo, JupiterV6RoutePlan } from 'types/jupiter'
|
||
import useJupiterSwapData from './useJupiterSwapData'
|
||
// import { Transaction } from '@solana/web3.js'
|
||
import {
|
||
JUPITER_V6_QUOTE_API_MAINNET,
|
||
MANGO_ROUTER_API_URL,
|
||
SOUND_SETTINGS_KEY,
|
||
} from 'utils/constants'
|
||
import useLocalStorageState from 'hooks/useLocalStorageState'
|
||
import { Howl } from 'howler'
|
||
import { INITIAL_SOUND_SETTINGS } from '@components/settings/SoundSettings'
|
||
import Tooltip from '@components/shared/Tooltip'
|
||
import { Disclosure, Transition } from '@headlessui/react'
|
||
import RoutesModal from './RoutesModal'
|
||
// import { createAssociatedTokenAccountIdempotentInstruction } from '@blockworks-foundation/mango-v4'
|
||
import FormatNumericValue from '@components/shared/FormatNumericValue'
|
||
import { isMangoError } from 'types'
|
||
import { useWallet } from '@solana/wallet-adapter-react'
|
||
import TokenLogo from '@components/shared/TokenLogo'
|
||
import {
|
||
Bank,
|
||
TransactionErrors,
|
||
parseTxForKnownErrors,
|
||
} from '@blockworks-foundation/mango-v4'
|
||
import CircularProgress from '@components/shared/CircularProgress'
|
||
import {
|
||
QueryObserverResult,
|
||
RefetchOptions,
|
||
RefetchQueryFilters,
|
||
} from '@tanstack/react-query'
|
||
import { isTokenInsured } from '@components/DepositForm'
|
||
import UninsuredNotification from '@components/shared/UninsuredNotification'
|
||
import { sendTxAndConfirm } from 'utils/governance/tools'
|
||
import useAnalytics from 'hooks/useAnalytics'
|
||
import Checkbox from '@components/forms/Checkbox'
|
||
|
||
type JupiterRouteInfoProps = {
|
||
amountIn: Decimal
|
||
loadingRoute: boolean
|
||
isWalletSwap?: boolean
|
||
onClose: () => void
|
||
onSuccess?: () => void
|
||
refetchRoute:
|
||
| (<TPageData>(
|
||
options?: (RefetchOptions & RefetchQueryFilters<TPageData>) | undefined,
|
||
) => Promise<
|
||
QueryObserverResult<{ bestRoute: JupiterV6RouteInfo | null }, Error>
|
||
>)
|
||
| undefined
|
||
routes: JupiterV6RouteInfo[] | undefined
|
||
selectedRoute: JupiterV6RouteInfo | undefined | null
|
||
setSelectedRoute: Dispatch<
|
||
SetStateAction<JupiterV6RouteInfo | undefined | null>
|
||
>
|
||
slippage: number
|
||
show: boolean
|
||
}
|
||
|
||
const deserializeJupiterIxAndAlt = async (
|
||
connection: Connection,
|
||
swapTransaction: string,
|
||
): Promise<[TransactionInstruction[], AddressLookupTableAccount[]]> => {
|
||
const parsedSwapTransaction = VersionedTransaction.deserialize(
|
||
Buffer.from(swapTransaction, 'base64'),
|
||
)
|
||
const message = parsedSwapTransaction.message
|
||
// const lookups = message.addressTableLookups
|
||
const addressLookupTablesResponses = await Promise.all(
|
||
message.addressTableLookups.map((alt) =>
|
||
connection.getAddressLookupTable(alt.accountKey),
|
||
),
|
||
)
|
||
const addressLookupTables: AddressLookupTableAccount[] =
|
||
addressLookupTablesResponses
|
||
.map((alt) => alt.value)
|
||
.filter((x): x is AddressLookupTableAccount => x !== null)
|
||
|
||
const decompiledMessage = TransactionMessage.decompile(message, {
|
||
addressLookupTableAccounts: addressLookupTables,
|
||
})
|
||
|
||
return [decompiledMessage.instructions, addressLookupTables]
|
||
}
|
||
|
||
const calculateOutFees = (
|
||
routePlan: JupiterV6RoutePlan[],
|
||
inputBank: Bank,
|
||
outputBank: Bank,
|
||
outputDecimals: number,
|
||
): [number, number] => {
|
||
let outFee = 0
|
||
for (let i = 0; i < routePlan.length; i++) {
|
||
const r = routePlan[i].swapInfo
|
||
const price = r.outAmount / r.inAmount
|
||
outFee *= price
|
||
if (r.feeMint === r.outputMint) {
|
||
outFee += r.feeAmount
|
||
} else {
|
||
outFee += r.feeAmount * price
|
||
}
|
||
}
|
||
const jupiterFee = outFee / 10 ** outputDecimals
|
||
|
||
const flashLoanSwapFeeRate = Math.max(
|
||
inputBank.flashLoanSwapFeeRate,
|
||
outputBank.flashLoanSwapFeeRate,
|
||
)
|
||
const mangoSwapFee = routePlan[0].swapInfo.inAmount * flashLoanSwapFeeRate
|
||
|
||
return [jupiterFee, mangoSwapFee]
|
||
}
|
||
|
||
// const prepareMangoRouterInstructions = async (
|
||
// selectedRoute: RouteInfo,
|
||
// inputMint: PublicKey,
|
||
// outputMint: PublicKey,
|
||
// userPublicKey: PublicKey,
|
||
// ): Promise<[TransactionInstruction[], AddressLookupTableAccount[]]> => {
|
||
// if (!selectedRoute || !selectedRoute.mints || !selectedRoute.instructions) {
|
||
// return [[], []]
|
||
// }
|
||
// const mintsToFilterOut = [inputMint, outputMint]
|
||
// const filteredOutMints = [
|
||
// ...selectedRoute.mints.filter(
|
||
// (routeMint) =>
|
||
// !mintsToFilterOut.find((filterOutMint) =>
|
||
// filterOutMint.equals(routeMint),
|
||
// ),
|
||
// ),
|
||
// ]
|
||
// const additionalInstructions = []
|
||
// for (const mint of filteredOutMints) {
|
||
// const ix = await createAssociatedTokenAccountIdempotentInstruction(
|
||
// userPublicKey,
|
||
// userPublicKey,
|
||
// mint,
|
||
// )
|
||
// additionalInstructions.push(ix)
|
||
// }
|
||
// const instructions = [
|
||
// ...additionalInstructions,
|
||
// ...selectedRoute.instructions,
|
||
// ]
|
||
// return [instructions, []]
|
||
// }
|
||
|
||
/** Given a Jupiter route, fetch the transaction for the user to sign.
|
||
**This function should ONLY be used for wallet swaps* */
|
||
export const fetchJupiterWalletSwapTransaction = async (
|
||
selectedRoute: JupiterV6RouteInfo,
|
||
userPublicKey: PublicKey,
|
||
slippage: number,
|
||
origin?: 'mango' | 'jupiter' | 'raydium',
|
||
): Promise<VersionedTransaction> => {
|
||
// docs https://station.jup.ag/api-v6/post-swap
|
||
const params: {
|
||
quoteResponse: JupiterV6RouteInfo
|
||
userPublicKey: PublicKey
|
||
slippageBps: number
|
||
autoCreateOutAta?: boolean
|
||
wrapAndUnwrapSol?: boolean
|
||
} = {
|
||
// response from /quote api
|
||
quoteResponse: selectedRoute,
|
||
// user public key to be used for the swap
|
||
userPublicKey,
|
||
slippageBps: Math.ceil(slippage * 100),
|
||
}
|
||
|
||
if (origin === 'mango') {
|
||
params.autoCreateOutAta = true
|
||
params.wrapAndUnwrapSol = true
|
||
}
|
||
|
||
const transactions = await (
|
||
await fetch(
|
||
`${
|
||
origin === 'mango' ? MANGO_ROUTER_API_URL : JUPITER_V6_QUOTE_API_MAINNET
|
||
}/swap`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(params),
|
||
},
|
||
)
|
||
).json()
|
||
|
||
const { swapTransaction } = transactions
|
||
const parsedSwapTransaction = VersionedTransaction.deserialize(
|
||
Buffer.from(swapTransaction, 'base64'),
|
||
)
|
||
return parsedSwapTransaction
|
||
}
|
||
/** Given a Jupiter route, fetch the transaction for the user to sign.
|
||
**This function should be used for margin swaps* */
|
||
export const fetchJupiterTransaction = async (
|
||
connection: Connection,
|
||
selectedRoute: JupiterV6RouteInfo,
|
||
userPublicKey: PublicKey,
|
||
slippage: number,
|
||
inputMint: PublicKey,
|
||
outputMint: PublicKey,
|
||
origin?: 'mango' | 'jupiter' | 'raydium',
|
||
): Promise<[TransactionInstruction[], AddressLookupTableAccount[]]> => {
|
||
// docs https://station.jup.ag/api-v6/post-swap
|
||
const transactions = await (
|
||
await fetch(
|
||
`${
|
||
origin === 'mango' ? MANGO_ROUTER_API_URL : JUPITER_V6_QUOTE_API_MAINNET
|
||
}/swap`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
// response from /quote api
|
||
quoteResponse: selectedRoute,
|
||
// user public key to be used for the swap
|
||
userPublicKey,
|
||
slippageBps: Math.ceil(slippage * 100),
|
||
wrapAndUnwrapSol: false,
|
||
}),
|
||
},
|
||
)
|
||
).json()
|
||
|
||
const { swapTransaction } = transactions
|
||
|
||
const [ixs, alts] = await deserializeJupiterIxAndAlt(
|
||
connection,
|
||
swapTransaction,
|
||
)
|
||
|
||
const isSetupIx = (pk: PublicKey): boolean =>
|
||
pk.toString() === 'ComputeBudget111111111111111111111111111111' ||
|
||
pk.toString() === 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
|
||
|
||
const isDuplicateAta = (ix: TransactionInstruction): boolean => {
|
||
return (
|
||
ix.programId.toString() ===
|
||
'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' &&
|
||
(ix.keys[3].pubkey.toString() === inputMint.toString() ||
|
||
ix.keys[3].pubkey.toString() === outputMint.toString())
|
||
)
|
||
}
|
||
|
||
//remove ATA and compute setup from swaps in margin trades
|
||
const filtered_jup_ixs = ixs
|
||
.filter((ix) => !isSetupIx(ix.programId))
|
||
.filter((ix) => !isDuplicateAta(ix))
|
||
|
||
return [filtered_jup_ixs, alts]
|
||
}
|
||
|
||
const EMPTY_COINGECKO_PRICES = {
|
||
inputCoingeckoPrice: 0,
|
||
outputCoingeckoPrice: 0,
|
||
}
|
||
|
||
const successSound = new Howl({
|
||
src: ['/sounds/swap-success.mp3'],
|
||
volume: 0.5,
|
||
})
|
||
|
||
const SwapReviewRouteInfo = ({
|
||
amountIn,
|
||
loadingRoute,
|
||
isWalletSwap,
|
||
onClose,
|
||
onSuccess,
|
||
refetchRoute,
|
||
routes,
|
||
selectedRoute,
|
||
setSelectedRoute,
|
||
show,
|
||
}: JupiterRouteInfoProps) => {
|
||
const { t } = useTranslation(['common', 'account', 'swap', 'trade'])
|
||
const slippage = mangoStore((s) => s.swap.slippage)
|
||
|
||
const wallet = useWallet()
|
||
const [showRoutesModal, setShowRoutesModal] = useState<boolean>(false)
|
||
const [swapRate, setSwapRate] = useState<boolean>(false)
|
||
const [feeValue] = useState<number | null>(null)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [coingeckoPrices, setCoingeckoPrices] = useState(EMPTY_COINGECKO_PRICES)
|
||
const [acceptPriceImpact, setAcceptPriceImpact] = useState(false)
|
||
const { jupiterTokens } = useJupiterMints()
|
||
const { inputTokenInfo, outputTokenInfo } = useJupiterSwapData()
|
||
const inputBank = mangoStore((s) => s.swap.inputBank)
|
||
const outputBank = mangoStore((s) => s.swap.outputBank)
|
||
const [soundSettings] = useLocalStorageState(
|
||
SOUND_SETTINGS_KEY,
|
||
INITIAL_SOUND_SETTINGS,
|
||
)
|
||
const focusRef = useRef<HTMLButtonElement>(null)
|
||
const { sendAnalytics } = useAnalytics()
|
||
|
||
const [refetchRoutePercentage, setRefetchRoutePercentage] = useState(0)
|
||
|
||
useEffect(() => {
|
||
let currentPercentage = 0
|
||
const countdownInterval = setInterval(() => {
|
||
if (currentPercentage < 100) {
|
||
currentPercentage += 5 // 5% increment per second
|
||
setRefetchRoutePercentage(currentPercentage)
|
||
}
|
||
}, 1000)
|
||
|
||
return () => {
|
||
clearInterval(countdownInterval)
|
||
}
|
||
}, [selectedRoute])
|
||
|
||
const amountOut = useMemo(() => {
|
||
if (!selectedRoute?.outAmount || !outputTokenInfo) return
|
||
return new Decimal(selectedRoute.outAmount.toString()).div(
|
||
10 ** outputTokenInfo.decimals,
|
||
)
|
||
}, [selectedRoute, outputTokenInfo])
|
||
|
||
useEffect(() => {
|
||
if (focusRef?.current) {
|
||
focusRef.current.focus()
|
||
}
|
||
}, [focusRef])
|
||
|
||
useEffect(() => {
|
||
setCoingeckoPrices(EMPTY_COINGECKO_PRICES)
|
||
const fetchTokenPrices = async () => {
|
||
const inputId = inputTokenInfo?.extensions?.coingeckoId
|
||
const outputId = outputTokenInfo?.extensions?.coingeckoId
|
||
|
||
if (inputId && outputId) {
|
||
try {
|
||
const results = await fetch(
|
||
`https://api.coingecko.com/api/v3/simple/price?ids=${inputId},${outputId}&vs_currencies=usd`,
|
||
)
|
||
const json = await results.json()
|
||
if (json[inputId]?.usd && json[outputId]?.usd) {
|
||
setCoingeckoPrices({
|
||
inputCoingeckoPrice: json[inputId].usd,
|
||
outputCoingeckoPrice: json[outputId].usd,
|
||
})
|
||
}
|
||
} catch (e) {
|
||
console.error('Loading coingecko prices: ', e)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (inputTokenInfo && outputTokenInfo) {
|
||
fetchTokenPrices()
|
||
}
|
||
}, [inputTokenInfo, outputTokenInfo])
|
||
|
||
const onWalletSwap = useCallback(async () => {
|
||
if (!selectedRoute || !inputBank || !outputBank || !wallet.publicKey) return
|
||
const actions = mangoStore.getState().actions
|
||
const client = mangoStore.getState().client
|
||
const set = mangoStore.getState().set
|
||
const connection = mangoStore.getState().connection
|
||
setSubmitting(true)
|
||
try {
|
||
const vtx = await fetchJupiterWalletSwapTransaction(
|
||
selectedRoute,
|
||
wallet.publicKey,
|
||
slippage,
|
||
selectedRoute.origin,
|
||
)
|
||
|
||
const latestBlockhash = await connection.getLatestBlockhash()
|
||
const sign = wallet.signTransaction!
|
||
const signed = await sign(vtx)
|
||
|
||
const txid = await sendTxAndConfirm(
|
||
client.opts.multipleConnections,
|
||
connection,
|
||
signed,
|
||
latestBlockhash,
|
||
)
|
||
set((s) => {
|
||
s.swap.amountIn = ''
|
||
s.swap.amountOut = ''
|
||
})
|
||
notify({
|
||
title: 'Transaction confirmed',
|
||
type: 'success',
|
||
txid,
|
||
})
|
||
actions.fetchWalletTokens(wallet.publicKey)
|
||
} catch (e) {
|
||
console.log('error swapping wallet tokens', e)
|
||
} finally {
|
||
setSubmitting(false)
|
||
onClose()
|
||
}
|
||
}, [
|
||
inputBank,
|
||
outputBank,
|
||
onClose,
|
||
selectedRoute,
|
||
slippage,
|
||
wallet.publicKey,
|
||
])
|
||
|
||
const onSwap = useCallback(async () => {
|
||
if (!selectedRoute) return
|
||
let directRouteFallbackUsed = false
|
||
try {
|
||
const client = mangoStore.getState().client
|
||
const group = mangoStore.getState().group
|
||
const actions = mangoStore.getState().actions
|
||
const set = mangoStore.getState().set
|
||
const mangoAccount = mangoStore.getState().mangoAccount.current
|
||
const inputBank = mangoStore.getState().swap.inputBank
|
||
const outputBank = mangoStore.getState().swap.outputBank
|
||
const connection = mangoStore.getState().connection
|
||
|
||
if (
|
||
!mangoAccount ||
|
||
!group ||
|
||
!inputBank ||
|
||
!outputBank ||
|
||
!wallet.publicKey
|
||
)
|
||
return
|
||
setSubmitting(true)
|
||
let tx = ''
|
||
const [ixs, alts] =
|
||
// selectedRoute.routerName === 'Mango'
|
||
// ? await prepareMangoRouterInstructions(
|
||
// selectedRoute,
|
||
// inputBank.mint,
|
||
// outputBank.mint,
|
||
// mangoAccount.owner,
|
||
// )
|
||
// :
|
||
selectedRoute.instructions
|
||
? [selectedRoute.instructions, []]
|
||
: await fetchJupiterTransaction(
|
||
connection,
|
||
selectedRoute,
|
||
wallet.publicKey,
|
||
slippage,
|
||
inputBank.mint,
|
||
outputBank.mint,
|
||
selectedRoute.origin,
|
||
)
|
||
|
||
try {
|
||
sendAnalytics(
|
||
{
|
||
inputMintPk: inputBank.mint,
|
||
amountIn: amountIn.toNumber(),
|
||
outputMintPk: outputBank.mint,
|
||
},
|
||
'swapping',
|
||
)
|
||
const { signature, slot } = await client.marginTrade({
|
||
group,
|
||
mangoAccount,
|
||
inputMintPk: inputBank.mint,
|
||
amountIn: amountIn.toNumber(),
|
||
outputMintPk: outputBank.mint,
|
||
userDefinedInstructions: ixs,
|
||
userDefinedAlts: alts,
|
||
flashLoanType: { swap: {} },
|
||
sequenceCheck: false,
|
||
})
|
||
tx = signature
|
||
set((s) => {
|
||
s.successAnimation.swap = true
|
||
s.swap.amountIn = ''
|
||
s.swap.amountOut = ''
|
||
})
|
||
if (soundSettings['swap-success']) {
|
||
successSound.play()
|
||
}
|
||
sendAnalytics(
|
||
{
|
||
tx: `${tx}`,
|
||
},
|
||
'swapSuccess',
|
||
)
|
||
notify({
|
||
title: 'Transaction confirmed',
|
||
type: 'success',
|
||
txid: signature,
|
||
noSound: true,
|
||
})
|
||
actions.fetchGroup()
|
||
actions.fetchSwapHistory(mangoAccount.publicKey.toString(), 30000)
|
||
await actions.reloadMangoAccount(slot)
|
||
if (onSuccess) {
|
||
onSuccess()
|
||
}
|
||
} catch (e) {
|
||
sendAnalytics(
|
||
{
|
||
e: `${e}`,
|
||
tx: `${tx}`,
|
||
},
|
||
'onSwapError',
|
||
)
|
||
console.error('onSwap error: ', e)
|
||
sentry.captureException(e)
|
||
if (isMangoError(e)) {
|
||
const slippageExceeded = await parseTxForKnownErrors(
|
||
connection,
|
||
e?.txid,
|
||
)
|
||
if (
|
||
slippageExceeded ===
|
||
TransactionErrors.JupiterSlippageToleranceExceeded
|
||
) {
|
||
notify({
|
||
title: t('swap:error-slippage-exceeded'),
|
||
description: t('swap:error-slippage-exceeded-desc'),
|
||
txid: e?.txid,
|
||
type: 'error',
|
||
})
|
||
} else {
|
||
notify({
|
||
title: 'Transaction failed',
|
||
description: e.message,
|
||
txid: e?.txid,
|
||
type: 'error',
|
||
})
|
||
}
|
||
} else {
|
||
const stringError = `${e}`
|
||
if (
|
||
stringError.toLowerCase().includes('max accounts') &&
|
||
routes?.length &&
|
||
routes.length > 1
|
||
) {
|
||
directRouteFallbackUsed = true
|
||
setSelectedRoute(
|
||
routes.filter(
|
||
(x) =>
|
||
JSON.stringify(x.routePlan) !==
|
||
JSON.stringify(selectedRoute.routePlan),
|
||
)[0],
|
||
)
|
||
notify({
|
||
title: 'Transaction failed',
|
||
description: `${stringError} - please review route and click swap again`,
|
||
type: 'error',
|
||
})
|
||
} else {
|
||
notify({
|
||
title: 'Transaction failed',
|
||
description: `${stringError} - please try again`,
|
||
type: 'error',
|
||
})
|
||
}
|
||
}
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
} catch (e) {
|
||
console.error('Swap error:', e)
|
||
} finally {
|
||
if (!directRouteFallbackUsed) {
|
||
onClose()
|
||
}
|
||
}
|
||
}, [
|
||
sendAnalytics,
|
||
selectedRoute,
|
||
wallet.publicKey,
|
||
slippage,
|
||
amountIn,
|
||
soundSettings,
|
||
onSuccess,
|
||
t,
|
||
routes,
|
||
onClose,
|
||
])
|
||
|
||
const onClick = isWalletSwap ? onWalletSwap : onSwap
|
||
|
||
const [balance, borrowAmount] = useMemo(() => {
|
||
const mangoAccount = mangoStore.getState().mangoAccount.current
|
||
const inputBank = mangoStore.getState().swap.inputBank
|
||
if (!mangoAccount || !inputBank) return [0, 0]
|
||
|
||
const balance = mangoAccount.getTokenDepositsUi(inputBank)
|
||
const remainingBalance = balance - amountIn.toNumber()
|
||
const borrowAmount = remainingBalance < 0 ? Math.abs(remainingBalance) : 0
|
||
|
||
return [balance, borrowAmount]
|
||
}, [amountIn])
|
||
|
||
const [jupiterFees] = useMemo(() => {
|
||
if (
|
||
!selectedRoute?.routePlan ||
|
||
!inputBank ||
|
||
!outputBank ||
|
||
!outputTokenInfo
|
||
) {
|
||
return [0, 0]
|
||
}
|
||
|
||
return calculateOutFees(
|
||
selectedRoute?.routePlan,
|
||
inputBank,
|
||
outputBank,
|
||
outputTokenInfo.decimals,
|
||
)
|
||
}, [inputBank, outputBank, outputTokenInfo, selectedRoute])
|
||
|
||
const flashLoanFee = useMemo(() => {
|
||
if (!inputBank || !outputBank) return 0
|
||
const rate = Math.max(
|
||
inputBank.flashLoanSwapFeeRate,
|
||
outputBank.flashLoanSwapFeeRate,
|
||
)
|
||
return amountIn.mul(rate).toNumber()
|
||
}, [amountIn, inputBank, outputBank])
|
||
|
||
const coinGeckoPriceDifference = useMemo(() => {
|
||
if (
|
||
!coingeckoPrices?.outputCoingeckoPrice ||
|
||
!coingeckoPrices?.inputCoingeckoPrice ||
|
||
!amountOut
|
||
)
|
||
return
|
||
return amountIn
|
||
.div(amountOut)
|
||
.minus(
|
||
new Decimal(coingeckoPrices.outputCoingeckoPrice).div(
|
||
coingeckoPrices.inputCoingeckoPrice,
|
||
),
|
||
)
|
||
.div(amountIn.div(amountOut))
|
||
.mul(100)
|
||
}, [coingeckoPrices, amountIn, amountOut])
|
||
|
||
const isInsured = useMemo(() => {
|
||
const group = mangoStore.getState().group
|
||
return isTokenInsured(outputBank, group)
|
||
}, [outputBank])
|
||
|
||
const showPriceImpactCheck = useMemo(() => {
|
||
if (!coinGeckoPriceDifference) return true
|
||
return coinGeckoPriceDifference.gte(5)
|
||
}, [coinGeckoPriceDifference])
|
||
|
||
return routes?.length &&
|
||
selectedRoute &&
|
||
inputTokenInfo &&
|
||
outputTokenInfo &&
|
||
amountOut ? (
|
||
<Transition
|
||
className="absolute right-0 top-0 z-20 h-full w-full bg-th-bkg-1 pb-0"
|
||
show={show}
|
||
enter="transition ease-in duration-300"
|
||
enterFrom="-translate-x-full"
|
||
enterTo="translate-x-0"
|
||
leave="transition ease-out duration-300"
|
||
leaveFrom="translate-x-0"
|
||
leaveTo="-translate-x-full"
|
||
>
|
||
<div className="thin-scroll flex h-full flex-col justify-between overflow-y-auto">
|
||
<div>
|
||
<div className="relative w-full px-4 pt-4">
|
||
<IconButton
|
||
className="absolute text-th-fgd-2"
|
||
onClick={onClose}
|
||
size="small"
|
||
ref={focusRef}
|
||
>
|
||
<ArrowLeftIcon className="h-5 w-5" />
|
||
</IconButton>
|
||
<div className="absolute right-4 h-8 w-8">
|
||
<CircularProgress
|
||
size={32}
|
||
indicatorWidth={1}
|
||
trackWidth={1}
|
||
progress={refetchRoutePercentage}
|
||
/>
|
||
{refetchRoute ? (
|
||
<IconButton
|
||
className="absolute inset-0 text-th-fgd-2"
|
||
hideBg
|
||
onClick={() => refetchRoute()}
|
||
size="small"
|
||
ref={focusRef}
|
||
>
|
||
<ArrowPathIcon
|
||
className={`h-5 w-5 ${
|
||
loadingRoute ? 'animate-spin' : null
|
||
}`}
|
||
/>
|
||
</IconButton>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-center bg-th-bkg-1 px-6 pt-4">
|
||
<div className="flex w-full flex-col items-center pb-4">
|
||
<div className="relative mb-2 w-[72px]">
|
||
<TokenLogo bank={inputBank} size={40} />
|
||
<div className="absolute right-0 top-0">
|
||
<TokenLogo bank={outputBank} size={40} />
|
||
</div>
|
||
</div>
|
||
<div className="mb-0.5 flex items-center text-center text-lg">
|
||
<span>
|
||
<span className="font-mono text-th-fgd-1">
|
||
<FormatNumericValue value={amountIn} />
|
||
</span>{' '}
|
||
<span className="text-xs text-th-fgd-4">
|
||
{inputBank?.name}
|
||
</span>
|
||
</span>
|
||
<ArrowRightIcon className="mx-2 h-5 w-5 text-th-fgd-2" />
|
||
<span>
|
||
<span className="font-mono text-th-fgd-1">
|
||
<FormatNumericValue value={amountOut} />
|
||
</span>{' '}
|
||
<span className="text-xs text-th-fgd-4">
|
||
{outputBank?.name}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2 overflow-auto px-6">
|
||
<div className="flex justify-between">
|
||
<p className="text-sm text-th-fgd-3">{t('swap:rate')}</p>
|
||
<div>
|
||
<div className="flex items-center justify-end">
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
{swapRate ? (
|
||
<>
|
||
1{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{inputTokenInfo?.symbol} ≈{' '}
|
||
</span>
|
||
<FormatNumericValue value={amountOut.div(amountIn)} />{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{outputTokenInfo?.symbol}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
1{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{outputTokenInfo?.symbol} ≈{' '}
|
||
</span>
|
||
<FormatNumericValue value={amountIn.div(amountOut)} />{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{inputTokenInfo?.symbol}
|
||
</span>
|
||
</>
|
||
)}
|
||
</p>
|
||
<ArrowsRightLeftIcon
|
||
className="ml-1 h-4 w-4 cursor-pointer text-th-fgd-2 hover:text-th-active"
|
||
onClick={() => setSwapRate(!swapRate)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 px-1 text-xs">
|
||
{coinGeckoPriceDifference ? (
|
||
<div
|
||
className={`text-right font-mono ${
|
||
coinGeckoPriceDifference.gt(1)
|
||
? 'text-th-down'
|
||
: 'text-th-up'
|
||
}`}
|
||
>
|
||
{Decimal.abs(coinGeckoPriceDifference).toFixed(1)}%{' '}
|
||
<span className="font-body text-th-fgd-3">{`${
|
||
coinGeckoPriceDifference.lte(0)
|
||
? 'cheaper'
|
||
: 'more expensive'
|
||
} than CoinGecko`}</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<p className="text-sm text-th-fgd-3">
|
||
{t('swap:minimum-received')}
|
||
</p>
|
||
{outputTokenInfo?.decimals && selectedRoute ? (
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
{selectedRoute.swapMode === 'ExactIn' ? (
|
||
<FormatNumericValue
|
||
value={
|
||
selectedRoute.otherAmountThreshold /
|
||
10 ** outputTokenInfo.decimals
|
||
}
|
||
decimals={outputTokenInfo.decimals}
|
||
/>
|
||
) : (
|
||
<FormatNumericValue
|
||
value={
|
||
selectedRoute.outAmount / 10 ** outputTokenInfo.decimals
|
||
}
|
||
decimals={outputTokenInfo.decimals}
|
||
/>
|
||
)}{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{outputTokenInfo?.symbol}
|
||
</span>
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
{selectedRoute?.swapMode === 'ExactOut' ? (
|
||
<div className="flex justify-between">
|
||
<p className="text-sm text-th-fgd-3">
|
||
{t('swap:maximum-cost')}
|
||
</p>
|
||
{inputTokenInfo?.decimals && selectedRoute ? (
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
<FormatNumericValue
|
||
value={
|
||
selectedRoute.otherAmountThreshold /
|
||
10 ** inputTokenInfo.decimals
|
||
}
|
||
decimals={inputTokenInfo.decimals}
|
||
/>{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{inputTokenInfo?.symbol}
|
||
</span>
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div className="flex justify-between">
|
||
<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>
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
{selectedRoute?.priceImpactPct
|
||
? selectedRoute.priceImpactPct < 0.1
|
||
? '<0.1%'
|
||
: `${Number(selectedRoute.priceImpactPct).toFixed(2)}%`
|
||
: '–'}
|
||
</p>
|
||
</div>
|
||
{!isWalletSwap ? (
|
||
<div className="flex justify-between">
|
||
{/* <Tooltip content={t('swap:tooltip-flash-loan-fee')}> */}
|
||
<p>{t('swap:flash-loan-fee')}</p>
|
||
{/* </Tooltip> */}
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
≈
|
||
<FormatNumericValue
|
||
value={flashLoanFee}
|
||
decimals={inputBank?.mintDecimals}
|
||
/>{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{inputBank?.name}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
<div className="flex justify-between">
|
||
<Tooltip
|
||
content={
|
||
<>
|
||
<p>
|
||
The fee displayed here is an estimate and is displayed in
|
||
destination tokens for convenience. Note that each leg of
|
||
the swap may collect its fee in different tokens, so fees
|
||
may vary.
|
||
</p>
|
||
</>
|
||
}
|
||
>
|
||
<p className="tooltip-underline">Jupiter Fees</p>
|
||
</Tooltip>
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
≈
|
||
<FormatNumericValue
|
||
value={jupiterFees}
|
||
decimals={outputTokenInfo.decimals}
|
||
/>{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{outputTokenInfo?.symbol}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* <div className="flex justify-between">
|
||
<p className="text-th-fgd-3">Mango Fees</p>
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
≈{' '}
|
||
<FormatNumericValue
|
||
value={mangoFees}
|
||
decimals={outputTokenInfo.decimals}
|
||
/>{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{outputTokenInfo?.symbol}
|
||
</span>
|
||
</p>
|
||
</div> */}
|
||
{borrowAmount && inputBank ? (
|
||
<>
|
||
<div className="flex justify-between">
|
||
<Tooltip
|
||
content={
|
||
balance
|
||
? t('swap:tooltip-borrow-balance', {
|
||
balance: formatNumericValue(balance),
|
||
borrowAmount: formatNumericValue(borrowAmount),
|
||
token: inputTokenInfo?.symbol,
|
||
rate: formatNumericValue(
|
||
inputBank.getBorrowRateUi(),
|
||
2,
|
||
),
|
||
})
|
||
: t('swap:tooltip-borrow-no-balance', {
|
||
borrowAmount: formatNumericValue(borrowAmount),
|
||
token: inputTokenInfo?.symbol,
|
||
rate: formatNumericValue(
|
||
inputBank.getBorrowRateUi(),
|
||
2,
|
||
),
|
||
})
|
||
}
|
||
delay={100}
|
||
>
|
||
<p className="tooltip-underline">{t('borrow-amount')}</p>
|
||
</Tooltip>
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
<FormatNumericValue value={borrowAmount} />{' '}
|
||
<span className="font-body text-th-fgd-3">
|
||
{inputTokenInfo?.symbol}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<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-3">
|
||
{inputBank.name}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="p-6">
|
||
{showPriceImpactCheck ? (
|
||
<div className="mb-4 rounded-md border border-th-error p-2">
|
||
<Checkbox
|
||
checked={acceptPriceImpact}
|
||
onChange={(e) => setAcceptPriceImpact(e.target.checked)}
|
||
>
|
||
<p className="whitespace-normal text-th-fgd-2">
|
||
{coinGeckoPriceDifference
|
||
? `I accept the rate for this swap is
|
||
${coinGeckoPriceDifference.toFixed(1)}% worse than Coingecko`
|
||
: 'I accept the price of this swap has not been benchmarked to Coingecko and could differ to current market prices'}
|
||
</p>
|
||
</Checkbox>
|
||
</div>
|
||
) : null}
|
||
<div className="mb-4 flex items-center justify-center">
|
||
<Button
|
||
onClick={onClick}
|
||
className="flex w-full items-center justify-center text-base"
|
||
size="large"
|
||
disabled={showPriceImpactCheck && !acceptPriceImpact}
|
||
>
|
||
{submitting ? (
|
||
<Loading className="mr-2 h-5 w-5" />
|
||
) : (
|
||
<div className="flex items-center">
|
||
<ArrowsRightLeftIcon className="mr-2 h-5 w-5" />
|
||
{t('swap')}
|
||
</div>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
<div className="rounded-md bg-th-bkg-2">
|
||
<Disclosure>
|
||
{({ open }) => (
|
||
<>
|
||
<Disclosure.Button
|
||
className={`flex w-full items-center justify-between rounded-md p-3 focus-visible:bg-th-bkg-3 ${
|
||
open ? 'mb-2 rounded-b-none' : ''
|
||
}`}
|
||
>
|
||
<p>{t('swap:route-info')}</p>
|
||
<ChevronDownIcon
|
||
className={`${
|
||
open ? 'rotate-180' : 'rotate-0'
|
||
} h-5 w-5 text-th-fgd-3`}
|
||
/>
|
||
</Disclosure.Button>
|
||
<Disclosure.Panel className="space-y-2 p-3 pt-0">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm text-th-fgd-3">
|
||
{t('swap:swap-route')}
|
||
</p>
|
||
<div
|
||
className="flex items-center text-th-fgd-2 md:hover:cursor-pointer md:hover:text-th-fgd-3"
|
||
role="button"
|
||
onClick={() => setShowRoutesModal(true)}
|
||
>
|
||
<span className="overflow-ellipsis whitespace-nowrap">
|
||
{selectedRoute?.routePlan?.map((info, index) => {
|
||
let includeSeparator = false
|
||
if (
|
||
selectedRoute.routePlan &&
|
||
selectedRoute?.routePlan.length > 1 &&
|
||
index !== selectedRoute?.routePlan.length - 1
|
||
) {
|
||
includeSeparator = true
|
||
}
|
||
return (
|
||
<span key={index}>{`${info?.swapInfo.label} ${
|
||
includeSeparator ? 'x ' : ''
|
||
}`}</span>
|
||
)
|
||
})}
|
||
</span>
|
||
<PencilIcon className="ml-2 h-4 w-4 hover:text-th-active" />
|
||
</div>
|
||
</div>
|
||
{typeof feeValue === 'number' ? (
|
||
<div className="flex justify-between">
|
||
<p className="text-sm text-th-fgd-3">{t('fee')}</p>
|
||
<div className="flex items-center">
|
||
<p className="text-right font-mono text-sm text-th-fgd-2">
|
||
≈ ${feeValue?.toFixed(2)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
selectedRoute?.routePlan?.map((info, index) => {
|
||
const feeToken = jupiterTokens.find(
|
||
(item) => item?.address === info.swapInfo.feeMint,
|
||
)
|
||
return (
|
||
<div className="flex justify-between" key={index}>
|
||
<p className="text-sm text-th-fgd-3">
|
||
{t('swap:fees-paid-to', {
|
||
route: info?.swapInfo.label,
|
||
})}
|
||
</p>
|
||
{feeToken?.decimals && (
|
||
<p className="pl-4 text-right font-mono text-sm text-th-fgd-2">
|
||
{(
|
||
info.swapInfo.feeAmount /
|
||
Math.pow(10, feeToken.decimals)
|
||
).toFixed(6)}{' '}
|
||
<span className="font-body">
|
||
{feeToken?.symbol}
|
||
</span>{' '}
|
||
(
|
||
{(
|
||
(info.swapInfo.outputMint == feeToken.address
|
||
? info.swapInfo.feeAmount /
|
||
info.swapInfo.outAmount
|
||
: info.swapInfo.feeAmount /
|
||
info.swapInfo.inAmount) * 100
|
||
).toLocaleString(undefined, {
|
||
maximumSignificantDigits: 2,
|
||
})}
|
||
%)
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</Disclosure.Panel>
|
||
</>
|
||
)}
|
||
</Disclosure>
|
||
</div>
|
||
{!isInsured ? (
|
||
<div className="mt-4">
|
||
<UninsuredNotification name={outputBank?.name} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{showRoutesModal ? (
|
||
<RoutesModal
|
||
show={showRoutesModal}
|
||
onClose={() => setShowRoutesModal(false)}
|
||
setSelectedRoute={setSelectedRoute}
|
||
selectedRoute={selectedRoute}
|
||
routes={routes}
|
||
inputTokenSymbol={inputTokenInfo?.name}
|
||
outputTokenInfo={outputTokenInfo}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</Transition>
|
||
) : null
|
||
}
|
||
|
||
export default React.memo(SwapReviewRouteInfo)
|