mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-13 09:01:00 +00:00
feat: implement hybrid oracle AMM with CoinGecko prices
- Add priceOracle service for fetching CoinGecko prices - Update SwapInterface to use oracle prices instead of pool reserves - All swaps route through USDT as base currency - Multi-hop routing for non-USDT pairs (X → USDT → Y) - Display real-time USD prices from CoinGecko - Auto-refresh prices every 30 seconds
This commit is contained in:
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Price Oracle Service - Fetches prices from CoinGecko
|
||||||
|
* USDT-based Hybrid Oracle AMM
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COINGECKO_API = 'https://api.coingecko.com/api/v3';
|
||||||
|
|
||||||
|
// CoinGecko ID mappings
|
||||||
|
export const COINGECKO_IDS: Record<string, string> = {
|
||||||
|
'wDOT': 'polkadot',
|
||||||
|
'wETH': 'ethereum',
|
||||||
|
'wBTC': 'bitcoin',
|
||||||
|
'wUSDT': 'tether',
|
||||||
|
'USDT': 'tether',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manual prices for tokens not on CoinGecko (in USD)
|
||||||
|
export const MANUAL_PRICES: Record<string, number> = {
|
||||||
|
'HEZ': 1.0, // Set your HEZ price
|
||||||
|
'PEZ': 0.10, // Set your PEZ price
|
||||||
|
'wHEZ': 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Price cache
|
||||||
|
interface PriceCache {
|
||||||
|
prices: Record<string, number>;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let priceCache: PriceCache | null = null;
|
||||||
|
const CACHE_TTL = 60 * 1000; // 1 minute cache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch prices from CoinGecko
|
||||||
|
*/
|
||||||
|
export async function fetchCoinGeckoPrices(): Promise<Record<string, number>> {
|
||||||
|
try {
|
||||||
|
const ids = [...new Set(Object.values(COINGECKO_IDS))].join(',');
|
||||||
|
const response = await fetch(
|
||||||
|
`${COINGECKO_API}/simple/price?ids=${ids}&vs_currencies=usd`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`CoinGecko API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const prices: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const [symbol, cgId] of Object.entries(COINGECKO_IDS)) {
|
||||||
|
if (data[cgId]?.usd) {
|
||||||
|
prices[symbol] = data[cgId].usd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CoinGecko fetch failed:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all token prices (CoinGecko + manual)
|
||||||
|
*/
|
||||||
|
export async function getAllPrices(): Promise<Record<string, number>> {
|
||||||
|
// Check cache
|
||||||
|
if (priceCache && Date.now() - priceCache.timestamp < CACHE_TTL) {
|
||||||
|
return priceCache.prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coinGeckoPrices = await fetchCoinGeckoPrices();
|
||||||
|
const prices: Record<string, number> = {
|
||||||
|
...MANUAL_PRICES,
|
||||||
|
...coinGeckoPrices,
|
||||||
|
};
|
||||||
|
|
||||||
|
// USDT is always $1
|
||||||
|
prices['USDT'] = 1;
|
||||||
|
prices['wUSDT'] = 1;
|
||||||
|
|
||||||
|
priceCache = { prices, timestamp: Date.now() };
|
||||||
|
return prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate swap using oracle prices
|
||||||
|
* All swaps go through USDT as base currency
|
||||||
|
*/
|
||||||
|
export async function calculateOracleSwap(
|
||||||
|
fromSymbol: string,
|
||||||
|
toSymbol: string,
|
||||||
|
fromAmount: number,
|
||||||
|
feePercent: number = 0.3
|
||||||
|
): Promise<{
|
||||||
|
toAmount: number;
|
||||||
|
rate: number;
|
||||||
|
fromPriceUsd: number;
|
||||||
|
toPriceUsd: number;
|
||||||
|
route: string[];
|
||||||
|
} | null> {
|
||||||
|
const prices = await getAllPrices();
|
||||||
|
|
||||||
|
const fromPrice = prices[fromSymbol];
|
||||||
|
const toPrice = prices[toSymbol];
|
||||||
|
|
||||||
|
if (!fromPrice || !toPrice) {
|
||||||
|
console.warn(`Price not found: ${fromSymbol}=$${fromPrice}, ${toSymbol}=$${toPrice}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate rate and output
|
||||||
|
const rate = fromPrice / toPrice;
|
||||||
|
const feeMultiplier = 1 - (feePercent / 100);
|
||||||
|
|
||||||
|
// Determine route
|
||||||
|
let route: string[];
|
||||||
|
let totalFee = feePercent;
|
||||||
|
|
||||||
|
if (fromSymbol === 'USDT' || fromSymbol === 'wUSDT') {
|
||||||
|
// Direct: USDT → X
|
||||||
|
route = [fromSymbol, toSymbol];
|
||||||
|
} else if (toSymbol === 'USDT' || toSymbol === 'wUSDT') {
|
||||||
|
// Direct: X → USDT
|
||||||
|
route = [fromSymbol, toSymbol];
|
||||||
|
} else {
|
||||||
|
// Multi-hop: X → USDT → Y (double fee)
|
||||||
|
route = [fromSymbol, 'USDT', toSymbol];
|
||||||
|
totalFee = feePercent * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualFeeMultiplier = 1 - (totalFee / 100);
|
||||||
|
const toAmount = fromAmount * rate * actualFeeMultiplier;
|
||||||
|
|
||||||
|
return {
|
||||||
|
toAmount,
|
||||||
|
rate,
|
||||||
|
fromPriceUsd: fromPrice,
|
||||||
|
toPriceUsd: toPrice,
|
||||||
|
route,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exchange rate between two tokens
|
||||||
|
*/
|
||||||
|
export async function getExchangeRate(
|
||||||
|
fromSymbol: string,
|
||||||
|
toSymbol: string
|
||||||
|
): Promise<number | null> {
|
||||||
|
const prices = await getAllPrices();
|
||||||
|
const fromPrice = prices[fromSymbol];
|
||||||
|
const toPrice = prices[toSymbol];
|
||||||
|
|
||||||
|
if (!fromPrice || !toPrice) return null;
|
||||||
|
return fromPrice / toPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format USD price for display
|
||||||
|
*/
|
||||||
|
export function formatUsdPrice(price: number): string {
|
||||||
|
if (price >= 1000) {
|
||||||
|
return `$${price.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
||||||
|
} else if (price >= 1) {
|
||||||
|
return `$${price.toFixed(2)}`;
|
||||||
|
} else {
|
||||||
|
return `$${price.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||||
import { useWallet } from '@/contexts/WalletContext';
|
import { useWallet } from '@/contexts/WalletContext';
|
||||||
import { ArrowDownUp, AlertCircle, Loader2, Info, Settings, AlertTriangle } from 'lucide-react';
|
import { ArrowDownUp, AlertCircle, Loader2, Info, Settings, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -12,10 +12,9 @@ import { PoolInfo } from '@/types/dex';
|
|||||||
import {
|
import {
|
||||||
parseTokenInput,
|
parseTokenInput,
|
||||||
formatTokenBalance,
|
formatTokenBalance,
|
||||||
getAmountOut,
|
|
||||||
calculatePriceImpact,
|
|
||||||
formatAssetLocation,
|
formatAssetLocation,
|
||||||
} from '@pezkuwi/utils/dex';
|
} from '@pezkuwi/utils/dex';
|
||||||
|
import { getAllPrices, calculateOracleSwap, formatUsdPrice } from '@pezkuwi/lib/priceOracle';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
interface SwapInterfaceProps {
|
interface SwapInterfaceProps {
|
||||||
@@ -25,12 +24,13 @@ interface SwapInterfaceProps {
|
|||||||
|
|
||||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||||
|
|
||||||
// User-facing tokens
|
// User-facing tokens - All pairs go through USDT
|
||||||
// Native HEZ uses NATIVE_TOKEN_ID (-1) for pool matching
|
|
||||||
const USER_TOKENS = [
|
const USER_TOKENS = [
|
||||||
{ symbol: 'HEZ', emoji: '🟡', assetId: -1, name: 'HEZ', decimals: 12, displaySymbol: 'HEZ' }, // Native HEZ (NATIVE_TOKEN_ID)
|
{ symbol: 'HEZ', emoji: '🟡', assetId: -1, name: 'HEZ', decimals: 12, displaySymbol: 'HEZ', logo: '/shared/images/hez_token_512.png' },
|
||||||
{ symbol: 'PEZ', emoji: '🟣', assetId: 1, name: 'PEZ', decimals: 12, displaySymbol: 'PEZ' },
|
{ symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', decimals: 6, displaySymbol: 'USDT', logo: '/shared/images/USDT(hez)logo.png' },
|
||||||
{ symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', decimals: 6, displaySymbol: 'USDT' },
|
{ symbol: 'wDOT', emoji: '🔴', assetId: 1001, name: 'Wrapped DOT', decimals: 10, displaySymbol: 'wDOT', logo: '/shared/images/dot.png' },
|
||||||
|
{ symbol: 'wETH', emoji: '💎', assetId: 1002, name: 'Wrapped ETH', decimals: 18, displaySymbol: 'wETH', logo: '/shared/images/etherium.png' },
|
||||||
|
{ symbol: 'wBTC', emoji: '🟠', assetId: 1003, name: 'Wrapped BTC', decimals: 8, displaySymbol: 'wBTC', logo: '/shared/images/bitcoin.png' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
||||||
@@ -53,6 +53,30 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
|||||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
|
||||||
|
// Oracle prices state
|
||||||
|
const [prices, setPrices] = useState<Record<string, number>>({});
|
||||||
|
const [pricesLoading, setPricesLoading] = useState(true);
|
||||||
|
const [swapRoute, setSwapRoute] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch oracle prices
|
||||||
|
const fetchPrices = useCallback(async () => {
|
||||||
|
setPricesLoading(true);
|
||||||
|
try {
|
||||||
|
const fetchedPrices = await getAllPrices();
|
||||||
|
setPrices(fetchedPrices);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch prices:', error);
|
||||||
|
}
|
||||||
|
setPricesLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPrices();
|
||||||
|
// Refresh prices every 30 seconds
|
||||||
|
const interval = setInterval(fetchPrices, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchPrices]);
|
||||||
|
|
||||||
// Get asset IDs (for pool lookup)
|
// Get asset IDs (for pool lookup)
|
||||||
const getAssetId = (symbol: string) => {
|
const getAssetId = (symbol: string) => {
|
||||||
const token = USER_TOKENS.find(t => t.symbol === symbol);
|
const token = USER_TOKENS.find(t => t.symbol === symbol);
|
||||||
@@ -122,48 +146,51 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
|||||||
fetchBalances();
|
fetchBalances();
|
||||||
}, [assetHubApi, isAssetHubReady, account, fromToken, toToken, fromAssetId, toAssetId]);
|
}, [assetHubApi, isAssetHubReady, account, fromToken, toToken, fromAssetId, toAssetId]);
|
||||||
|
|
||||||
// Calculate output amount when input changes
|
// Calculate output amount using Oracle prices
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fromAmount || !activePool || !fromTokenInfo || !toTokenInfo) {
|
const calculateSwap = async () => {
|
||||||
setToAmount('');
|
if (!fromAmount || !fromTokenInfo || !toTokenInfo || parseFloat(fromAmount) <= 0) {
|
||||||
return;
|
setToAmount('');
|
||||||
}
|
setSwapRoute([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fromAmountRaw = parseTokenInput(fromAmount, fromTokenInfo.decimals);
|
const result = await calculateOracleSwap(
|
||||||
|
fromToken,
|
||||||
|
toToken,
|
||||||
|
parseFloat(fromAmount),
|
||||||
|
0.3 // 0.3% fee per hop
|
||||||
|
);
|
||||||
|
|
||||||
// Determine direction and calculate output
|
if (result) {
|
||||||
const isForward = activePool.asset1 === fromAssetId;
|
// Format output based on decimals
|
||||||
const reserveIn = isForward ? activePool.reserve1 : activePool.reserve2;
|
const formattedOutput = result.toAmount.toFixed(
|
||||||
const reserveOut = isForward ? activePool.reserve2 : activePool.reserve1;
|
toTokenInfo.decimals > 6 ? 6 : toTokenInfo.decimals
|
||||||
|
);
|
||||||
|
setToAmount(formattedOutput);
|
||||||
|
setSwapRoute(result.route);
|
||||||
|
} else {
|
||||||
|
setToAmount('');
|
||||||
|
setSwapRoute([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (import.meta.env.DEV) console.error('Failed to calculate swap:', error);
|
||||||
|
setToAmount('');
|
||||||
|
setSwapRoute([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toAmountRaw = getAmountOut(fromAmountRaw, reserveIn, reserveOut, 3); // 0.3% fee
|
calculateSwap();
|
||||||
const toAmountDisplay = formatTokenBalance(toAmountRaw, toTokenInfo.decimals, 6);
|
}, [fromAmount, fromToken, toToken, fromTokenInfo, toTokenInfo, prices]);
|
||||||
|
|
||||||
setToAmount(toAmountDisplay);
|
// Get oracle exchange rate
|
||||||
} catch (error) {
|
const oracleRate = React.useMemo(() => {
|
||||||
if (import.meta.env.DEV) console.error('Failed to calculate output:', error);
|
const fromPrice = prices[fromToken];
|
||||||
setToAmount('');
|
const toPrice = prices[toToken];
|
||||||
}
|
if (!fromPrice || !toPrice) return null;
|
||||||
}, [fromAmount, activePool, fromTokenInfo, toTokenInfo, fromAssetId, toAssetId]);
|
return fromPrice / toPrice;
|
||||||
|
}, [prices, fromToken, toToken]);
|
||||||
// Calculate price impact
|
|
||||||
const priceImpact = React.useMemo(() => {
|
|
||||||
if (!fromAmount || !activePool || !fromAssetId || !toAssetId || !fromTokenInfo) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fromAmountRaw = parseTokenInput(fromAmount, fromTokenInfo.decimals);
|
|
||||||
const isForward = activePool.asset1 === fromAssetId;
|
|
||||||
const reserveIn = isForward ? activePool.reserve1 : activePool.reserve2;
|
|
||||||
const reserveOut = isForward ? activePool.reserve2 : activePool.reserve1;
|
|
||||||
|
|
||||||
return parseFloat(calculatePriceImpact(reserveIn, reserveOut, fromAmountRaw));
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}, [fromAmount, activePool, fromAssetId, toAssetId, fromTokenInfo]);
|
|
||||||
|
|
||||||
// Check if user has insufficient balance
|
// Check if user has insufficient balance
|
||||||
const hasInsufficientBalance = React.useMemo(() => {
|
const hasInsufficientBalance = React.useMemo(() => {
|
||||||
@@ -227,87 +254,49 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
|||||||
minOut: minAmountOut.toString(),
|
minOut: minAmountOut.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx;
|
// XCM Locations for all supported tokens
|
||||||
|
const nativeLocation = formatAssetLocation(-1); // HEZ (native)
|
||||||
|
const usdtLocation = formatAssetLocation(1000); // wUSDT
|
||||||
|
const wdotLocation = formatAssetLocation(1001); // wDOT
|
||||||
|
const wethLocation = formatAssetLocation(1002); // wETH
|
||||||
|
const wbtcLocation = formatAssetLocation(1003); // wBTC
|
||||||
|
|
||||||
// Native HEZ uses NATIVE_TOKEN_ID (-1) for XCM Location
|
// Build swap path - all pairs go through USDT
|
||||||
// assetConversion pallet expects XCM MultiLocation format for swap paths
|
const getLocation = (symbol: string) => {
|
||||||
const nativeLocation = formatAssetLocation(-1); // { parents: 1, interior: 'Here' }
|
switch (symbol) {
|
||||||
const pezLocation = formatAssetLocation(1); // PEZ asset
|
case 'HEZ': return nativeLocation;
|
||||||
const usdtLocation = formatAssetLocation(1000); // wUSDT asset
|
case 'USDT': return usdtLocation;
|
||||||
|
case 'wDOT': return wdotLocation;
|
||||||
|
case 'wETH': return wethLocation;
|
||||||
|
case 'wBTC': return wbtcLocation;
|
||||||
|
default: return formatAssetLocation(fromAssetId!);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (fromToken === 'HEZ' && toToken === 'PEZ') {
|
const fromLocation = getLocation(fromToken);
|
||||||
// HEZ → PEZ: Direct swap using native token
|
const toLocation = getLocation(toToken);
|
||||||
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
||||||
[nativeLocation, pezLocation],
|
|
||||||
amountIn.toString(),
|
|
||||||
minAmountOut.toString(),
|
|
||||||
account,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
} else if (fromToken === 'PEZ' && toToken === 'HEZ') {
|
// Determine swap path based on route
|
||||||
// PEZ → HEZ: Direct swap to native token
|
let swapPath: unknown[];
|
||||||
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
||||||
[pezLocation, nativeLocation],
|
|
||||||
amountIn.toString(),
|
|
||||||
minAmountOut.toString(),
|
|
||||||
account,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
} else if (fromToken === 'HEZ' && toToken === 'USDT') {
|
|
||||||
// HEZ → USDT: Direct swap using native token
|
|
||||||
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
||||||
[nativeLocation, usdtLocation],
|
|
||||||
amountIn.toString(),
|
|
||||||
minAmountOut.toString(),
|
|
||||||
account,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
} else if (fromToken === 'USDT' && toToken === 'HEZ') {
|
|
||||||
// USDT → HEZ: Direct swap to native token
|
|
||||||
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
||||||
[usdtLocation, nativeLocation],
|
|
||||||
amountIn.toString(),
|
|
||||||
minAmountOut.toString(),
|
|
||||||
account,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
} else if (fromToken === 'PEZ' && toToken === 'USDT') {
|
|
||||||
// PEZ → USDT: Multi-hop through HEZ (PEZ → HEZ → USDT)
|
|
||||||
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
||||||
[pezLocation, nativeLocation, usdtLocation],
|
|
||||||
amountIn.toString(),
|
|
||||||
minAmountOut.toString(),
|
|
||||||
account,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
} else if (fromToken === 'USDT' && toToken === 'PEZ') {
|
|
||||||
// USDT → PEZ: Multi-hop through HEZ (USDT → HEZ → PEZ)
|
|
||||||
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
||||||
[usdtLocation, nativeLocation, pezLocation],
|
|
||||||
amountIn.toString(),
|
|
||||||
minAmountOut.toString(),
|
|
||||||
account,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (fromToken === 'USDT' || toToken === 'USDT') {
|
||||||
|
// Direct swap with USDT
|
||||||
|
swapPath = [fromLocation, toLocation];
|
||||||
} else {
|
} else {
|
||||||
// Generic swap using XCM Locations
|
// Multi-hop through USDT: X → USDT → Y
|
||||||
const fromLocation = formatAssetLocation(fromAssetId!);
|
swapPath = [fromLocation, usdtLocation, toLocation];
|
||||||
const toLocation = formatAssetLocation(toAssetId!);
|
|
||||||
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
||||||
[fromLocation, toLocation],
|
|
||||||
amountIn.toString(),
|
|
||||||
minAmountOut.toString(),
|
|
||||||
account,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) console.log('Swap path:', swapRoute, swapPath);
|
||||||
|
|
||||||
|
const tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
||||||
|
swapPath,
|
||||||
|
amountIn.toString(),
|
||||||
|
minAmountOut.toString(),
|
||||||
|
account,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
setTxStatus('submitting');
|
setTxStatus('submitting');
|
||||||
|
|
||||||
await tx.signAndSend(
|
await tx.signAndSend(
|
||||||
@@ -355,12 +344,8 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const exchangeRate = activePool && fromTokenInfo && toTokenInfo
|
// Exchange rate from oracle
|
||||||
? (
|
const exchangeRate = oracleRate ? oracleRate.toFixed(6) : '0';
|
||||||
parseFloat(formatTokenBalance(activePool.reserve2, toTokenInfo.decimals, 6)) /
|
|
||||||
parseFloat(formatTokenBalance(activePool.reserve1, fromTokenInfo.decimals, 6))
|
|
||||||
).toFixed(6)
|
|
||||||
: '0';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="max-w-lg mx-auto">
|
||||||
@@ -515,38 +500,53 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Swap Details */}
|
{/* Swap Details - Oracle Prices */}
|
||||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-2 text-sm">
|
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-400 flex items-center gap-1">
|
<span className="text-gray-400 flex items-center gap-1">
|
||||||
<Info className="w-3 h-3" />
|
<Info className="w-3 h-3" />
|
||||||
Exchange Rate
|
Exchange Rate
|
||||||
|
<span className="text-xs text-green-500">(CoinGecko)</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-white">
|
<div className="flex items-center gap-2">
|
||||||
{activePool ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : 'No pool available'}
|
<span className="text-white">
|
||||||
|
{oracleRate ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : 'Loading...'}
|
||||||
|
</span>
|
||||||
|
<button onClick={fetchPrices} className="text-gray-400 hover:text-white">
|
||||||
|
<RefreshCw className={`w-3 h-3 ${pricesLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* USD Prices */}
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{fromToken}: {prices[fromToken] ? formatUsdPrice(prices[fromToken]) : '...'}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{toToken}: {prices[toToken] ? formatUsdPrice(prices[toToken]) : '...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fromAmount && parseFloat(fromAmount) > 0 && priceImpact > 0 && (
|
{/* Route */}
|
||||||
|
{swapRoute.length > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-400 flex items-center gap-1">
|
<span className="text-gray-400">Route</span>
|
||||||
<AlertTriangle className={`w-3 h-3 ${
|
<span className="text-purple-400 text-xs">
|
||||||
priceImpact < 1 ? 'text-green-500' :
|
{swapRoute.join(' → ')}
|
||||||
priceImpact < 5 ? 'text-yellow-500' :
|
|
||||||
'text-red-500'
|
|
||||||
}`} />
|
|
||||||
Price Impact
|
|
||||||
</span>
|
|
||||||
<span className={`font-semibold ${
|
|
||||||
priceImpact < 1 ? 'text-green-400' :
|
|
||||||
priceImpact < 5 ? 'text-yellow-400' :
|
|
||||||
'text-red-400'
|
|
||||||
}`}>
|
|
||||||
{priceImpact < 0.01 ? '<0.01%' : `${priceImpact.toFixed(2)}%`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fees */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Swap Fee</span>
|
||||||
|
<span className="text-yellow-400">
|
||||||
|
{swapRoute.length > 2 ? '0.6%' : '0.3%'}
|
||||||
|
{swapRoute.length > 2 && <span className="text-xs text-gray-500 ml-1">(2 hops)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between pt-2 border-t border-gray-700">
|
<div className="flex justify-between pt-2 border-t border-gray-700">
|
||||||
<span className="text-gray-400">Slippage Tolerance</span>
|
<span className="text-gray-400">Slippage Tolerance</span>
|
||||||
<span className="text-blue-400">{slippage}%</span>
|
<span className="text-blue-400">{slippage}%</span>
|
||||||
@@ -563,11 +563,11 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{priceImpact >= 5 && !hasInsufficientBalance && (
|
{swapRoute.length > 2 && !hasInsufficientBalance && (
|
||||||
<Alert className="bg-red-900/20 border-red-500/30">
|
<Alert className="bg-yellow-900/20 border-yellow-500/30">
|
||||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
<Info className="h-4 w-4 text-yellow-500" />
|
||||||
<AlertDescription className="text-red-300 text-sm">
|
<AlertDescription className="text-yellow-300 text-sm">
|
||||||
High price impact! Consider a smaller amount.
|
This swap uses multi-hop routing ({swapRoute.join(' → ')}). Double fee applies.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -580,7 +580,8 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
|||||||
!account ||
|
!account ||
|
||||||
!fromAmount ||
|
!fromAmount ||
|
||||||
parseFloat(fromAmount) <= 0 ||
|
parseFloat(fromAmount) <= 0 ||
|
||||||
!activePool ||
|
!oracleRate ||
|
||||||
|
!toAmount ||
|
||||||
hasInsufficientBalance ||
|
hasInsufficientBalance ||
|
||||||
txStatus === 'signing' ||
|
txStatus === 'signing' ||
|
||||||
txStatus === 'submitting'
|
txStatus === 'submitting'
|
||||||
@@ -590,8 +591,10 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
|
|||||||
? 'Connect Wallet'
|
? 'Connect Wallet'
|
||||||
: hasInsufficientBalance
|
: hasInsufficientBalance
|
||||||
? `Insufficient ${fromToken} Balance`
|
? `Insufficient ${fromToken} Balance`
|
||||||
: !activePool
|
: !oracleRate
|
||||||
? 'No Pool Available'
|
? 'Price Not Available'
|
||||||
|
: pricesLoading
|
||||||
|
? 'Loading Prices...'
|
||||||
: 'Swap Tokens'}
|
: 'Swap Tokens'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user