238 lines
8.7 KiB
TypeScript
238 lines
8.7 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useQueryClient } from '@tanstack/react-query'
|
||
import { Notification, PrimaryButton } from '@shared/ui'
|
||
import {
|
||
useWalletBalance, useWalletAddresses, useTokensList,
|
||
useRelayQuote, useExecuteRelaySwap, useSignSwap,
|
||
useTrxSwapQuote, useFetchTrxQuote, useExecuteTrxSwap,
|
||
type Chain, type RelaySwapResponse, type TrxSwapQuoteData,
|
||
} from '@features/wallet'
|
||
import { ROUTES } from '@shared/config/routes'
|
||
import { useDebounce } from '@shared/lib/hooks/useDebounce'
|
||
import { TOKENS_LIST, buildTokensFromBalance, useSwapForm } from '../model/useSwapForm'
|
||
import { SwapCard } from './SwapCard'
|
||
import { SwapDirectionButton } from './SwapDirectionButton'
|
||
import { SwapInfoPanel } from './SwapInfoPanel'
|
||
import { SwapConfirmModal } from './SwapConfirmModal'
|
||
import { TrxConfirmModal } from './TrxConfirmModal'
|
||
import styles from './SwapForm.module.css'
|
||
|
||
const CHAIN_ID: Record<string, number> = { ETH: 1, BSC: 56, SOL: 792703809 }
|
||
const NATIVE_ADDR: Record<string, string> = {
|
||
SOL: '11111111111111111111111111111111',
|
||
DEFAULT: '0x0000000000000000000000000000000000000000',
|
||
}
|
||
function nativeAddr(chain: string) {
|
||
return NATIVE_ADDR[chain] ?? NATIVE_ADDR.DEFAULT
|
||
}
|
||
|
||
export function SwapForm() {
|
||
const navigate = useNavigate()
|
||
const queryClient = useQueryClient()
|
||
|
||
const {
|
||
fromAmount, fromUsd,
|
||
fromToken, toToken,
|
||
setFromAmount, setPercent, swapTokens,
|
||
setFromToken, setToToken,
|
||
} = useSwapForm()
|
||
|
||
const [fromNetwork, setFromNetwork] = useState('ETH')
|
||
const [modalData, setModalData] = useState<RelaySwapResponse | null>(null)
|
||
const [trxModalQuote, setTrxModalQuote] = useState<TrxSwapQuoteData | null>(null)
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||
|
||
const isTrxNetwork = fromNetwork === 'TRX'
|
||
|
||
const { data: walletData } = useWalletBalance(fromNetwork as Chain)
|
||
const tokenOptions = walletData ? buildTokensFromBalance(walletData) : TOKENS_LIST
|
||
|
||
useEffect(() => {
|
||
if (tokenOptions.length === 0) return
|
||
setFromToken(t => tokenOptions.find(o => o.symbol === t.symbol) ?? tokenOptions[0])
|
||
setToToken(t => tokenOptions.find(o => o.symbol === t.symbol) ?? (tokenOptions[1] ?? tokenOptions[0]))
|
||
}, [walletData, fromNetwork])
|
||
|
||
const debouncedAmount = useDebounce(fromAmount, 500)
|
||
const { data: addresses } = useWalletAddresses()
|
||
const { data: tokensList } = useTokensList()
|
||
|
||
const parsedAmount = parseFloat(debouncedAmount)
|
||
|
||
// ── Relay (ETH / BSC / SOL) ─────────────────────────────────────────────
|
||
const chainId = CHAIN_ID[fromNetwork]
|
||
const walletAddress = addresses?.find(a => a.chain === fromNetwork)?.address
|
||
const fromContract = tokensList?.find(t => t.chain === fromNetwork && t.symbol === fromToken.symbol)?.contract ?? nativeAddr(fromNetwork)
|
||
const toContract = tokensList?.find(t => t.chain === fromNetwork && t.symbol === toToken.symbol)?.contract ?? nativeAddr(fromNetwork)
|
||
|
||
const quotePayload = !isTrxNetwork && chainId && walletAddress && parsedAmount > 0
|
||
? {
|
||
user: walletAddress,
|
||
recipient: walletAddress,
|
||
originChainId: chainId,
|
||
destinationChainId: chainId,
|
||
originCurrency: fromContract,
|
||
destinationCurrency: toContract,
|
||
amount: Math.round(parsedAmount * Math.pow(10, fromToken.decimals)).toString(),
|
||
tradeType: 'EXACT_INPUT' as const,
|
||
}
|
||
: null
|
||
|
||
const { data: quoteData } = useRelayQuote(quotePayload)
|
||
const { mutate: executeSwap, isPending: isSwapping } = useExecuteRelaySwap()
|
||
const { mutate: signSwap, isPending: isSigning } = useSignSwap()
|
||
|
||
// ── TRX ─────────────────────────────────────────────────────────────────
|
||
const trxQuotePayload = isTrxNetwork && parsedAmount > 0
|
||
? { from: fromToken.symbol, to: toToken.symbol, amountHuman: debouncedAmount }
|
||
: null
|
||
|
||
const { data: trxQuoteData } = useTrxSwapQuote(trxQuotePayload)
|
||
const { mutate: fetchTrxQuote, isPending: isFetchingTrxQuote } = useFetchTrxQuote()
|
||
const { mutate: executeTrxSwap, isPending: isExecutingTrxSwap } = useExecuteTrxSwap()
|
||
|
||
const isProcessing = isSigning || isExecutingTrxSwap
|
||
|
||
const insufficientBalance = (parseFloat(fromAmount) || 0) > fromToken.balance
|
||
|
||
// ── Display values ───────────────────────────────────────────────────────
|
||
const displayToAmount = isTrxNetwork
|
||
? (trxQuoteData?.expectedOutFormatted ?? '0')
|
||
: (quoteData?.details.currencyOut.amountFormatted ?? '0')
|
||
|
||
const displayToUsd = isTrxNetwork
|
||
? undefined
|
||
: quoteData?.details.currencyOut.amountUsd
|
||
|
||
const gasFee = isTrxNetwork
|
||
? trxQuoteData?.fees.network.amountUsd?.toString()
|
||
: quoteData?.fees.gas.amountUsd
|
||
|
||
const serviceFee = quoteData?.appCommission?.usd?.toString()
|
||
const serviceFeeToken = quoteData?.appCommission?.inToken
|
||
|
||
const isButtonDisabled = isTrxNetwork
|
||
? parsedAmount <= 0 || isFetchingTrxQuote
|
||
: !quotePayload || isSwapping
|
||
|
||
function handleSwap() {
|
||
if (isTrxNetwork) {
|
||
if (!trxQuotePayload) return
|
||
fetchTrxQuote(trxQuotePayload, {
|
||
onSuccess: (quote) => setTrxModalQuote(quote),
|
||
})
|
||
} else {
|
||
if (!quotePayload) return
|
||
executeSwap(quotePayload, {
|
||
onSuccess: (data) => setModalData(data),
|
||
})
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={styles.form}>
|
||
<SwapCard
|
||
mode="from"
|
||
token={fromToken}
|
||
tokenOptions={tokenOptions}
|
||
amount={fromAmount}
|
||
usd={fromUsd}
|
||
onAmountChange={setFromAmount}
|
||
onSetPercent={setPercent}
|
||
onTokenChange={setFromToken}
|
||
selectedNetwork={fromNetwork}
|
||
onNetworkChange={setFromNetwork}
|
||
/>
|
||
|
||
<SwapDirectionButton onClick={swapTokens} />
|
||
|
||
<SwapCard
|
||
mode="to"
|
||
token={toToken}
|
||
tokenOptions={tokenOptions}
|
||
amount={displayToAmount}
|
||
usd={displayToUsd}
|
||
onTokenChange={setToToken}
|
||
/>
|
||
|
||
<SwapInfoPanel gasFee={gasFee} serviceFee={serviceFee} serviceFeeToken={serviceFeeToken} />
|
||
|
||
<PrimaryButton
|
||
label={isSwapping || isFetchingTrxQuote ? 'Загрузка...' : undefined}
|
||
onClick={handleSwap}
|
||
disabled={isButtonDisabled}
|
||
/>
|
||
|
||
{modalData && (
|
||
<SwapConfirmModal
|
||
data={modalData}
|
||
insufficientBalance={insufficientBalance}
|
||
onClose={() => setModalData(null)}
|
||
onConfirm={() => {
|
||
const txData = modalData.steps[0]?.items[0]?.data
|
||
if (txData) {
|
||
setErrorMessage(null)
|
||
signSwap(
|
||
{ chain: fromNetwork as Chain, txData },
|
||
{
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', fromNetwork] })
|
||
queryClient.invalidateQueries({ queryKey: ['wallet', 'portfolio'] })
|
||
navigate(ROUTES.WALLET)
|
||
},
|
||
onError: (err) => {
|
||
setErrorMessage(err instanceof Error ? err.message : 'Не удалось подписать транзакцию')
|
||
},
|
||
},
|
||
)
|
||
}
|
||
setModalData(null)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{trxModalQuote && (
|
||
<TrxConfirmModal
|
||
quote={trxModalQuote}
|
||
fromSymbol={fromToken.symbol}
|
||
toSymbol={toToken.symbol}
|
||
amountHuman={fromAmount}
|
||
insufficientBalance={insufficientBalance}
|
||
onClose={() => setTrxModalQuote(null)}
|
||
onConfirm={() => {
|
||
setErrorMessage(null)
|
||
executeTrxSwap(trxModalQuote.quoteId, {
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', 'TRX'] })
|
||
queryClient.invalidateQueries({ queryKey: ['wallet', 'portfolio'] })
|
||
navigate(ROUTES.WALLET)
|
||
},
|
||
onError: (err) => {
|
||
setErrorMessage(err instanceof Error ? err.message : 'Не удалось выполнить свап')
|
||
},
|
||
})
|
||
setTrxModalQuote(null)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{isProcessing && (
|
||
<Notification
|
||
status="warning"
|
||
message="Обработка транзакции..."
|
||
onClose={() => { }}
|
||
/>
|
||
)}
|
||
|
||
{errorMessage && (
|
||
<Notification
|
||
status="error"
|
||
message={errorMessage}
|
||
onClose={() => setErrorMessage(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|