Реалізація DEX-інтерфейсу (обмін токенів) на сайті
DEX swap-інтерфейс — складніше, ніж здається. Крім самого обміну: отримання котирувань (on-chain або агрегатор), розрахунок price impact, slippage tolerance, deadline для транзакції, approve + swap двокроковий флоу, обробка native ETH vs WETH. Це повноцінний продуктовий компонент, який або збирається з нуля, або інтегрується через агрегатор.
Два підходи: власний DEX vs агрегатор
Власний роутер (Uniswap v2/v3 fork): ви контролюєте контракт, інтерфейс працює безпосередньо з вашим пулом. Котирування розраховуються on-chain через getAmountsOut.
Агрегатор (1inch, 0x, Paraswap): маршрутизує через найкращий шлях по всіх DEX. Підходить для продуктів, де важлива найкраща ціна, а не excluzivний пул. API повертає готові transaction data.
Розбираємо обидва сценарії.
Варіант 1: прямий Uniswap v2 роутер
// lib/swap.ts
import { createPublicClient, http, parseAbi, formatUnits, parseUnits } from 'viem';
const ROUTER_ABI = parseAbi([
'function getAmountsOut(uint256 amountIn, address[] path) view returns (uint256[])',
'function swapExactTokensForTokens(uint256,uint256,address[],address,uint256) returns (uint256[])',
'function swapExactETHForTokens(uint256,address[],address,uint256) payable returns (uint256[])',
'function swapExactTokensForETH(uint256,uint256,address[],address,uint256) returns (uint256[])',
]);
const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as const;
export async function getQuote(
tokenIn: `0x${string}` | 'ETH',
tokenOut: `0x${string}` | 'ETH',
amountIn: bigint,
decimalsIn: number,
decimalsOut: number,
): Promise<{ amountOut: bigint; path: `0x${string}`[]; priceImpact: number }> {
const client = createPublicClient({ chain: mainnet, transport: http() });
const addressIn = tokenIn === 'ETH' ? WETH : tokenIn;
const addressOut = tokenOut === 'ETH' ? WETH : tokenOut;
// Прямий маршрут
const directPath: `0x${string}`[] = [addressIn, addressOut];
// Маршрут через WETH (якщо токени не мають прямої пари)
const wethPath: `0x${string}`[] = [addressIn, WETH, addressOut];
const [directResult, wethResult] = await client.multicall({
contracts: [
{ address: ROUTER, abi: ROUTER_ABI, functionName: 'getAmountsOut', args: [amountIn, directPath] },
{ address: ROUTER, abi: ROUTER_ABI, functionName: 'getAmountsOut', args: [amountIn, wethPath] },
],
allowFailure: true,
});
const directOut = directResult.status === 'success'
? (directResult.result as bigint[])[directResult.result.length - 1]
: 0n;
const wethOut = wethResult.status === 'success'
? (wethResult.result as bigint[])[(wethResult.result as bigint[]).length - 1]
: 0n;
const bestOut = directOut >= wethOut ? directOut : wethOut;
const bestPath = directOut >= wethOut ? directPath : wethPath;
// Price impact — різниця між spot price та реальною ціною
// (спрощено через резерви пула)
const priceImpact = 0; // в реальному проекті розраховується за резервами
return { amountOut: bestOut, path: bestPath, priceImpact };
}
Хук котирування з debounce
// hooks/useQuote.ts
import { useQuery } from '@tanstack/react-query';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
export function useQuote(
tokenIn: string,
tokenOut: string,
amountIn: string,
decimalsIn: number,
decimalsOut: number,
) {
// Debounce — не запрашуємо при кожному символі
const debouncedAmount = useDebouncedValue(amountIn, 400);
const amountWei = debouncedAmount ? parseUnits(debouncedAmount, decimalsIn) : 0n;
return useQuery({
queryKey: ['quote', tokenIn, tokenOut, amountWei.toString()],
queryFn: () => getQuote(
tokenIn as `0x${string}`,
tokenOut as `0x${string}`,
amountWei,
decimalsIn,
decimalsOut,
),
enabled: amountWei > 0n,
staleTime: 15_000, // котирування застарівають через 15 секунд
refetchInterval: 15_000,
});
}
Варіант 2: котирування через 0x API
// lib/0x.ts
export async function get0xQuote(
sellToken: string, // адреса або "ETH"
buyToken: string,
sellAmount: string, // в wei
takerAddress?: string,
): Promise<{
buyAmount: string;
price: string;
guaranteedPrice: string;
to: string;
data: string;
value: string;
gas: string;
estimatedPriceImpact: string;
}> {
const params = new URLSearchParams({
sellToken,
buyToken,
sellAmount,
...(takerAddress && { takerAddress }),
affiliateAddress: process.env.NEXT_PUBLIC_FEE_RECIPIENT ?? '',
buyTokenPercentageFee: '0.005', // 0.5% протокольний збір (опціонально)
});
const res = await fetch(
`https://api.0x.org/swap/v1/quote?${params}`,
{ headers: { '0x-api-key': process.env.ZRX_API_KEY! } },
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.reason ?? 'Quote failed');
}
return res.json();
}
При використанні 0x — quote.to та quote.data передаються безпосередньо в транзакцію, без виклику конкретних функцій роутера. Це спрощує інтеграцію.
Компонент обміну
// components/SwapWidget.tsx
export function SwapWidget() {
const { address } = useAccount();
const [tokenIn, setTokenIn] = useState<Token>(ETH_TOKEN);
const [tokenOut, setTokenOut] = useState<Token>(USDC_TOKEN);
const [amountIn, setAmountIn] = useState('');
const [slippage, setSlippage] = useState(0.5); // %
const { data: quote, isLoading: quoteLoading } = useQuote(
tokenIn.address, tokenOut.address, amountIn, tokenIn.decimals, tokenOut.decimals,
);
const amountOut = quote ? formatUnits(quote.amountOut, tokenOut.decimals) : '';
// Мінімум з урахуванням slippage
const minOut = quote
? quote.amountOut - (quote.amountOut * BigInt(Math.floor(slippage * 100))) / 10000n
: 0n;
// Deadline: 20 хвилин від поточного моменту
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200);
return (
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-neutral-900 p-4">
<div className="space-y-2">
<TokenInput
label="Відправляєте"
token={tokenIn}
value={amountIn}
onChange={setAmountIn}
onTokenChange={setTokenIn}
balance={walletBalanceIn}
showMax
/>
<SwapDirectionButton onClick={() => {
setTokenIn(tokenOut);
setTokenOut(tokenIn);
setAmountIn(amountOut);
}} />
<TokenInput
label="Отримуєте"
token={tokenOut}
value={quoteLoading ? '...' : amountOut}
onTokenChange={setTokenOut}
readOnly
/>
</div>
{quote && (
<div className="mt-4 space-y-1.5 rounded-xl bg-neutral-800/50 p-3 text-sm">
<Row label="Курс" value={`1 ${tokenIn.symbol} = ${(parseFloat(amountOut) / parseFloat(amountIn)).toFixed(6)} ${tokenOut.symbol}`} />
<Row label="Price Impact" value={`${quote.priceImpact.toFixed(2)}%`} warn={quote.priceImpact > 2} />
<Row label="Мін. отримаєте" value={`${formatUnits(minOut, tokenOut.decimals)} ${tokenOut.symbol}`} />
<Row label="Slippage" value={`${slippage}%`} />
</div>
)}
<SwapButton
quote={quote}
tokenIn={tokenIn}
amountIn={parseUnits(amountIn || '0', tokenIn.decimals)}
minOut={minOut}
deadline={deadline}
path={quote?.path}
className="mt-4 w-full"
/>
<SlippageSettings value={slippage} onChange={setSlippage} className="mt-3" />
</div>
);
}
Обробка price impact
function PriceImpactWarning({ impact }: { impact: number }) {
if (impact < 1) return null;
if (impact < 3) return (
<p className="text-sm text-yellow-400">⚠ Price impact {impact.toFixed(2)}% — вище від звичайного</p>
);
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
Price impact {impact.toFixed(2)}% — високий ризик втрат. Зменшіть суму обміну або виберіть інший маршрут.
</div>
);
}
Token selector з пошуком
// Список токенів — з Uniswap token list або власного
const TOKEN_LIST_URL = 'https://gateway.ipfs.io/ipns/tokens.uniswap.org';
export function TokenSelector({ onSelect }: { onSelect: (token: Token) => void }) {
const [search, setSearch] = useState('');
const { data: tokenList } = useQuery({
queryKey: ['tokenList'],
queryFn: () => fetch(TOKEN_LIST_URL).then(r => r.json()).then(d => d.tokens),
staleTime: Infinity,
});
const filtered = tokenList?.filter(t =>
t.chainId === 1 &&
(t.symbol.toLowerCase().includes(search.toLowerCase()) ||
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.address.toLowerCase() === search.toLowerCase()),
) ?? [];
return (
<div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Назва, символ або адреса"
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2"
/>
<VirtualList items={filtered} renderItem={token => (
<TokenRow token={token} onClick={() => onSelect(token)} />
)} />
</div>
);
}
Строки: swap-віджет поверх Uniswap v2 роутера з котируваннями, slippage, approve та базовою обробкою помилок — 5–7 днів. Повнофункціональний DEX з агрегатором (0x/1inch), token selector з Uniswap token list, налаштуваннями slippage/deadline, історією транзакцій та підтримкою декількох мереж — 2–3 тижні.







