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' type JupiterRouteInfoProps = { amountIn: Decimal loadingRoute: boolean isWalletSwap?: boolean onClose: () => void onSuccess?: () => void refetchRoute: | (( options?: (RefetchOptions & RefetchQueryFilters) | undefined, ) => Promise< QueryObserverResult<{ bestRoute: JupiterV6RouteInfo | null }, Error> >) | undefined routes: JupiterV6RouteInfo[] | undefined selectedRoute: JupiterV6RouteInfo | undefined | null setSelectedRoute: Dispatch< SetStateAction > 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 => { // 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(false) const [swapRate, setSwapRate] = useState(false) const [feeValue] = useState(null) const [submitting, setSubmitting] = useState(false) const [coingeckoPrices, setCoingeckoPrices] = useState(EMPTY_COINGECKO_PRICES) 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(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(() => { return amountOut?.toNumber() ? amountIn .div(amountOut) .minus( new Decimal(coingeckoPrices?.outputCoingeckoPrice).div( coingeckoPrices?.inputCoingeckoPrice, ), ) .div(amountIn.div(amountOut)) .mul(100) : new Decimal(0) }, [coingeckoPrices, amountIn, amountOut]) const isInsured = useMemo(() => { const group = mangoStore.getState().group return isTokenInsured(outputBank, group) }, [outputBank]) return routes?.length && selectedRoute && inputTokenInfo && outputTokenInfo && amountOut ? (
{refetchRoute ? ( refetchRoute()} size="small" ref={focusRef} > ) : null}

{' '} {inputTokenInfo?.symbol} {' '} {`${outputTokenInfo?.symbol}`}

{t('price')}

{swapRate ? ( <> 1{' '} {inputTokenInfo?.symbol} ≈{' '} {' '} {outputTokenInfo?.symbol} ) : ( <> 1{' '} {outputTokenInfo?.symbol} ≈{' '} {' '} {inputTokenInfo?.symbol} )}

setSwapRate(!swapRate)} />
{coingeckoPrices?.outputCoingeckoPrice && coingeckoPrices?.inputCoingeckoPrice ? (
{Decimal.abs(coinGeckoPriceDifference).toFixed(1)}%{' '} {`${ coinGeckoPriceDifference.lte(0) ? 'cheaper' : 'more expensive' } than CoinGecko`}
) : null}

{t('swap:minimum-received')}

{outputTokenInfo?.decimals && selectedRoute ? (

{selectedRoute.swapMode === 'ExactIn' ? ( ) : ( )}{' '} {outputTokenInfo?.symbol}

) : null}
{selectedRoute?.swapMode === 'ExactOut' ? (

{t('swap:maximum-cost')}

{inputTokenInfo?.decimals && selectedRoute ? (

{' '} {inputTokenInfo?.symbol}

) : null}
) : null}

The price impact is the difference observed between the total value of the entry tokens swapped and the destination tokens obtained.

The bigger the trade is, the bigger the price impact can be.

} >

{t('swap:price-impact')}

{selectedRoute?.priceImpactPct * 100 < 0.1 ? '<0.1%' : `${(selectedRoute?.priceImpactPct * 100).toFixed(2)}%`}

{!isWalletSwap ? (
{/* */}

{t('swap:flash-loan-fee')}

{/*
*/}

{' '} {inputBank?.name}

) : null}

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.

} >

Jupiter Fees

{' '} {outputTokenInfo?.symbol}

{/*

Mango Fees

≈{' '} {' '} {outputTokenInfo?.symbol}

*/} {borrowAmount && inputBank ? ( <>

{t('borrow-amount')}

{' '} {inputTokenInfo?.symbol}

{t('loan-origination-fee')}

{' '} {inputBank.name}

) : null}
{!isInsured ? (
) : null}
{({ open }) => ( <>

{t('swap:route-info')}

{t('swap:swap-route')}

setShowRoutesModal(true)} > {selectedRoute?.routePlan?.map((info, index) => { let includeSeparator = false if ( selectedRoute.routePlan && selectedRoute?.routePlan.length > 1 && index !== selectedRoute?.routePlan.length - 1 ) { includeSeparator = true } return ( {`${info?.swapInfo.label} ${ includeSeparator ? 'x ' : '' }`} ) })}
{typeof feeValue === 'number' ? (

{t('fee')}

≈ ${feeValue?.toFixed(2)}

) : ( selectedRoute?.routePlan?.map((info, index) => { const feeToken = jupiterTokens.find( (item) => item?.address === info.swapInfo.feeMint, ) return (

{t('swap:fees-paid-to', { route: info?.swapInfo.label, })}

{feeToken?.decimals && (

{( info.swapInfo.feeAmount / Math.pow(10, feeToken.decimals) ).toFixed(6)}{' '} {feeToken?.symbol} {' '} ( {( (info.swapInfo.outputMint == feeToken.address ? info.swapInfo.feeAmount / info.swapInfo.outAmount : info.swapInfo.feeAmount / info.swapInfo.inAmount) * 100 ).toLocaleString(undefined, { maximumSignificantDigits: 2, })} %)

)}
) }) )}
)}
{showRoutesModal ? ( setShowRoutesModal(false)} setSelectedRoute={setSelectedRoute} selectedRoute={selectedRoute} routes={routes} inputTokenSymbol={inputTokenInfo?.name} outputTokenInfo={outputTokenInfo} /> ) : null}
) : null } export default React.memo(SwapReviewRouteInfo)