213 lines
7.0 KiB
TypeScript
213 lines
7.0 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useQueryClient } from '@tanstack/react-query'
|
||
import {
|
||
useJumperTokens, useWalletBalance, useWalletAddresses, useFetchJumperQuote, useExecuteBridge,
|
||
type Chain, type JumperToken, type WalletBalanceData, type JumperQuote,
|
||
} from '@features/wallet'
|
||
import { Notification, PrimaryButton } from '@shared/ui'
|
||
import { ROUTES } from '@shared/config/routes'
|
||
import { toBaseUnits } from '@shared/lib/utils/baseUnits'
|
||
import {
|
||
TOKEN_META, buildTokensFromBalance, useSwapForm,
|
||
type Token,
|
||
} from '../../swap-form/model/useSwapForm'
|
||
import { SwapCard } from '../../swap-form/ui/SwapCard'
|
||
import { NetworkSelect } from './NetworkSelect'
|
||
import { BridgeConfirmModal } from './BridgeConfirmModal'
|
||
import styles from './BridgeForm.module.css'
|
||
|
||
const NETWORKS = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC']
|
||
|
||
const CHAIN_ID_BY_NET: Record<string, string> = {
|
||
ETH: '1',
|
||
BSC: '56',
|
||
SOL: '1151111081099710',
|
||
TRX: '728126428',
|
||
BTC: '20000000000001',
|
||
}
|
||
|
||
const NET_BY_CHAIN_ID: Record<string, string> = Object.fromEntries(
|
||
Object.entries(CHAIN_ID_BY_NET).map(([net, id]) => [id, net])
|
||
)
|
||
|
||
function mapJumperToken(t: JumperToken): Token {
|
||
const meta = TOKEN_META[t.symbol]
|
||
return {
|
||
symbol: t.symbol,
|
||
letter: meta?.letter ?? t.symbol[0],
|
||
color: meta?.color ?? '#888',
|
||
logo: t.logoURI ?? meta?.logo,
|
||
network: NET_BY_CHAIN_ID[String(t.chainId)] ?? t.symbol,
|
||
balance: 0,
|
||
usdRate: parseFloat(t.priceUSD) || 0,
|
||
decimals: t.decimals,
|
||
}
|
||
}
|
||
|
||
function balanceBySymbol(data: WalletBalanceData): Record<string, number> {
|
||
const map: Record<string, number> = {}
|
||
for (const t of buildTokensFromBalance(data)) map[t.symbol] = t.balance
|
||
return map
|
||
}
|
||
|
||
export function BridgeForm() {
|
||
const navigate = useNavigate()
|
||
const queryClient = useQueryClient()
|
||
const {
|
||
fromAmount, fromUsd,
|
||
fromToken, toToken,
|
||
setFromAmount, setPercent,
|
||
setFromToken, setToToken,
|
||
} = useSwapForm()
|
||
|
||
const [fromNetwork, setFromNetwork] = useState('ETH')
|
||
const [toNetwork, setToNetwork] = useState('BSC')
|
||
const [quote, setQuote] = useState<JumperQuote | null>(null)
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||
|
||
const { data: jumperData } = useJumperTokens()
|
||
const { data: fromWalletData } = useWalletBalance(fromNetwork as Chain)
|
||
const { data: toWalletData } = useWalletBalance(toNetwork as Chain)
|
||
const { data: addresses } = useWalletAddresses()
|
||
const { mutate: fetchQuote, isPending } = useFetchJumperQuote()
|
||
const { mutate: executeBridge, isPending: isExecuting } = useExecuteBridge()
|
||
|
||
function optionsFor(network: string, walletData?: WalletBalanceData): Token[] {
|
||
const list = (jumperData?.[CHAIN_ID_BY_NET[network]] ?? []).map(mapJumperToken)
|
||
if (!walletData) return list
|
||
const balances = balanceBySymbol(walletData)
|
||
return list.map(t => (balances[t.symbol] != null ? { ...t, balance: balances[t.symbol] } : t))
|
||
}
|
||
|
||
const fromTokenOptions = optionsFor(fromNetwork, fromWalletData)
|
||
const toTokenOptions = optionsFor(toNetwork, toWalletData)
|
||
|
||
useEffect(() => {
|
||
if (fromTokenOptions.length === 0) return
|
||
setFromToken(t => fromTokenOptions.find(o => o.symbol === t.symbol) ?? fromTokenOptions[0])
|
||
}, [jumperData, fromWalletData, fromNetwork])
|
||
|
||
useEffect(() => {
|
||
if (toTokenOptions.length === 0) return
|
||
setToToken(t => toTokenOptions.find(o => o.symbol === t.symbol) ?? toTokenOptions[0])
|
||
}, [jumperData, toWalletData, toNetwork])
|
||
|
||
const fromJumper = jumperData?.[CHAIN_ID_BY_NET[fromNetwork]]?.find(t => t.symbol === fromToken.symbol)
|
||
const toJumper = jumperData?.[CHAIN_ID_BY_NET[toNetwork]]?.find(t => t.symbol === toToken.symbol)
|
||
const fromAddress = addresses?.find(a => a.chain === fromNetwork)?.address
|
||
const toAddress = addresses?.find(a => a.chain === toNetwork)?.address
|
||
const parsedAmount = parseFloat(fromAmount)
|
||
const canQuote = !!fromJumper && !!toJumper && !!fromAddress && !!toAddress && parsedAmount > 0
|
||
|
||
function handleConfirm() {
|
||
if (!fromJumper || !toJumper || !fromAddress || !toAddress) return
|
||
setErrorMessage(null)
|
||
fetchQuote(
|
||
{
|
||
fromChain: CHAIN_ID_BY_NET[fromNetwork],
|
||
toChain: CHAIN_ID_BY_NET[toNetwork],
|
||
fromToken: fromJumper.address,
|
||
toToken: toJumper.address,
|
||
fromAmount: toBaseUnits(fromAmount, fromToken.decimals),
|
||
fromAddress,
|
||
toAddress,
|
||
slippage: 0.005,
|
||
},
|
||
{
|
||
onSuccess: (q) => setQuote(q),
|
||
onError: (err) => setErrorMessage(err instanceof Error ? err.message : 'Не удалось получить котировку'),
|
||
},
|
||
)
|
||
}
|
||
|
||
function handleExecute() {
|
||
if (!quote) return
|
||
setErrorMessage(null)
|
||
executeBridge(
|
||
{
|
||
provider: 'jumper',
|
||
fromChain: quote.action.fromChainId,
|
||
toChain: quote.action.toChainId,
|
||
fromToken: quote.action.fromToken.address,
|
||
toToken: quote.action.toToken.address,
|
||
fromAmount: quote.action.fromAmount,
|
||
fromAddress: quote.action.fromAddress,
|
||
toAddress: quote.action.toAddress,
|
||
acceptedMinOut: quote.estimate.toAmountMin,
|
||
},
|
||
{
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', fromNetwork] })
|
||
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', toNetwork] })
|
||
queryClient.invalidateQueries({ queryKey: ['wallet', 'portfolio'] })
|
||
setQuote(null)
|
||
navigate(ROUTES.WALLET)
|
||
},
|
||
onError: (err) => setErrorMessage(err instanceof Error ? err.message : 'Не удалось выполнить бридж'),
|
||
},
|
||
)
|
||
}
|
||
|
||
if (!jumperData) {
|
||
return <div className={styles.form} />
|
||
}
|
||
|
||
return (
|
||
<div className={styles.form}>
|
||
<NetworkSelect
|
||
label="ИЗ"
|
||
value={fromNetwork}
|
||
onChange={setFromNetwork}
|
||
options={NETWORKS.filter(n => n !== toNetwork)}
|
||
/>
|
||
<SwapCard
|
||
mode="from"
|
||
token={fromToken}
|
||
tokenOptions={fromTokenOptions}
|
||
amount={fromAmount}
|
||
usd={fromUsd}
|
||
onAmountChange={setFromAmount}
|
||
onSetPercent={setPercent}
|
||
onTokenChange={setFromToken}
|
||
hideNetworkSelect
|
||
/>
|
||
|
||
<NetworkSelect
|
||
label="В"
|
||
value={toNetwork}
|
||
onChange={setToNetwork}
|
||
options={NETWORKS.filter(n => n !== fromNetwork)}
|
||
/>
|
||
<SwapCard
|
||
mode="to"
|
||
token={toToken}
|
||
tokenOptions={toTokenOptions}
|
||
amount="0"
|
||
onTokenChange={setToToken}
|
||
hideNetworkSelect
|
||
/>
|
||
|
||
<PrimaryButton label="Подтвердить бридж" onClick={handleConfirm} disabled={!canQuote || isPending} />
|
||
|
||
{quote && (
|
||
<BridgeConfirmModal
|
||
quote={quote}
|
||
fromAmountHuman={fromAmount}
|
||
isExecuting={isExecuting}
|
||
onConfirm={handleExecute}
|
||
onClose={() => setQuote(null)}
|
||
/>
|
||
)}
|
||
|
||
{errorMessage && (
|
||
<Notification
|
||
status="error"
|
||
message={errorMessage}
|
||
onClose={() => setErrorMessage(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|