14.05.2026 rip
This commit is contained in:
@@ -96,7 +96,12 @@ async function walletGet<T>(path: string, allowRetry: boolean = true): Promise<T
|
||||
return data as T
|
||||
}
|
||||
|
||||
async function walletPost<T>(path: string, body: unknown, allowRetry: boolean = true): Promise<T> {
|
||||
async function walletPost<T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
allowRetry: boolean = true,
|
||||
extraHeaders: Record<string, string> = {}
|
||||
): Promise<T> {
|
||||
const csrf = await getCsrfToken()
|
||||
const bearer = tokenStore.get()
|
||||
|
||||
@@ -107,6 +112,7 @@ async function walletPost<T>(path: string, body: unknown, allowRetry: boolean =
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -114,7 +120,7 @@ async function walletPost<T>(path: string, body: unknown, allowRetry: boolean =
|
||||
if (res.status === 401 && allowRetry) {
|
||||
try {
|
||||
await refreshAccessToken()
|
||||
return walletPost<T>(path, body, false)
|
||||
return walletPost<T>(path, body, false, extraHeaders)
|
||||
} catch {
|
||||
tokenStore.clear()
|
||||
throw new Error('Unauthorized')
|
||||
@@ -225,8 +231,8 @@ export interface RelaySwapResponse {
|
||||
operation: string
|
||||
sender: string
|
||||
recipient: string
|
||||
currencyIn: { amount: string; amountFormatted: string; amountUsd: string; currency: unknown }
|
||||
currencyOut: { amount: string; amountFormatted: string; amountUsd: string; currency: unknown }
|
||||
currencyIn: { amount: string; amountFormatted: string; amountUsd: string; currency: { symbol: string } }
|
||||
currencyOut: { amount: string; amountFormatted: string; amountUsd: string; currency: { symbol: string } }
|
||||
totalImpact: { usd: string; percent: string }
|
||||
rate: string
|
||||
timeEstimate: number
|
||||
@@ -237,6 +243,18 @@ export async function executeRelaySwap(payload: RelayQuotePayload): Promise<Rela
|
||||
return walletPost<RelaySwapResponse>('/api/relay/execute/swap', payload)
|
||||
}
|
||||
|
||||
export async function signRawEvmTx(
|
||||
chain: 'ETH' | 'BSC',
|
||||
txData: RelaySwapStep['items'][0]['data']
|
||||
): Promise<unknown> {
|
||||
const key = `relay-${chain.toLowerCase()}-${Date.now()}`
|
||||
return walletPost(`/api/wallets/${chain}/sign-raw-evm-tx`, txData, true, { 'Idempotency-Key': key })
|
||||
}
|
||||
|
||||
export async function signSolTx(txData: unknown): Promise<unknown> {
|
||||
return walletPost('/api/wallets/SOL/sign-and-broadcast-tx', txData)
|
||||
}
|
||||
|
||||
export async function createWallet(): Promise<void> {
|
||||
await walletPost<unknown>('/api/wallets/create', {})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { useAllWalletBalances, usePrices, useSendWallet, useWalletAddresses, useWalletBalance, usePortfolio, useTokensList, useRelayQuote, useExecuteRelaySwap, useCreateWallet, useRevealMnemonic } from './model/useWalletData'
|
||||
export { useAllWalletBalances, usePrices, useSendWallet, useWalletAddresses, useWalletBalance, usePortfolio, useTokensList, useRelayQuote, useExecuteRelaySwap, useSignSwap, useCreateWallet, useRevealMnemonic } from './model/useWalletData'
|
||||
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry, SendWalletPayload, SendWalletResponse, WalletAddress, PortfolioData, PortfolioChain, PortfolioNative, PortfolioToken, TokenInfo, RelayQuotePayload, RelayQuoteResponse, RelaySwapResponse, RelaySwapStep } from './api/walletApi'
|
||||
export { CHAINS } from './api/walletApi'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
||||
import { getWalletBalance, getPrices, sendWallet, getWalletAddresses, getPortfolio, getTokensList, getRelayQuote, executeRelaySwap, createWallet, revealMnemonic, CHAINS, type Chain, type SendWalletPayload, type RelayQuotePayload } from '../api/walletApi'
|
||||
import { getWalletBalance, getPrices, sendWallet, getWalletAddresses, getPortfolio, getTokensList, getRelayQuote, executeRelaySwap, signRawEvmTx, signSolTx, createWallet, revealMnemonic, CHAINS, type Chain, type SendWalletPayload, type RelayQuotePayload, type RelaySwapStep } from '../api/walletApi'
|
||||
|
||||
export function useWalletBalance(chain: Chain) {
|
||||
return useQuery({
|
||||
@@ -88,3 +88,12 @@ export function useExecuteRelaySwap() {
|
||||
mutationFn: (payload: RelayQuotePayload) => executeRelaySwap(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSignSwap() {
|
||||
return useMutation({
|
||||
mutationFn: ({ chain, txData }: { chain: Chain; txData: unknown }) => {
|
||||
if (chain === 'SOL') return signSolTx(txData)
|
||||
return signRawEvmTx(chain as 'ETH' | 'BSC', txData as RelaySwapStep['items'][0]['data'])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
138
src/widgets/swap-form/ui/SwapConfirmModal.module.css
Normal file
138
src/widgets/swap-form/ui/SwapConfirmModal.module.css
Normal file
@@ -0,0 +1,138 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.tokenUsd {
|
||||
font-size: 13px;
|
||||
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;
|
||||
}
|
||||
|
||||
.impact {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
81
src/widgets/swap-form/ui/SwapConfirmModal.tsx
Normal file
81
src/widgets/swap-form/ui/SwapConfirmModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import styles from './SwapConfirmModal.module.css'
|
||||
|
||||
interface SwapData {
|
||||
details: {
|
||||
currencyIn: { amountFormatted: string; amountUsd: string; currency: { symbol: string } }
|
||||
currencyOut: { amountFormatted: string; amountUsd: string; currency: { symbol: string } }
|
||||
totalImpact: { percent: string }
|
||||
rate: string
|
||||
}
|
||||
fees: {
|
||||
gas: { amountUsd: string }
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: SwapData
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SwapConfirmModal({ data, onConfirm, onClose }: Props) {
|
||||
const { details, fees } = data
|
||||
const { currencyIn, currencyOut, totalImpact, rate } = details
|
||||
|
||||
const impactPercent = parseFloat(totalImpact.percent)
|
||||
const rateFormatted = parseFloat(rate).toFixed(4)
|
||||
|
||||
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}>
|
||||
{currencyIn.amountFormatted} {currencyIn.currency.symbol}
|
||||
</span>
|
||||
<span className={styles.tokenUsd}>≈ ${currencyIn.amountUsd}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.arrow}>↓</div>
|
||||
|
||||
<div className={styles.token}>
|
||||
<span className={styles.tokenLabel}>Получаете</span>
|
||||
<span className={styles.tokenAmount}>
|
||||
{currencyOut.amountFormatted} {currencyOut.currency.symbol}
|
||||
</span>
|
||||
<span className={styles.tokenUsd}>≈ ${currencyOut.amountUsd}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>Курс</span>
|
||||
<span className={styles.rowValue}>
|
||||
1 {currencyIn.currency.symbol} = {rateFormatted} {currencyOut.currency.symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>Комиссия сети</span>
|
||||
<span className={styles.rowValue}>${fees.gas.amountUsd}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>Влияние на цену</span>
|
||||
<span className={`${styles.rowValue} ${impactPercent < 0 ? styles.impact : ''}`}>
|
||||
{totalImpact.percent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={styles.confirmBtn} onClick={onConfirm}>
|
||||
Подтвердить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PrimaryButton } from '@shared/ui'
|
||||
import { useWalletBalance, useWalletAddresses, useTokensList, useRelayQuote, useExecuteRelaySwap, type Chain } from '@features/wallet'
|
||||
import { useWalletBalance, useWalletAddresses, useTokensList, useRelayQuote, useExecuteRelaySwap, useSignSwap, type Chain, type RelaySwapResponse } from '@features/wallet'
|
||||
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 styles from './SwapForm.module.css'
|
||||
|
||||
|
||||
@@ -27,6 +28,7 @@ export function SwapForm() {
|
||||
} = useSwapForm()
|
||||
|
||||
const [fromNetwork, setFromNetwork] = useState('ETH')
|
||||
const [modalData, setModalData] = useState<RelaySwapResponse | null>(null)
|
||||
|
||||
const { data: walletData } = useWalletBalance(fromNetwork as Chain)
|
||||
const tokenOptions = walletData ? buildTokensFromBalance(walletData) : TOKENS_LIST
|
||||
@@ -62,6 +64,7 @@ export function SwapForm() {
|
||||
|
||||
const { data: quoteData } = useRelayQuote(quotePayload)
|
||||
const { mutate: executeSwap, isPending: isSwapping } = useExecuteRelaySwap()
|
||||
const { mutate: signSwap } = useSignSwap()
|
||||
|
||||
const displayToAmount = quoteData?.details.currencyOut.amountFormatted ?? '0'
|
||||
const displayToUsd = quoteData?.details.currencyOut.amountUsd
|
||||
@@ -69,7 +72,9 @@ export function SwapForm() {
|
||||
|
||||
function handleSwap() {
|
||||
if (!quotePayload) return
|
||||
executeSwap(quotePayload)
|
||||
executeSwap(quotePayload, {
|
||||
onSuccess: (data) => setModalData(data),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -101,6 +106,18 @@ export function SwapForm() {
|
||||
<SwapInfoPanel gasFee={gasFee} />
|
||||
|
||||
<PrimaryButton onClick={handleSwap} disabled={!quotePayload || isSwapping} />
|
||||
|
||||
{modalData && (
|
||||
<SwapConfirmModal
|
||||
data={modalData}
|
||||
onClose={() => setModalData(null)}
|
||||
onConfirm={() => {
|
||||
const txData = modalData.steps[0]?.items[0]?.data
|
||||
if (txData) signSwap({ chain: fromNetwork as Chain, txData })
|
||||
setModalData(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user