diff --git a/src/features/wallet/api/walletApi.ts b/src/features/wallet/api/walletApi.ts index f5a37ab..1bfed26 100644 --- a/src/features/wallet/api/walletApi.ts +++ b/src/features/wallet/api/walletApi.ts @@ -96,7 +96,12 @@ async function walletGet(path: string, allowRetry: boolean = true): Promise(path: string, body: unknown, allowRetry: boolean = true): Promise { +async function walletPost( + path: string, + body: unknown, + allowRetry: boolean = true, + extraHeaders: Record = {} +): Promise { const csrf = await getCsrfToken() const bearer = tokenStore.get() @@ -107,6 +112,7 @@ async function walletPost(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(path: string, body: unknown, allowRetry: boolean = if (res.status === 401 && allowRetry) { try { await refreshAccessToken() - return walletPost(path, body, false) + return walletPost(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('/api/relay/execute/swap', payload) } +export async function signRawEvmTx( + chain: 'ETH' | 'BSC', + txData: RelaySwapStep['items'][0]['data'] +): Promise { + 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 { + return walletPost('/api/wallets/SOL/sign-and-broadcast-tx', txData) +} + export async function createWallet(): Promise { await walletPost('/api/wallets/create', {}) } diff --git a/src/features/wallet/index.ts b/src/features/wallet/index.ts index 603371e..666832b 100644 --- a/src/features/wallet/index.ts +++ b/src/features/wallet/index.ts @@ -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' diff --git a/src/features/wallet/model/useWalletData.ts b/src/features/wallet/model/useWalletData.ts index 38f7016..9ba2e7e 100644 --- a/src/features/wallet/model/useWalletData.ts +++ b/src/features/wallet/model/useWalletData.ts @@ -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']) + }, + }) +} diff --git a/src/widgets/swap-form/ui/SwapConfirmModal.module.css b/src/widgets/swap-form/ui/SwapConfirmModal.module.css new file mode 100644 index 0000000..6968432 --- /dev/null +++ b/src/widgets/swap-form/ui/SwapConfirmModal.module.css @@ -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); +} diff --git a/src/widgets/swap-form/ui/SwapConfirmModal.tsx b/src/widgets/swap-form/ui/SwapConfirmModal.tsx new file mode 100644 index 0000000..c3328c5 --- /dev/null +++ b/src/widgets/swap-form/ui/SwapConfirmModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+ Подтвердить своп + +
+ +
+
+ Отдаёте + + {currencyIn.amountFormatted} {currencyIn.currency.symbol} + + ≈ ${currencyIn.amountUsd} +
+ +
+ +
+ Получаете + + {currencyOut.amountFormatted} {currencyOut.currency.symbol} + + ≈ ${currencyOut.amountUsd} +
+
+ +
+
+ Курс + + 1 {currencyIn.currency.symbol} = {rateFormatted} {currencyOut.currency.symbol} + +
+
+ Комиссия сети + ${fees.gas.amountUsd} +
+
+ Влияние на цену + + {totalImpact.percent}% + +
+
+ + +
+
+ ) +} diff --git a/src/widgets/swap-form/ui/SwapForm.tsx b/src/widgets/swap-form/ui/SwapForm.tsx index 77e7c89..22ced64 100644 --- a/src/widgets/swap-form/ui/SwapForm.tsx +++ b/src/widgets/swap-form/ui/SwapForm.tsx @@ -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(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() { + + {modalData && ( + setModalData(null)} + onConfirm={() => { + const txData = modalData.steps[0]?.items[0]?.data + if (txData) signSwap({ chain: fromNetwork as Chain, txData }) + setModalData(null) + }} + /> + )} ) }