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:
2026-02-06 10:41:52 +03:00
parent c08a3887be
commit 884d68714b
2 changed files with 326 additions and 153 deletions
+170
View File
@@ -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)}`;
}
}
+156 -153
View File
@@ -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<SwapInterfaceProps> = ({ pools }) => {
@@ -53,6 +53,30 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
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)
const getAssetId = (symbol: string) => {
const token = USER_TOKENS.find(t => t.symbol === symbol);
@@ -122,48 +146,51 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ 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<SwapInterfaceProps> = ({ 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<SwapInterfaceProps> = ({ 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 (
<div className="max-w-lg mx-auto">
@@ -515,38 +500,53 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
</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="flex justify-between">
<div className="flex justify-between items-center">
<span className="text-gray-400 flex items-center gap-1">
<Info className="w-3 h-3" />
Exchange Rate
<span className="text-xs text-green-500">(CoinGecko)</span>
</span>
<span className="text-white">
{activePool ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : 'No pool available'}
<div className="flex items-center gap-2">
<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>
</div>
{fromAmount && parseFloat(fromAmount) > 0 && priceImpact > 0 && (
{/* Route */}
{swapRoute.length > 0 && (
<div className="flex justify-between">
<span className="text-gray-400 flex items-center gap-1">
<AlertTriangle className={`w-3 h-3 ${
priceImpact < 1 ? 'text-green-500' :
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 className="text-gray-400">Route</span>
<span className="text-purple-400 text-xs">
{swapRoute.join(' → ')}
</span>
</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">
<span className="text-gray-400">Slippage Tolerance</span>
<span className="text-blue-400">{slippage}%</span>
@@ -563,11 +563,11 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
</Alert>
)}
{priceImpact >= 5 && !hasInsufficientBalance && (
<Alert className="bg-red-900/20 border-red-500/30">
<AlertTriangle className="h-4 w-4 text-red-500" />
<AlertDescription className="text-red-300 text-sm">
High price impact! Consider a smaller amount.
{swapRoute.length > 2 && !hasInsufficientBalance && (
<Alert className="bg-yellow-900/20 border-yellow-500/30">
<Info className="h-4 w-4 text-yellow-500" />
<AlertDescription className="text-yellow-300 text-sm">
This swap uses multi-hop routing ({swapRoute.join(' → ')}). Double fee applies.
</AlertDescription>
</Alert>
)}
@@ -580,7 +580,8 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
!account ||
!fromAmount ||
parseFloat(fromAmount) <= 0 ||
!activePool ||
!oracleRate ||
!toAmount ||
hasInsufficientBalance ||
txStatus === 'signing' ||
txStatus === 'submitting'
@@ -590,8 +591,10 @@ export const SwapInterface: React.FC<SwapInterfaceProps> = ({ pools }) => {
? 'Connect Wallet'
: hasInsufficientBalance
? `Insufficient ${fromToken} Balance`
: !activePool
? 'No Pool Available'
: !oracleRate
? 'Price Not Available'
: pricesLoading
? 'Loading Prices...'
: 'Swap Tokens'}
</Button>
</CardContent>