add redirects
This commit is contained in:
@@ -195,6 +195,136 @@ export async function getTokensList(): Promise<TokenInfo[]> {
|
|||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JumperToken {
|
||||||
|
address: string
|
||||||
|
chainId: number
|
||||||
|
symbol: string
|
||||||
|
decimals: number
|
||||||
|
name: string
|
||||||
|
coinKey?: string
|
||||||
|
logoURI?: string
|
||||||
|
priceUSD: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JumperTokensMap = Record<string, JumperToken[]>
|
||||||
|
|
||||||
|
export async function getJumperTokens(): Promise<JumperTokensMap> {
|
||||||
|
const res = await walletGet<{ tokens?: JumperTokensMap; data?: { tokens: JumperTokensMap } }>(
|
||||||
|
'/api/jumper/tokens?chains=1,56,1151111081099710,728126428,20000000000001'
|
||||||
|
)
|
||||||
|
return res.data?.tokens ?? res.tokens ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JumperQuotePayload {
|
||||||
|
fromChain: string
|
||||||
|
toChain: string
|
||||||
|
fromToken: string
|
||||||
|
toToken: string
|
||||||
|
fromAmount: string
|
||||||
|
fromAddress: string
|
||||||
|
toAddress: string
|
||||||
|
slippage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JumperQuoteToken {
|
||||||
|
address: string
|
||||||
|
chainId: number
|
||||||
|
symbol: string
|
||||||
|
decimals: number
|
||||||
|
name: string
|
||||||
|
logoURI?: string
|
||||||
|
priceUSD: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JumperFeeCost {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
token: JumperQuoteToken
|
||||||
|
amount: string
|
||||||
|
amountUSD: string
|
||||||
|
percentage?: string
|
||||||
|
included?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JumperQuote {
|
||||||
|
type: string
|
||||||
|
id: string
|
||||||
|
tool: string
|
||||||
|
toolDetails: { key: string; name: string; logoURI?: string }
|
||||||
|
action: {
|
||||||
|
fromToken: JumperQuoteToken
|
||||||
|
fromAmount: string
|
||||||
|
toToken: JumperQuoteToken
|
||||||
|
fromChainId: number
|
||||||
|
toChainId: number
|
||||||
|
slippage: number
|
||||||
|
fromAddress: string
|
||||||
|
toAddress: string
|
||||||
|
}
|
||||||
|
estimate: {
|
||||||
|
tool: string
|
||||||
|
approvalAddress?: string
|
||||||
|
toAmountMin: string
|
||||||
|
toAmount: string
|
||||||
|
fromAmount: string
|
||||||
|
feeCosts?: JumperFeeCost[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJumperQuote(payload: JumperQuotePayload): Promise<JumperQuote> {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
fromChain: payload.fromChain,
|
||||||
|
toChain: payload.toChain,
|
||||||
|
fromToken: payload.fromToken,
|
||||||
|
toToken: payload.toToken,
|
||||||
|
fromAmount: payload.fromAmount,
|
||||||
|
fromAddress: payload.fromAddress,
|
||||||
|
toAddress: payload.toAddress,
|
||||||
|
slippage: String(payload.slippage),
|
||||||
|
}).toString()
|
||||||
|
const res = await walletGet<{ body?: JumperQuote; data?: { body?: JumperQuote } }>(
|
||||||
|
`/api/jumper/quote-best?${qs}`
|
||||||
|
)
|
||||||
|
return (res.data?.body ?? res.body) as JumperQuote
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BridgeExecutePayload {
|
||||||
|
provider: string
|
||||||
|
fromChain: number
|
||||||
|
toChain: number
|
||||||
|
fromToken: string
|
||||||
|
toToken: string
|
||||||
|
fromAmount: string
|
||||||
|
fromAddress: string
|
||||||
|
toAddress: string
|
||||||
|
acceptedMinOut?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BridgeExecuteResult {
|
||||||
|
provider: string
|
||||||
|
fromChain: number
|
||||||
|
toChain: number
|
||||||
|
toolName: string
|
||||||
|
feeTxid?: string
|
||||||
|
feeAmount?: string
|
||||||
|
bridgeTxid: string
|
||||||
|
fromAmount: string
|
||||||
|
toAmountMin: string
|
||||||
|
fromAmountUSD?: string
|
||||||
|
toAmountUSD?: string
|
||||||
|
trackerUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBridge(payload: BridgeExecutePayload): Promise<BridgeExecuteResult> {
|
||||||
|
const res = await walletPost<{ data?: { success: boolean; data: BridgeExecuteResult } }>(
|
||||||
|
'/api/bridge/execute',
|
||||||
|
payload,
|
||||||
|
true,
|
||||||
|
{ 'Idempotency-Key': crypto.randomUUID() }
|
||||||
|
)
|
||||||
|
return (res.data?.data ?? res) as BridgeExecuteResult
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRelayQuote(payload: RelayQuotePayload): Promise<RelayQuoteResponse> {
|
export async function getRelayQuote(payload: RelayQuotePayload): Promise<RelayQuoteResponse> {
|
||||||
return walletPost<RelayQuoteResponse>('/api/relay/quote', payload)
|
return walletPost<RelayQuoteResponse>('/api/relay/quote', payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { useAllWalletBalances, usePrices, useSendWallet, useWalletAddresses, useWalletBalance, usePortfolio, useTokensList, useRelayQuote, useExecuteRelaySwap, useSignSwap, useTrxSwapQuote, useFetchTrxQuote, useExecuteTrxSwap, useCreateWallet, useRevealMnemonic } from './model/useWalletData'
|
export { useAllWalletBalances, usePrices, useSendWallet, useWalletAddresses, useWalletBalance, usePortfolio, useTokensList, useRelayQuote, useExecuteRelaySwap, useSignSwap, useTrxSwapQuote, useFetchTrxQuote, useExecuteTrxSwap, useJumperTokens, useFetchJumperQuote, useExecuteBridge, useCreateWallet, useRevealMnemonic } from './model/useWalletData'
|
||||||
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry, SendWalletPayload, SendWalletResponse, WalletAddress, PortfolioData, PortfolioChain, PortfolioNative, PortfolioToken, TokenInfo, RelayQuotePayload, RelayQuoteResponse, RelaySwapResponse, RelaySwapStep, TrxSwapQuotePayload, TrxSwapQuoteData } from './api/walletApi'
|
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry, SendWalletPayload, SendWalletResponse, WalletAddress, PortfolioData, PortfolioChain, PortfolioNative, PortfolioToken, TokenInfo, RelayQuotePayload, RelayQuoteResponse, RelaySwapResponse, RelaySwapStep, TrxSwapQuotePayload, TrxSwapQuoteData, JumperToken, JumperTokensMap, JumperQuote, JumperQuotePayload, JumperQuoteToken, JumperFeeCost, BridgeExecutePayload, BridgeExecuteResult } from './api/walletApi'
|
||||||
export { CHAINS } from './api/walletApi'
|
export { CHAINS } from './api/walletApi'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
||||||
import { getWalletBalance, getPrices, sendWallet, getWalletAddresses, getPortfolio, getTokensList, getRelayQuote, executeRelaySwap, signRawEvmTx, signSolTx, getTrxSwapQuote, executeTrxSwap, createWallet, revealMnemonic, CHAINS, type Chain, type SendWalletPayload, type RelayQuotePayload, type RelaySwapStep, type TrxSwapQuotePayload } from '../api/walletApi'
|
import { getWalletBalance, getPrices, sendWallet, getWalletAddresses, getPortfolio, getTokensList, getRelayQuote, executeRelaySwap, signRawEvmTx, signSolTx, getTrxSwapQuote, executeTrxSwap, getJumperTokens, getJumperQuote, executeBridge, createWallet, revealMnemonic, CHAINS, type Chain, type SendWalletPayload, type RelayQuotePayload, type RelaySwapStep, type TrxSwapQuotePayload, type JumperQuotePayload, type BridgeExecutePayload } from '../api/walletApi'
|
||||||
|
|
||||||
export function useWalletBalance(chain: Chain) {
|
export function useWalletBalance(chain: Chain) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -58,6 +58,22 @@ export function useTokensList() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useJumperTokens() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['wallet', 'jumper', 'tokens'],
|
||||||
|
queryFn: getJumperTokens,
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetchJumperQuote() {
|
||||||
|
return useMutation({ mutationFn: (payload: JumperQuotePayload) => getJumperQuote(payload) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExecuteBridge() {
|
||||||
|
return useMutation({ mutationFn: (payload: BridgeExecutePayload) => executeBridge(payload) })
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateWallet() {
|
export function useCreateWallet() {
|
||||||
return useMutation({ mutationFn: createWallet })
|
return useMutation({ mutationFn: createWallet })
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/shared/lib/utils/baseUnits.ts
Normal file
13
src/shared/lib/utils/baseUnits.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function toBaseUnits(amountHuman: string, decimals: number): string {
|
||||||
|
const [intPart, fracRaw = ''] = amountHuman.split('.')
|
||||||
|
const frac = fracRaw.slice(0, decimals).padEnd(decimals, '0')
|
||||||
|
const combined = `${intPart}${frac}`.replace(/^0+(?=\d)/, '')
|
||||||
|
return combined || '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromBaseUnits(raw: string, decimals: number): string {
|
||||||
|
const s = raw.padStart(decimals + 1, '0')
|
||||||
|
const int = s.slice(0, s.length - decimals)
|
||||||
|
const frac = s.slice(s.length - decimals).replace(/0+$/, '')
|
||||||
|
return frac ? `${int}.${frac}` : int
|
||||||
|
}
|
||||||
141
src/widgets/bridge-form/ui/BridgeConfirmModal.module.css
Normal file
141
src/widgets/bridge-form/ui/BridgeConfirmModal.module.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 11, 46, 0.75);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-mid);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenAmount {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.minOut {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowValue {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn {
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
transition: filter 0.25s, box-shadow 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
box-shadow: 0 0 24px rgba(91, 61, 184, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
66
src/widgets/bridge-form/ui/BridgeConfirmModal.tsx
Normal file
66
src/widgets/bridge-form/ui/BridgeConfirmModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { JumperQuote } from '@features/wallet'
|
||||||
|
import { fromBaseUnits } from '@shared/lib/utils/baseUnits'
|
||||||
|
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
|
||||||
|
import styles from './BridgeConfirmModal.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quote: JumperQuote
|
||||||
|
fromAmountHuman: string
|
||||||
|
isExecuting?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BridgeConfirmModal({ quote, fromAmountHuman, isExecuting, onConfirm, onClose }: Props) {
|
||||||
|
const { action, estimate, toolDetails } = quote
|
||||||
|
const toSymbol = action.toToken.symbol
|
||||||
|
const fromSymbol = action.fromToken.symbol
|
||||||
|
|
||||||
|
const toAmount = truncateDecimals(fromBaseUnits(estimate.toAmount, action.toToken.decimals), 8)
|
||||||
|
const toMin = truncateDecimals(fromBaseUnits(estimate.toAmountMin, action.toToken.decimals), 8)
|
||||||
|
|
||||||
|
const feesUsd = (estimate.feeCosts ?? [])
|
||||||
|
.reduce((sum, f) => sum + (parseFloat(f.amountUSD) || 0), 0)
|
||||||
|
.toFixed(2)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose}>
|
||||||
|
<div className={styles.card} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.title}>Подтвердить бридж</span>
|
||||||
|
<button className={styles.closeBtn} onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.flow}>
|
||||||
|
<div className={styles.token}>
|
||||||
|
<span className={styles.tokenLabel}>Отдаёте</span>
|
||||||
|
<span className={styles.tokenAmount}>{fromAmountHuman} {fromSymbol}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.arrow}>↓</div>
|
||||||
|
|
||||||
|
<div className={styles.token}>
|
||||||
|
<span className={styles.tokenLabel}>Получаете</span>
|
||||||
|
<span className={styles.tokenAmount}>{toAmount} {toSymbol}</span>
|
||||||
|
<span className={styles.minOut}>Минимум: {toMin} {toSymbol}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.details}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>Комиссия</span>
|
||||||
|
<span className={styles.rowValue}>≈${feesUsd}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>Мост</span>
|
||||||
|
<span className={styles.rowValue}>{toolDetails.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.confirmBtn} onClick={onConfirm} disabled={isExecuting}>
|
||||||
|
{isExecuting ? 'Обработка...' : 'Подтвердить бридж'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,141 +1,166 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Notification, PrimaryButton } from '@shared/ui'
|
|
||||||
import {
|
import {
|
||||||
useWalletBalance, useWalletAddresses, useTokensList,
|
useJumperTokens, useWalletBalance, useWalletAddresses, useFetchJumperQuote, useExecuteBridge,
|
||||||
useRelayQuote, useExecuteRelaySwap, useSignSwap,
|
type Chain, type JumperToken, type WalletBalanceData, type JumperQuote,
|
||||||
useTrxSwapQuote, useFetchTrxQuote, useExecuteTrxSwap,
|
|
||||||
type Chain, type RelaySwapResponse, type TrxSwapQuoteData,
|
|
||||||
} from '@features/wallet'
|
} from '@features/wallet'
|
||||||
|
import { Notification, PrimaryButton } from '@shared/ui'
|
||||||
import { ROUTES } from '@shared/config/routes'
|
import { ROUTES } from '@shared/config/routes'
|
||||||
import { useDebounce } from '@shared/lib/hooks/useDebounce'
|
import { toBaseUnits } from '@shared/lib/utils/baseUnits'
|
||||||
import { TOKENS_LIST, buildTokensFromBalance, useSwapForm } from '../../swap-form/model/useSwapForm'
|
import {
|
||||||
|
TOKEN_META, buildTokensFromBalance, useSwapForm,
|
||||||
|
type Token,
|
||||||
|
} from '../../swap-form/model/useSwapForm'
|
||||||
import { SwapCard } from '../../swap-form/ui/SwapCard'
|
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 { NetworkSelect } from './NetworkSelect'
|
||||||
|
import { BridgeConfirmModal } from './BridgeConfirmModal'
|
||||||
import styles from './BridgeForm.module.css'
|
import styles from './BridgeForm.module.css'
|
||||||
|
|
||||||
const CHAIN_ID: Record<string, number> = { ETH: 1, BSC: 56, SOL: 792703809 }
|
const NETWORKS = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC']
|
||||||
const NATIVE_ADDR: Record<string, string> = {
|
|
||||||
SOL: '11111111111111111111111111111111',
|
const CHAIN_ID_BY_NET: Record<string, string> = {
|
||||||
DEFAULT: '0x0000000000000000000000000000000000000000',
|
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() {
|
export function BridgeForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fromAmount, fromUsd,
|
fromAmount, fromUsd,
|
||||||
fromToken, toToken,
|
fromToken, toToken,
|
||||||
setFromAmount, setPercent, swapTokens,
|
setFromAmount, setPercent,
|
||||||
setFromToken, setToToken,
|
setFromToken, setToToken,
|
||||||
} = useSwapForm()
|
} = useSwapForm()
|
||||||
|
|
||||||
const [fromNetwork, setFromNetwork] = useState('ETH')
|
const [fromNetwork, setFromNetwork] = useState('ETH')
|
||||||
const [toNetwork, setToNetwork] = useState('BSC')
|
const [toNetwork, setToNetwork] = useState('BSC')
|
||||||
const [modalData, setModalData] = useState<RelaySwapResponse | null>(null)
|
const [quote, setQuote] = useState<JumperQuote | null>(null)
|
||||||
const [trxModalQuote, setTrxModalQuote] = useState<TrxSwapQuoteData | null>(null)
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | 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: fromWalletData } = useWalletBalance(fromNetwork as Chain)
|
||||||
const { data: toWalletData } = useWalletBalance(toNetwork 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
|
function optionsFor(network: string, walletData?: WalletBalanceData): Token[] {
|
||||||
const toTokenOptions = toWalletData ? buildTokensFromBalance(toWalletData) : TOKENS_LIST
|
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(() => {
|
useEffect(() => {
|
||||||
if (fromTokenOptions.length === 0) return
|
if (fromTokenOptions.length === 0) return
|
||||||
setFromToken(t => fromTokenOptions.find(o => o.symbol === t.symbol) ?? fromTokenOptions[0])
|
setFromToken(t => fromTokenOptions.find(o => o.symbol === t.symbol) ?? fromTokenOptions[0])
|
||||||
}, [fromWalletData, fromNetwork])
|
}, [jumperData, fromWalletData, fromNetwork])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toTokenOptions.length === 0) return
|
if (toTokenOptions.length === 0) return
|
||||||
setToToken(t => toTokenOptions.find(o => o.symbol === t.symbol) ?? toTokenOptions[0])
|
setToToken(t => toTokenOptions.find(o => o.symbol === t.symbol) ?? toTokenOptions[0])
|
||||||
}, [toWalletData, toNetwork])
|
}, [jumperData, toWalletData, toNetwork])
|
||||||
|
|
||||||
const debouncedAmount = useDebounce(fromAmount, 500)
|
const fromJumper = jumperData?.[CHAIN_ID_BY_NET[fromNetwork]]?.find(t => t.symbol === fromToken.symbol)
|
||||||
const { data: addresses } = useWalletAddresses()
|
const toJumper = jumperData?.[CHAIN_ID_BY_NET[toNetwork]]?.find(t => t.symbol === toToken.symbol)
|
||||||
const { data: tokensList } = useTokensList()
|
|
||||||
|
|
||||||
const parsedAmount = parseFloat(debouncedAmount)
|
|
||||||
|
|
||||||
const fromChainId = CHAIN_ID[fromNetwork]
|
|
||||||
const toChainId = CHAIN_ID[toNetwork]
|
|
||||||
const fromAddress = addresses?.find(a => a.chain === fromNetwork)?.address
|
const fromAddress = addresses?.find(a => a.chain === fromNetwork)?.address
|
||||||
const toAddress = addresses?.find(a => a.chain === toNetwork)?.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 parsedAmount = parseFloat(fromAmount)
|
||||||
const toContract = tokensList?.find(t => t.chain === toNetwork && t.symbol === toToken.symbol)?.contract ?? nativeAddr(toNetwork)
|
const canQuote = !!fromJumper && !!toJumper && !!fromAddress && !!toAddress && parsedAmount > 0
|
||||||
|
|
||||||
const quotePayload = !isTrxNetwork && fromChainId && toChainId && fromAddress && parsedAmount > 0
|
function handleConfirm() {
|
||||||
? {
|
if (!fromJumper || !toJumper || !fromAddress || !toAddress) return
|
||||||
user: fromAddress,
|
setErrorMessage(null)
|
||||||
recipient: toAddress ?? fromAddress,
|
fetchQuote(
|
||||||
originChainId: fromChainId,
|
{
|
||||||
destinationChainId: toChainId,
|
fromChain: CHAIN_ID_BY_NET[fromNetwork],
|
||||||
originCurrency: fromContract,
|
toChain: CHAIN_ID_BY_NET[toNetwork],
|
||||||
destinationCurrency: toContract,
|
fromToken: fromJumper.address,
|
||||||
amount: Math.round(parsedAmount * Math.pow(10, fromToken.decimals)).toString(),
|
toToken: toJumper.address,
|
||||||
tradeType: 'EXACT_INPUT' as const,
|
fromAmount: toBaseUnits(fromAmount, fromToken.decimals),
|
||||||
|
fromAddress,
|
||||||
|
toAddress,
|
||||||
|
slippage: 0.005,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (q) => setQuote(q),
|
||||||
|
onError: (err) => setErrorMessage(err instanceof Error ? err.message : 'Не удалось получить котировку'),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
: null
|
|
||||||
|
|
||||||
const { data: quoteData } = useRelayQuote(quotePayload)
|
function handleExecute() {
|
||||||
const { mutate: executeSwap, isPending: isSwapping } = useExecuteRelaySwap()
|
if (!quote) return
|
||||||
const { mutate: signSwap, isPending: isSigning } = useSignSwap()
|
setErrorMessage(null)
|
||||||
|
executeBridge(
|
||||||
const trxQuotePayload = isTrxNetwork && parsedAmount > 0
|
{
|
||||||
? { from: fromToken.symbol, to: toToken.symbol, amountHuman: debouncedAmount }
|
provider: 'jumper',
|
||||||
: null
|
fromChain: quote.action.fromChainId,
|
||||||
|
toChain: quote.action.toChainId,
|
||||||
const { data: trxQuoteData } = useTrxSwapQuote(trxQuotePayload)
|
fromToken: quote.action.fromToken.address,
|
||||||
const { mutate: fetchTrxQuote, isPending: isFetchingTrxQuote } = useFetchTrxQuote()
|
toToken: quote.action.toToken.address,
|
||||||
const { mutate: executeTrxSwap, isPending: isExecutingTrxSwap } = useExecuteTrxSwap()
|
fromAmount: quote.action.fromAmount,
|
||||||
|
fromAddress: quote.action.fromAddress,
|
||||||
const isProcessing = isSigning || isExecutingTrxSwap
|
toAddress: quote.action.toAddress,
|
||||||
|
acceptedMinOut: quote.estimate.toAmountMin,
|
||||||
const displayToAmount = isTrxNetwork
|
},
|
||||||
? (trxQuoteData?.expectedOutFormatted ?? '0')
|
{
|
||||||
: (quoteData?.details.currencyOut.amountFormatted ?? '0')
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', fromNetwork] })
|
||||||
const displayToUsd = isTrxNetwork
|
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', toNetwork] })
|
||||||
? undefined
|
queryClient.invalidateQueries({ queryKey: ['wallet', 'portfolio'] })
|
||||||
: quoteData?.details.currencyOut.amountUsd
|
setQuote(null)
|
||||||
|
navigate(ROUTES.WALLET)
|
||||||
const gasFee = isTrxNetwork
|
},
|
||||||
? trxQuoteData?.fees.network.amountUsd?.toString()
|
onError: (err) => setErrorMessage(err instanceof Error ? err.message : 'Не удалось выполнить бридж'),
|
||||||
: 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 (
|
return (
|
||||||
<div className={styles.form}>
|
<div className={styles.form}>
|
||||||
<NetworkSelect label="ИЗ" value={fromNetwork} onChange={setFromNetwork} />
|
<NetworkSelect
|
||||||
|
label="ИЗ"
|
||||||
|
value={fromNetwork}
|
||||||
|
onChange={setFromNetwork}
|
||||||
|
options={NETWORKS.filter(n => n !== toNetwork)}
|
||||||
|
/>
|
||||||
<SwapCard
|
<SwapCard
|
||||||
mode="from"
|
mode="from"
|
||||||
token={fromToken}
|
token={fromToken}
|
||||||
@@ -148,81 +173,30 @@ export function BridgeForm() {
|
|||||||
hideNetworkSelect
|
hideNetworkSelect
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SwapDirectionButton onClick={swapTokens} />
|
<NetworkSelect
|
||||||
|
label="В"
|
||||||
<NetworkSelect label="В" value={toNetwork} onChange={setToNetwork} />
|
value={toNetwork}
|
||||||
|
onChange={setToNetwork}
|
||||||
|
options={NETWORKS.filter(n => n !== fromNetwork)}
|
||||||
|
/>
|
||||||
<SwapCard
|
<SwapCard
|
||||||
mode="to"
|
mode="to"
|
||||||
token={toToken}
|
token={toToken}
|
||||||
tokenOptions={toTokenOptions}
|
tokenOptions={toTokenOptions}
|
||||||
amount={displayToAmount}
|
amount="0"
|
||||||
usd={displayToUsd}
|
|
||||||
onTokenChange={setToToken}
|
onTokenChange={setToToken}
|
||||||
hideNetworkSelect
|
hideNetworkSelect
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SwapInfoPanel gasFee={gasFee} />
|
<PrimaryButton label="Подтвердить бридж" onClick={handleConfirm} disabled={!canQuote || isPending} />
|
||||||
|
|
||||||
<PrimaryButton label="Подтвердить бридж" onClick={handleSwap} disabled={isButtonDisabled} />
|
{quote && (
|
||||||
|
<BridgeConfirmModal
|
||||||
{modalData && (
|
quote={quote}
|
||||||
<SwapConfirmModal
|
fromAmountHuman={fromAmount}
|
||||||
data={modalData}
|
isExecuting={isExecuting}
|
||||||
onClose={() => setModalData(null)}
|
onConfirm={handleExecute}
|
||||||
onConfirm={() => {
|
onClose={() => setQuote(null)}
|
||||||
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={() => {}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import styles from './NetworkSelect.module.css'
|
import styles from './NetworkSelect.module.css'
|
||||||
|
|
||||||
const NETWORKS = ['ETH', 'BSC', 'TRX', 'SOL']
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
|
options: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkSelect({ label, value, onChange }: Props) {
|
export function NetworkSelect({ label, value, onChange, options }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<span className={styles.label}>{label}</span>
|
<span className={styles.label}>{label}</span>
|
||||||
@@ -17,7 +16,7 @@ export function NetworkSelect({ label, value, onChange }: Props) {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{NETWORKS.map(n => (
|
{options.map(n => (
|
||||||
<option key={n} value={n}>{n}</option>
|
<option key={n} value={n}>{n}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { SwapConfirmModal } from './SwapConfirmModal'
|
|||||||
import { TrxConfirmModal } from './TrxConfirmModal'
|
import { TrxConfirmModal } from './TrxConfirmModal'
|
||||||
import styles from './SwapForm.module.css'
|
import styles from './SwapForm.module.css'
|
||||||
|
|
||||||
|
|
||||||
const CHAIN_ID: Record<string, number> = { ETH: 1, BSC: 56, SOL: 792703809 }
|
const CHAIN_ID: Record<string, number> = { ETH: 1, BSC: 56, SOL: 792703809 }
|
||||||
const NATIVE_ADDR: Record<string, string> = {
|
const NATIVE_ADDR: Record<string, string> = {
|
||||||
SOL: '11111111111111111111111111111111',
|
SOL: '11111111111111111111111111111111',
|
||||||
@@ -211,7 +210,7 @@ export function SwapForm() {
|
|||||||
<Notification
|
<Notification
|
||||||
status="warning"
|
status="warning"
|
||||||
message="Обработка транзакции..."
|
message="Обработка транзакции..."
|
||||||
onClose={() => {}}
|
onClose={() => { }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user