diff --git a/shared/lib/priceOracle.ts b/shared/lib/priceOracle.ts new file mode 100644 index 00000000..f3f3f301 --- /dev/null +++ b/shared/lib/priceOracle.ts @@ -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 = { + 'wDOT': 'polkadot', + 'wETH': 'ethereum', + 'wBTC': 'bitcoin', + 'wUSDT': 'tether', + 'USDT': 'tether', +}; + +// Manual prices for tokens not on CoinGecko (in USD) +export const MANUAL_PRICES: Record = { + 'HEZ': 1.0, // Set your HEZ price + 'PEZ': 0.10, // Set your PEZ price + 'wHEZ': 1.0, +}; + +// Price cache +interface PriceCache { + prices: Record; + timestamp: number; +} + +let priceCache: PriceCache | null = null; +const CACHE_TTL = 60 * 1000; // 1 minute cache + +/** + * Fetch prices from CoinGecko + */ +export async function fetchCoinGeckoPrices(): Promise> { + 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 = {}; + + 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> { + // Check cache + if (priceCache && Date.now() - priceCache.timestamp < CACHE_TTL) { + return priceCache.prices; + } + + const coinGeckoPrices = await fetchCoinGeckoPrices(); + const prices: Record = { + ...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 { + 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)}`; + } +} diff --git a/web/src/components/dex/SwapInterface.tsx b/web/src/components/dex/SwapInterface.tsx index 13cacd84..92d83b7f 100644 --- a/web/src/components/dex/SwapInterface.tsx +++ b/web/src/components/dex/SwapInterface.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; 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 { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -12,10 +12,9 @@ import { PoolInfo } from '@/types/dex'; import { parseTokenInput, formatTokenBalance, - getAmountOut, - calculatePriceImpact, formatAssetLocation, } from '@pezkuwi/utils/dex'; +import { getAllPrices, calculateOracleSwap, formatUsdPrice } from '@pezkuwi/lib/priceOracle'; import { useToast } from '@/hooks/use-toast'; interface SwapInterfaceProps { @@ -25,12 +24,13 @@ interface SwapInterfaceProps { type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error'; -// User-facing tokens -// Native HEZ uses NATIVE_TOKEN_ID (-1) for pool matching +// User-facing tokens - All pairs go through USDT const USER_TOKENS = [ - { symbol: 'HEZ', emoji: '🟡', assetId: -1, name: 'HEZ', decimals: 12, displaySymbol: 'HEZ' }, // Native HEZ (NATIVE_TOKEN_ID) - { symbol: 'PEZ', emoji: '🟣', assetId: 1, name: 'PEZ', decimals: 12, displaySymbol: 'PEZ' }, - { symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', decimals: 6, displaySymbol: 'USDT' }, + { symbol: 'HEZ', emoji: '🟡', assetId: -1, name: 'HEZ', decimals: 12, displaySymbol: 'HEZ', logo: '/shared/images/hez_token_512.png' }, + { symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', decimals: 6, displaySymbol: 'USDT', logo: '/shared/images/USDT(hez)logo.png' }, + { 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; export const SwapInterface: React.FC = ({ pools }) => { @@ -53,6 +53,30 @@ export const SwapInterface: React.FC = ({ pools }) => { const [txStatus, setTxStatus] = useState('idle'); const [errorMessage, setErrorMessage] = useState(''); + // Oracle prices state + const [prices, setPrices] = useState>({}); + const [pricesLoading, setPricesLoading] = useState(true); + const [swapRoute, setSwapRoute] = useState([]); + + // 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) const getAssetId = (symbol: string) => { const token = USER_TOKENS.find(t => t.symbol === symbol); @@ -122,48 +146,51 @@ export const SwapInterface: React.FC = ({ pools }) => { fetchBalances(); }, [assetHubApi, isAssetHubReady, account, fromToken, toToken, fromAssetId, toAssetId]); - // Calculate output amount when input changes + // Calculate output amount using Oracle prices useEffect(() => { - if (!fromAmount || !activePool || !fromTokenInfo || !toTokenInfo) { - setToAmount(''); - return; - } + const calculateSwap = async () => { + if (!fromAmount || !fromTokenInfo || !toTokenInfo || parseFloat(fromAmount) <= 0) { + setToAmount(''); + setSwapRoute([]); + return; + } - try { - const fromAmountRaw = parseTokenInput(fromAmount, fromTokenInfo.decimals); + try { + const result = await calculateOracleSwap( + fromToken, + toToken, + parseFloat(fromAmount), + 0.3 // 0.3% fee per hop + ); - // Determine direction and calculate output - const isForward = activePool.asset1 === fromAssetId; - const reserveIn = isForward ? activePool.reserve1 : activePool.reserve2; - const reserveOut = isForward ? activePool.reserve2 : activePool.reserve1; + if (result) { + // Format output based on decimals + const formattedOutput = result.toAmount.toFixed( + 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 - const toAmountDisplay = formatTokenBalance(toAmountRaw, toTokenInfo.decimals, 6); + calculateSwap(); + }, [fromAmount, fromToken, toToken, fromTokenInfo, toTokenInfo, prices]); - setToAmount(toAmountDisplay); - } catch (error) { - if (import.meta.env.DEV) console.error('Failed to calculate output:', error); - setToAmount(''); - } - }, [fromAmount, activePool, fromTokenInfo, toTokenInfo, fromAssetId, toAssetId]); - - // 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]); + // Get oracle exchange rate + const oracleRate = React.useMemo(() => { + const fromPrice = prices[fromToken]; + const toPrice = prices[toToken]; + if (!fromPrice || !toPrice) return null; + return fromPrice / toPrice; + }, [prices, fromToken, toToken]); // Check if user has insufficient balance const hasInsufficientBalance = React.useMemo(() => { @@ -227,87 +254,49 @@ export const SwapInterface: React.FC = ({ pools }) => { 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 - // assetConversion pallet expects XCM MultiLocation format for swap paths - const nativeLocation = formatAssetLocation(-1); // { parents: 1, interior: 'Here' } - const pezLocation = formatAssetLocation(1); // PEZ asset - const usdtLocation = formatAssetLocation(1000); // wUSDT asset + // Build swap path - all pairs go through USDT + const getLocation = (symbol: string) => { + switch (symbol) { + case 'HEZ': return nativeLocation; + 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') { - // HEZ → PEZ: Direct swap using native token - tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( - [nativeLocation, pezLocation], - amountIn.toString(), - minAmountOut.toString(), - account, - true - ); + const fromLocation = getLocation(fromToken); + const toLocation = getLocation(toToken); - } else if (fromToken === 'PEZ' && toToken === 'HEZ') { - // PEZ → HEZ: Direct swap to native token - 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 - ); + // Determine swap path based on route + let swapPath: unknown[]; + if (fromToken === 'USDT' || toToken === 'USDT') { + // Direct swap with USDT + swapPath = [fromLocation, toLocation]; } else { - // Generic swap using XCM Locations - const fromLocation = formatAssetLocation(fromAssetId!); - const toLocation = formatAssetLocation(toAssetId!); - tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( - [fromLocation, toLocation], - amountIn.toString(), - minAmountOut.toString(), - account, - true - ); + // Multi-hop through USDT: X → USDT → Y + swapPath = [fromLocation, usdtLocation, toLocation]; } + 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'); await tx.signAndSend( @@ -355,12 +344,8 @@ export const SwapInterface: React.FC = ({ pools }) => { } }; - const exchangeRate = activePool && fromTokenInfo && toTokenInfo - ? ( - parseFloat(formatTokenBalance(activePool.reserve2, toTokenInfo.decimals, 6)) / - parseFloat(formatTokenBalance(activePool.reserve1, fromTokenInfo.decimals, 6)) - ).toFixed(6) - : '0'; + // Exchange rate from oracle + const exchangeRate = oracleRate ? oracleRate.toFixed(6) : '0'; return (
@@ -515,38 +500,53 @@ export const SwapInterface: React.FC = ({ pools }) => {
- {/* Swap Details */} + {/* Swap Details - Oracle Prices */}
-
+
Exchange Rate + (CoinGecko) - - {activePool ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : 'No pool available'} +
+ + {oracleRate ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : 'Loading...'} + + +
+
+ + {/* USD Prices */} +
+ + {fromToken}: {prices[fromToken] ? formatUsdPrice(prices[fromToken]) : '...'} + + + {toToken}: {prices[toToken] ? formatUsdPrice(prices[toToken]) : '...'}
- {fromAmount && parseFloat(fromAmount) > 0 && priceImpact > 0 && ( + {/* Route */} + {swapRoute.length > 0 && (
- - - Price Impact - - - {priceImpact < 0.01 ? '<0.01%' : `${priceImpact.toFixed(2)}%`} + Route + + {swapRoute.join(' → ')}
)} + {/* Fees */} +
+ Swap Fee + + {swapRoute.length > 2 ? '0.6%' : '0.3%'} + {swapRoute.length > 2 && (2 hops)} + +
+
Slippage Tolerance {slippage}% @@ -563,11 +563,11 @@ export const SwapInterface: React.FC = ({ pools }) => { )} - {priceImpact >= 5 && !hasInsufficientBalance && ( - - - - High price impact! Consider a smaller amount. + {swapRoute.length > 2 && !hasInsufficientBalance && ( + + + + This swap uses multi-hop routing ({swapRoute.join(' → ')}). Double fee applies. )} @@ -580,7 +580,8 @@ export const SwapInterface: React.FC = ({ pools }) => { !account || !fromAmount || parseFloat(fromAmount) <= 0 || - !activePool || + !oracleRate || + !toAmount || hasInsufficientBalance || txStatus === 'signing' || txStatus === 'submitting' @@ -590,8 +591,10 @@ export const SwapInterface: React.FC = ({ pools }) => { ? 'Connect Wallet' : hasInsufficientBalance ? `Insufficient ${fromToken} Balance` - : !activePool - ? 'No Pool Available' + : !oracleRate + ? 'Price Not Available' + : pricesLoading + ? 'Loading Prices...' : 'Swap Tokens'}