add redirects

This commit is contained in:
2026-05-28 20:24:56 +03:00
parent 2e6ed487fd
commit 2026230ff6
10 changed files with 506 additions and 168 deletions

View File

@@ -1,141 +1,166 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } 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,
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 { useDebounce } from '@shared/lib/hooks/useDebounce'
import { TOKENS_LIST, buildTokensFromBalance, useSwapForm } from '../../swap-form/model/useSwapForm'
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 { SwapDirectionButton } from '../../swap-form/ui/SwapDirectionButton'
import { SwapInfoPanel } from '../../swap-form/ui/SwapInfoPanel'
import { SwapConfirmModal } from '../../swap-form/ui/SwapConfirmModal'
import { TrxConfirmModal } from '../../swap-form/ui/TrxConfirmModal'
import { NetworkSelect } from './NetworkSelect'
import { BridgeConfirmModal } from './BridgeConfirmModal'
import styles from './BridgeForm.module.css'
const CHAIN_ID: Record<string, number> = { ETH: 1, BSC: 56, SOL: 792703809 }
const NATIVE_ADDR: Record<string, string> = {
SOL: '11111111111111111111111111111111',
DEFAULT: '0x0000000000000000000000000000000000000000',
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',
}
function nativeAddr(chain: string) {
return NATIVE_ADDR[chain] ?? NATIVE_ADDR.DEFAULT
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, swapTokens,
setFromAmount, setPercent,
setFromToken, setToToken,
} = useSwapForm()
const [fromNetwork, setFromNetwork] = useState('ETH')
const [toNetwork, setToNetwork] = useState('BSC')
const [modalData, setModalData] = useState<RelaySwapResponse | null>(null)
const [trxModalQuote, setTrxModalQuote] = useState<TrxSwapQuoteData | null>(null)
const [quote, setQuote] = useState<JumperQuote | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const isTrxNetwork = fromNetwork === 'TRX'
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()
const fromTokenOptions = fromWalletData ? buildTokensFromBalance(fromWalletData) : TOKENS_LIST
const toTokenOptions = toWalletData ? buildTokensFromBalance(toWalletData) : TOKENS_LIST
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])
}, [fromWalletData, fromNetwork])
}, [jumperData, fromWalletData, fromNetwork])
useEffect(() => {
if (toTokenOptions.length === 0) return
setToToken(t => toTokenOptions.find(o => o.symbol === t.symbol) ?? toTokenOptions[0])
}, [toWalletData, toNetwork])
}, [jumperData, toWalletData, toNetwork])
const debouncedAmount = useDebounce(fromAmount, 500)
const { data: addresses } = useWalletAddresses()
const { data: tokensList } = useTokensList()
const parsedAmount = parseFloat(debouncedAmount)
const fromChainId = CHAIN_ID[fromNetwork]
const toChainId = CHAIN_ID[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 fromContract = tokensList?.find(t => t.chain === fromNetwork && t.symbol === fromToken.symbol)?.contract ?? nativeAddr(fromNetwork)
const toContract = tokensList?.find(t => t.chain === toNetwork && t.symbol === toToken.symbol)?.contract ?? nativeAddr(toNetwork)
const parsedAmount = parseFloat(fromAmount)
const canQuote = !!fromJumper && !!toJumper && !!fromAddress && !!toAddress && parsedAmount > 0
const quotePayload = !isTrxNetwork && fromChainId && toChainId && fromAddress && parsedAmount > 0
? {
user: fromAddress,
recipient: toAddress ?? fromAddress,
originChainId: fromChainId,
destinationChainId: toChainId,
originCurrency: fromContract,
destinationCurrency: toContract,
amount: Math.round(parsedAmount * Math.pow(10, fromToken.decimals)).toString(),
tradeType: 'EXACT_INPUT' as const,
}
: null
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 : 'Не удалось получить котировку'),
},
)
}
const { data: quoteData } = useRelayQuote(quotePayload)
const { mutate: executeSwap, isPending: isSwapping } = useExecuteRelaySwap()
const { mutate: signSwap, isPending: isSigning } = useSignSwap()
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 : 'Не удалось выполнить бридж'),
},
)
}
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 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 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),
})
}
if (!jumperData) {
return <div className={styles.form} />
}
return (
<div className={styles.form}>
<NetworkSelect label="ИЗ" value={fromNetwork} onChange={setFromNetwork} />
<NetworkSelect
label="ИЗ"
value={fromNetwork}
onChange={setFromNetwork}
options={NETWORKS.filter(n => n !== toNetwork)}
/>
<SwapCard
mode="from"
token={fromToken}
@@ -148,81 +173,30 @@ export function BridgeForm() {
hideNetworkSelect
/>
<SwapDirectionButton onClick={swapTokens} />
<NetworkSelect label="В" value={toNetwork} onChange={setToNetwork} />
<NetworkSelect
label="В"
value={toNetwork}
onChange={setToNetwork}
options={NETWORKS.filter(n => n !== fromNetwork)}
/>
<SwapCard
mode="to"
token={toToken}
tokenOptions={toTokenOptions}
amount={displayToAmount}
usd={displayToUsd}
amount="0"
onTokenChange={setToToken}
hideNetworkSelect
/>
<SwapInfoPanel gasFee={gasFee} />
<PrimaryButton label="Подтвердить бридж" onClick={handleConfirm} disabled={!canQuote || isPending} />
<PrimaryButton label="Подтвердить бридж" onClick={handleSwap} disabled={isButtonDisabled} />
{modalData && (
<SwapConfirmModal
data={modalData}
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', 'balance', toNetwork] })
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}
onClose={() => setTrxModalQuote(null)}
onConfirm={() => {
setErrorMessage(null)
executeTrxSwap(trxModalQuote.quoteId, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', 'TRX'] })
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', toNetwork] })
queryClient.invalidateQueries({ queryKey: ['wallet', 'portfolio'] })
navigate(ROUTES.WALLET)
},
onError: (err) => {
setErrorMessage(err instanceof Error ? err.message : 'Не удалось выполнить бридж')
},
})
setTrxModalQuote(null)
}}
/>
)}
{isProcessing && (
<Notification
status="warning"
message="Обработка транзакции..."
onClose={() => {}}
{quote && (
<BridgeConfirmModal
quote={quote}
fromAmountHuman={fromAmount}
isExecuting={isExecuting}
onConfirm={handleExecute}
onClose={() => setQuote(null)}
/>
)}