feat: Add comprehensive liquidity pool management system

- Add AddLiquidityModal with automatic HEZ to wHEZ wrapping and 10% slippage tolerance
- Add RemoveLiquidityModal with automatic wHEZ to HEZ unwrapping
- Add PoolDashboard component with metrics, APR calculation, and impermanent loss calculator
- Add /pool route and integrate Pool button in WalletDashboard
- Display real-time pool reserves, TVL, and user positions
- Support batched transactions for optimal UX

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-02 23:32:40 +03:00
parent a7b2474dda
commit f4c224dc9b
6 changed files with 1087 additions and 3 deletions
+6
View File
@@ -8,6 +8,7 @@ import PasswordReset from '@/pages/PasswordReset';
import ProfileSettings from '@/pages/ProfileSettings';
import AdminPanel from '@/pages/AdminPanel';
import WalletDashboard from './pages/WalletDashboard';
import PoolDashboardPage from './pages/PoolDashboard';
import { AppProvider } from '@/contexts/AppContext';
import { PolkadotProvider } from '@/contexts/PolkadotContext';
import { WalletProvider } from '@/contexts/WalletContext';
@@ -56,6 +57,11 @@ function App() {
<WalletDashboard />
</ProtectedRoute>
} />
<Route path="/pool" element={
<ProtectedRoute>
<PoolDashboardPage />
</ProtectedRoute>
} />
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
+321
View File
@@ -0,0 +1,321 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Info, AlertCircle } from 'lucide-react';
import { web3FromAddress } from '@polkadot/extension-dapp';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface AddLiquidityModalProps {
isOpen: boolean;
onClose: () => void;
}
export const AddLiquidityModal: React.FC<AddLiquidityModalProps> = ({ isOpen, onClose }) => {
const { api, selectedAccount, isApiReady } = usePolkadot();
const { balances, refreshBalances } = useWallet();
const [whezAmount, setWhezAmount] = useState('');
const [pezAmount, setPezAmount] = useState('');
const [currentPrice, setCurrentPrice] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Fetch current pool price
useEffect(() => {
if (!api || !isApiReady || !isOpen) return;
const fetchPoolPrice = async () => {
try {
const poolId = [0, 1];
const poolInfo = await api.query.assetConversion.pools(poolId);
if (poolInfo.isSome) {
const lpTokenData = poolInfo.unwrap().toJSON() as any;
const lpTokenId = lpTokenData.lpToken;
// Get pool account
const poolAccountData = await api.query.assetConversion.poolAccountIds?.(poolId);
let poolAccount = '';
if (poolAccountData && poolAccountData.isSome) {
poolAccount = poolAccountData.unwrap().toString();
// Get reserves
const whezBalanceData = await api.query.assets.account(0, poolAccount);
const pezBalanceData = await api.query.assets.account(1, poolAccount);
if (whezBalanceData.isSome && pezBalanceData.isSome) {
const whezData = whezBalanceData.unwrap().toJSON() as any;
const pezData = pezBalanceData.unwrap().toJSON() as any;
const reserve0 = Number(whezData.balance) / 1e12;
const reserve1 = Number(pezData.balance) / 1e12;
setCurrentPrice(reserve1 / reserve0);
}
}
}
} catch (err) {
console.error('Error fetching pool price:', err);
}
};
fetchPoolPrice();
}, [api, isApiReady, isOpen]);
// Auto-calculate PEZ amount based on wHEZ input
useEffect(() => {
if (whezAmount && currentPrice) {
const calculatedPez = parseFloat(whezAmount) * currentPrice;
setPezAmount(calculatedPez.toFixed(4));
} else if (!whezAmount) {
setPezAmount('');
}
}, [whezAmount, currentPrice]);
const handleAddLiquidity = async () => {
if (!api || !selectedAccount || !whezAmount || !pezAmount) return;
setIsLoading(true);
setError(null);
try {
// Validate amounts
if (parseFloat(whezAmount) <= 0 || parseFloat(pezAmount) <= 0) {
setError('Please enter valid amounts');
setIsLoading(false);
return;
}
if (parseFloat(whezAmount) > whezBalance) {
setError('Insufficient HEZ balance');
setIsLoading(false);
return;
}
if (parseFloat(pezAmount) > pezBalance) {
setError('Insufficient PEZ balance');
setIsLoading(false);
return;
}
// Get the signer from the extension
const injector = await web3FromAddress(selectedAccount.address);
const whezAmountBN = BigInt(Math.floor(parseFloat(whezAmount) * 1e12));
const pezAmountBN = BigInt(Math.floor(parseFloat(pezAmount) * 1e12));
// Min amounts (90% of desired to account for slippage - more tolerance for AMM)
const minWhezBN = (whezAmountBN * BigInt(90)) / BigInt(100);
const minPezBN = (pezAmountBN * BigInt(90)) / BigInt(100);
// Need to wrap HEZ to wHEZ first
const wrapTx = api.tx.tokenWrapper.wrap(whezAmountBN.toString());
// Add liquidity transaction
const addLiquidityTx = api.tx.assetConversion.addLiquidity(
0, // asset1 (wHEZ)
1, // asset2 (PEZ)
whezAmountBN.toString(),
pezAmountBN.toString(),
minWhezBN.toString(),
minPezBN.toString(),
selectedAccount.address
);
// Batch transactions
const tx = api.tx.utility.batchAll([wrapTx, addLiquidityTx]);
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, events, dispatchError }) => {
if (status.isInBlock) {
console.log('Transaction in block:', status.asInBlock.toHex());
} else if (status.isFinalized) {
console.log('Transaction finalized:', status.asFinalized.toHex());
// Check for errors
const hasError = events.some(({ event }) =>
api.events.system.ExtrinsicFailed.is(event)
);
if (hasError || dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
const { docs, name, section } = decoded;
errorMessage = `${section}.${name}: ${docs.join(' ')}`;
console.error('Dispatch error:', errorMessage);
} else {
errorMessage = dispatchError.toString();
console.error('Dispatch error:', errorMessage);
}
}
// Also check events for more details
events.forEach(({ event }) => {
if (api.events.system.ExtrinsicFailed.is(event)) {
console.error('ExtrinsicFailed event:', event.toHuman());
}
});
setError(errorMessage);
setIsLoading(false);
} else {
console.log('Transaction successful');
setSuccess(true);
setIsLoading(false);
setWhezAmount('');
setPezAmount('');
refreshBalances();
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
}
}
}
);
} catch (err) {
console.error('Error adding liquidity:', err);
setError(err instanceof Error ? err.message : 'Failed to add liquidity');
setIsLoading(false);
}
};
if (!isOpen) return null;
const whezBalance = balances.HEZ || 0;
const pezBalance = balances.PEZ || 0;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 rounded-lg max-w-md w-full p-6 border border-gray-700">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">Add Liquidity</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{error && (
<Alert className="mb-4 bg-red-900/20 border-red-500">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="mb-4 bg-green-900/20 border-green-500">
<AlertDescription>Liquidity added successfully!</AlertDescription>
</Alert>
)}
<Alert className="mb-4 bg-blue-900/20 border-blue-500">
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
Add liquidity to earn 3% fees from all swaps. Your HEZ will be automatically wrapped to wHEZ.
</AlertDescription>
</Alert>
<div className="space-y-4">
{/* HEZ Input */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
HEZ Amount
</label>
<div className="relative">
<input
type="number"
value={whezAmount}
onChange={(e) => setWhezAmount(e.target.value)}
placeholder="0.0"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500"
disabled={isLoading}
/>
<div className="absolute right-3 top-3 flex items-center gap-2">
<span className="text-gray-400 text-sm">HEZ</span>
</div>
</div>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Balance: {whezBalance.toLocaleString()}</span>
<button
onClick={() => setWhezAmount(whezBalance.toString())}
className="text-blue-400 hover:text-blue-300"
>
Max
</button>
</div>
</div>
<div className="flex justify-center">
<Plus className="w-5 h-5 text-gray-400" />
</div>
{/* PEZ Input */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
PEZ Amount (Auto-calculated)
</label>
<div className="relative">
<input
type="number"
value={pezAmount}
placeholder="0.0"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-400 focus:outline-none cursor-not-allowed"
disabled={true}
readOnly
/>
<div className="absolute right-3 top-3 flex items-center gap-2">
<span className="text-gray-400 text-sm">PEZ</span>
</div>
</div>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Balance: {pezBalance.toLocaleString()}</span>
<span>
{currentPrice && `Rate: 1 HEZ = ${currentPrice.toFixed(4)} PEZ`}
</span>
</div>
</div>
{/* Price Info */}
{whezAmount && pezAmount && (
<div className="bg-gray-800 rounded-lg p-3 space-y-2 text-sm">
<div className="flex justify-between text-gray-300">
<span>Share of Pool</span>
<span>~0.1%</span>
</div>
<div className="flex justify-between text-gray-300">
<span>Slippage Tolerance</span>
<span>10%</span>
</div>
</div>
)}
<Button
onClick={handleAddLiquidity}
disabled={
isLoading ||
!whezAmount ||
!pezAmount ||
parseFloat(whezAmount) > whezBalance ||
parseFloat(pezAmount) > pezBalance
}
className="w-full bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 h-12"
>
{isLoading ? 'Adding Liquidity...' : 'Add Liquidity'}
</Button>
</div>
</div>
</div>
);
};
+486
View File
@@ -0,0 +1,486 @@
import React, { useState, useEffect } from 'react';
import { TrendingUp, Droplet, DollarSign, Percent, Info, AlertTriangle, BarChart3, Clock } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { ASSET_IDS } from '@/lib/wallet';
import { AddLiquidityModal } from '@/components/AddLiquidityModal';
import { RemoveLiquidityModal } from '@/components/RemoveLiquidityModal';
interface PoolData {
asset0: number;
asset1: number;
reserve0: number;
reserve1: number;
lpTokenId: number;
poolAccount: string;
}
interface LPPosition {
lpTokenBalance: number;
share: number; // Percentage of pool
asset0Amount: number;
asset1Amount: number;
}
const PoolDashboard = () => {
const { api, isApiReady, selectedAccount } = usePolkadot();
const { balances } = useWallet();
const [poolData, setPoolData] = useState<PoolData | null>(null);
const [lpPosition, setLPPosition] = useState<LPPosition | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isAddLiquidityModalOpen, setIsAddLiquidityModalOpen] = useState(false);
const [isRemoveLiquidityModalOpen, setIsRemoveLiquidityModalOpen] = useState(false);
// Fetch pool data
useEffect(() => {
if (!api || !isApiReady) return;
const fetchPoolData = async () => {
setIsLoading(true);
setError(null);
try {
// Query wHEZ/PEZ pool
const poolId = [0, 1]; // wHEZ (asset 0) / PEZ (asset 1)
const poolInfo = await api.query.assetConversion.pools(poolId);
if (poolInfo.isSome) {
const lpTokenData = poolInfo.unwrap().toJSON() as any;
const lpTokenId = lpTokenData.lpToken;
// Get pool account
const poolAccountData = await api.query.assetConversion.poolAccountIds?.(poolId);
let poolAccount = '';
if (poolAccountData && poolAccountData.isSome) {
poolAccount = poolAccountData.unwrap().toString();
}
// Get reserves
const whezBalanceData = await api.query.assets.account(0, poolAccount);
const pezBalanceData = await api.query.assets.account(1, poolAccount);
let reserve0 = 0;
let reserve1 = 0;
if (whezBalanceData.isSome) {
const whezData = whezBalanceData.unwrap().toJSON() as any;
reserve0 = Number(whezData.balance) / 1e12;
}
if (pezBalanceData.isSome) {
const pezData = pezBalanceData.unwrap().toJSON() as any;
reserve1 = Number(pezData.balance) / 1e12;
}
setPoolData({
asset0: 0,
asset1: 1,
reserve0,
reserve1,
lpTokenId,
poolAccount,
});
// Get user's LP position if account connected
if (selectedAccount) {
await fetchLPPosition(lpTokenId, reserve0, reserve1);
}
} else {
setError('Pool not found');
}
} catch (err) {
console.error('Error fetching pool data:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch pool data');
} finally {
setIsLoading(false);
}
};
const fetchLPPosition = async (lpTokenId: number, reserve0: number, reserve1: number) => {
if (!api || !selectedAccount) return;
try {
// Query user's LP token balance
const lpBalance = await api.query.poolAssets.account(lpTokenId, selectedAccount.address);
if (lpBalance.isSome) {
const lpData = lpBalance.unwrap().toJSON() as any;
const userLpBalance = Number(lpData.balance) / 1e12;
// Query total LP supply
const lpAssetData = await api.query.poolAssets.asset(lpTokenId);
if (lpAssetData.isSome) {
const assetInfo = lpAssetData.unwrap().toJSON() as any;
const totalSupply = Number(assetInfo.supply) / 1e12;
// Calculate user's share
const sharePercentage = (userLpBalance / totalSupply) * 100;
// Calculate user's actual token amounts
const asset0Amount = (sharePercentage / 100) * reserve0;
const asset1Amount = (sharePercentage / 100) * reserve1;
setLPPosition({
lpTokenBalance: userLpBalance,
share: sharePercentage,
asset0Amount,
asset1Amount,
});
}
}
} catch (err) {
console.error('Error fetching LP position:', err);
}
};
fetchPoolData();
// Refresh every 30 seconds
const interval = setInterval(fetchPoolData, 30000);
return () => clearInterval(interval);
}, [api, isApiReady, selectedAccount]);
// Calculate metrics
const constantProduct = poolData ? poolData.reserve0 * poolData.reserve1 : 0;
const currentPrice = poolData ? poolData.reserve1 / poolData.reserve0 : 0;
const totalLiquidityUSD = poolData ? poolData.reserve0 * 2 : 0; // Simplified: assumes 1:1 USD peg
// APR calculation (simplified - would need 24h volume data)
const estimateAPR = () => {
if (!poolData) return 0;
// Estimate based on pool size and typical volume
// This is a simplified calculation
// Real APR = (24h fees × 365) / TVL
const dailyVolumeEstimate = totalLiquidityUSD * 0.1; // Assume 10% daily turnover
const dailyFees = dailyVolumeEstimate * 0.03; // 3% fee
const annualFees = dailyFees * 365;
const apr = (annualFees / totalLiquidityUSD) * 100;
return apr;
};
// Impermanent loss calculator
const calculateImpermanentLoss = (priceChange: number) => {
// IL formula: 2 * sqrt(price_ratio) / (1 + price_ratio) - 1
const priceRatio = 1 + priceChange / 100;
const il = ((2 * Math.sqrt(priceRatio)) / (1 + priceRatio) - 1) * 100;
return il;
};
if (isLoading && !poolData) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-gray-400">Loading pool data...</p>
</div>
</div>
);
}
if (error) {
return (
<Alert className="bg-red-900/20 border-red-500">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
if (!poolData) {
return (
<Alert className="bg-yellow-900/20 border-yellow-500">
<Info className="h-4 w-4" />
<AlertDescription>No pool data available</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Droplet className="h-6 w-6 text-blue-400" />
wHEZ/PEZ Pool Dashboard
</h2>
<p className="text-gray-400 mt-1">Monitor liquidity pool metrics and your position</p>
</div>
<Badge variant="outline" className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Live
</Badge>
</div>
{/* Key Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Liquidity */}
<Card className="p-4 bg-gray-800/50 border-gray-700">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-400">Total Liquidity</p>
<p className="text-2xl font-bold text-white mt-1">
${totalLiquidityUSD.toLocaleString('en-US', { maximumFractionDigits: 0 })}
</p>
<p className="text-xs text-gray-500 mt-1">
{poolData.reserve0.toLocaleString()} wHEZ + {poolData.reserve1.toLocaleString()} PEZ
</p>
</div>
<DollarSign className="h-8 w-8 text-green-400" />
</div>
</Card>
{/* Current Price */}
<Card className="p-4 bg-gray-800/50 border-gray-700">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-400">HEZ Price</p>
<p className="text-2xl font-bold text-white mt-1">
{currentPrice.toFixed(4)} PEZ
</p>
<p className="text-xs text-gray-500 mt-1">
1 PEZ = {(1 / currentPrice).toFixed(6)} HEZ
</p>
</div>
<TrendingUp className="h-8 w-8 text-blue-400" />
</div>
</Card>
{/* APR */}
<Card className="p-4 bg-gray-800/50 border-gray-700">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-400">Estimated APR</p>
<p className="text-2xl font-bold text-white mt-1">
{estimateAPR().toFixed(2)}%
</p>
<p className="text-xs text-gray-500 mt-1">
From swap fees
</p>
</div>
<Percent className="h-8 w-8 text-yellow-400" />
</div>
</Card>
{/* Constant Product */}
<Card className="p-4 bg-gray-800/50 border-gray-700">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-400">Constant (k)</p>
<p className="text-2xl font-bold text-white mt-1">
{(constantProduct / 1e9).toFixed(1)}B
</p>
<p className="text-xs text-gray-500 mt-1">
x × y = k
</p>
</div>
<BarChart3 className="h-8 w-8 text-purple-400" />
</div>
</Card>
</div>
<Tabs defaultValue="reserves" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-gray-800">
<TabsTrigger value="reserves">Reserves</TabsTrigger>
<TabsTrigger value="position">Your Position</TabsTrigger>
<TabsTrigger value="calculator">IL Calculator</TabsTrigger>
</TabsList>
{/* Reserves Tab */}
<TabsContent value="reserves" className="space-y-4">
<Card className="p-6 bg-gray-800/50 border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Pool Reserves</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-900/50 rounded-lg">
<div>
<p className="text-sm text-gray-400">wHEZ Reserve</p>
<p className="text-2xl font-bold text-white">{poolData.reserve0.toLocaleString('en-US', { maximumFractionDigits: 2 })}</p>
</div>
<Badge variant="outline">Asset 0</Badge>
</div>
<div className="flex items-center justify-between p-4 bg-gray-900/50 rounded-lg">
<div>
<p className="text-sm text-gray-400">PEZ Reserve</p>
<p className="text-2xl font-bold text-white">{poolData.reserve1.toLocaleString('en-US', { maximumFractionDigits: 2 })}</p>
</div>
<Badge variant="outline">Asset 1</Badge>
</div>
</div>
<div className="mt-6 p-4 bg-blue-900/20 border border-blue-500/30 rounded-lg">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-400 mt-0.5" />
<div className="text-sm text-gray-300">
<p className="font-semibold text-blue-400 mb-1">AMM Formula</p>
<p>Pool maintains constant product: x × y = k</p>
<p className="mt-2 font-mono text-xs">
{poolData.reserve0.toFixed(2)} × {poolData.reserve1.toFixed(2)} = {constantProduct.toLocaleString()}
</p>
</div>
</div>
</div>
</Card>
</TabsContent>
{/* Your Position Tab */}
<TabsContent value="position" className="space-y-4">
<Card className="p-6 bg-gray-800/50 border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Your Liquidity Position</h3>
{!selectedAccount ? (
<Alert className="bg-yellow-900/20 border-yellow-500">
<Info className="h-4 w-4" />
<AlertDescription>Connect wallet to view your position</AlertDescription>
</Alert>
) : !lpPosition ? (
<div className="text-center py-8 text-gray-400">
<Droplet className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No liquidity position found</p>
<Button
onClick={() => setIsAddLiquidityModalOpen(true)}
className="mt-4 bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700"
>
Add Liquidity
</Button>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-gray-900/50 rounded-lg">
<p className="text-sm text-gray-400">LP Tokens</p>
<p className="text-xl font-bold text-white">{lpPosition.lpTokenBalance.toFixed(4)}</p>
</div>
<div className="p-4 bg-gray-900/50 rounded-lg">
<p className="text-sm text-gray-400">Pool Share</p>
<p className="text-xl font-bold text-white">{lpPosition.share.toFixed(4)}%</p>
</div>
</div>
<div className="p-4 bg-gray-900/50 rounded-lg">
<p className="text-sm text-gray-400 mb-2">Your Position Value</p>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-300">wHEZ:</span>
<span className="text-white font-semibold">{lpPosition.asset0Amount.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-300">PEZ:</span>
<span className="text-white font-semibold">{lpPosition.asset1Amount.toFixed(4)}</span>
</div>
</div>
</div>
<div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg">
<p className="text-sm text-gray-400 mb-2">Estimated Earnings (APR {estimateAPR().toFixed(2)}%)</p>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-300">Daily:</span>
<span className="text-green-400">~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 365 / 100).toFixed(4)} HEZ</span>
</div>
<div className="flex justify-between">
<span className="text-gray-300">Monthly:</span>
<span className="text-green-400">~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 12 / 100).toFixed(4)} HEZ</span>
</div>
<div className="flex justify-between">
<span className="text-gray-300">Yearly:</span>
<span className="text-green-400">~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 100).toFixed(4)} HEZ</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 pt-2">
<Button
onClick={() => setIsAddLiquidityModalOpen(true)}
className="bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700"
>
Add More
</Button>
<Button
onClick={() => setIsRemoveLiquidityModalOpen(true)}
variant="outline"
className="border-red-600 text-red-400 hover:bg-red-900/20"
>
Remove
</Button>
</div>
</div>
)}
</Card>
</TabsContent>
{/* Impermanent Loss Calculator Tab */}
<TabsContent value="calculator" className="space-y-4">
<Card className="p-6 bg-gray-800/50 border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Impermanent Loss Calculator</h3>
<div className="space-y-4">
<div className="p-4 bg-gray-900/50 rounded-lg">
<p className="text-sm text-gray-400 mb-3">If HEZ price changes by:</p>
<div className="space-y-2">
{[10, 25, 50, 100, 200].map((change) => {
const il = calculateImpermanentLoss(change);
return (
<div key={change} className="flex justify-between items-center py-2 border-b border-gray-700 last:border-0">
<span className="text-gray-300">+{change}%</span>
<Badge
variant="outline"
className={il < -1 ? 'border-red-500 text-red-400' : 'border-yellow-500 text-yellow-400'}
>
{il.toFixed(2)}% Loss
</Badge>
</div>
);
})}
</div>
</div>
<Alert className="bg-orange-900/20 border-orange-500">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<p className="font-semibold mb-1">What is Impermanent Loss?</p>
<p className="text-sm text-gray-300">
Impermanent loss occurs when the price ratio of tokens in the pool changes.
The larger the price change, the greater the loss compared to simply holding the tokens.
Fees earned from swaps can offset this loss over time.
</p>
</AlertDescription>
</Alert>
</div>
</Card>
</TabsContent>
</Tabs>
{/* Modals */}
<AddLiquidityModal
isOpen={isAddLiquidityModalOpen}
onClose={() => setIsAddLiquidityModalOpen(false)}
/>
{lpPosition && poolData && (
<RemoveLiquidityModal
isOpen={isRemoveLiquidityModalOpen}
onClose={() => setIsRemoveLiquidityModalOpen(false)}
lpPosition={lpPosition}
lpTokenId={poolData.lpTokenId}
/>
)}
</div>
);
};
export default PoolDashboard;
+237
View File
@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { X, Minus, AlertCircle, Info } from 'lucide-react';
import { web3FromAddress } from '@polkadot/extension-dapp';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface RemoveLiquidityModalProps {
isOpen: boolean;
onClose: () => void;
lpPosition: {
lpTokenBalance: number;
share: number;
asset0Amount: number;
asset1Amount: number;
};
lpTokenId: number;
}
export const RemoveLiquidityModal: React.FC<RemoveLiquidityModalProps> = ({
isOpen,
onClose,
lpPosition,
lpTokenId,
}) => {
const { api, selectedAccount } = usePolkadot();
const { refreshBalances } = useWallet();
const [percentage, setPercentage] = useState(100);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleRemoveLiquidity = async () => {
if (!api || !selectedAccount) return;
setIsLoading(true);
setError(null);
try {
// Get the signer from the extension
const injector = await web3FromAddress(selectedAccount.address);
// Calculate LP tokens to remove
const lpToRemove = (lpPosition.lpTokenBalance * percentage) / 100;
const lpToRemoveBN = BigInt(Math.floor(lpToRemove * 1e12));
// Calculate expected token amounts (with 95% slippage tolerance)
const expectedWhezBN = BigInt(Math.floor((lpPosition.asset0Amount * percentage) / 100 * 1e12));
const expectedPezBN = BigInt(Math.floor((lpPosition.asset1Amount * percentage) / 100 * 1e12));
const minWhezBN = (expectedWhezBN * BigInt(95)) / BigInt(100);
const minPezBN = (expectedPezBN * BigInt(95)) / BigInt(100);
// Remove liquidity transaction
const removeLiquidityTx = api.tx.assetConversion.removeLiquidity(
0, // asset1 (wHEZ)
1, // asset2 (PEZ)
lpToRemoveBN.toString(),
minWhezBN.toString(),
minPezBN.toString(),
selectedAccount.address
);
// Unwrap wHEZ back to HEZ
const unwrapTx = api.tx.tokenWrapper.unwrap(minWhezBN.toString());
// Batch transactions
const tx = api.tx.utility.batchAll([removeLiquidityTx, unwrapTx]);
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, events }) => {
if (status.isInBlock) {
console.log('Transaction in block');
} else if (status.isFinalized) {
console.log('Transaction finalized');
// Check for errors
const hasError = events.some(({ event }) =>
api.events.system.ExtrinsicFailed.is(event)
);
if (hasError) {
setError('Transaction failed');
setIsLoading(false);
} else {
setSuccess(true);
setIsLoading(false);
refreshBalances();
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
}
}
}
);
} catch (err) {
console.error('Error removing liquidity:', err);
setError(err instanceof Error ? err.message : 'Failed to remove liquidity');
setIsLoading(false);
}
};
if (!isOpen) return null;
const whezToReceive = (lpPosition.asset0Amount * percentage) / 100;
const pezToReceive = (lpPosition.asset1Amount * percentage) / 100;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 rounded-lg max-w-md w-full p-6 border border-gray-700">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">Remove Liquidity</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{error && (
<Alert className="mb-4 bg-red-900/20 border-red-500">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="mb-4 bg-green-900/20 border-green-500">
<AlertDescription>Liquidity removed successfully!</AlertDescription>
</Alert>
)}
<Alert className="mb-4 bg-blue-900/20 border-blue-500">
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
Remove your liquidity to receive back your tokens. wHEZ will be automatically unwrapped to HEZ.
</AlertDescription>
</Alert>
<div className="space-y-6">
{/* Percentage Selector */}
<div>
<div className="flex justify-between mb-2">
<label className="text-sm font-medium text-gray-300">Amount to Remove</label>
<span className="text-2xl font-bold text-white">{percentage}%</span>
</div>
<input
type="range"
min="1"
max="100"
value={percentage}
onChange={(e) => setPercentage(parseInt(e.target.value))}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
disabled={isLoading}
/>
<div className="flex justify-between mt-2">
{[25, 50, 75, 100].map((p) => (
<button
key={p}
onClick={() => setPercentage(p)}
className={`px-3 py-1 rounded text-sm ${
percentage === p
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
disabled={isLoading}
>
{p}%
</button>
))}
</div>
</div>
{/* You Will Receive */}
<div className="bg-gray-800 rounded-lg p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300 mb-2">You Will Receive</h3>
<div className="flex justify-between items-center p-3 bg-gray-900/50 rounded-lg">
<div>
<p className="text-xs text-gray-400">HEZ</p>
<p className="text-xl font-bold text-white">
{whezToReceive.toFixed(4)}
</p>
</div>
<Minus className="w-5 h-5 text-gray-400" />
</div>
<div className="flex justify-between items-center p-3 bg-gray-900/50 rounded-lg">
<div>
<p className="text-xs text-gray-400">PEZ</p>
<p className="text-xl font-bold text-white">
{pezToReceive.toFixed(4)}
</p>
</div>
<Minus className="w-5 h-5 text-gray-400" />
</div>
</div>
{/* LP Token Info */}
<div className="bg-gray-800 rounded-lg p-3 space-y-2 text-sm">
<div className="flex justify-between text-gray-300">
<span>LP Tokens to Burn</span>
<span>{((lpPosition.lpTokenBalance * percentage) / 100).toFixed(4)}</span>
</div>
<div className="flex justify-between text-gray-300">
<span>Remaining LP Tokens</span>
<span>
{((lpPosition.lpTokenBalance * (100 - percentage)) / 100).toFixed(4)}
</span>
</div>
<div className="flex justify-between text-gray-300">
<span>Slippage Tolerance</span>
<span>5%</span>
</div>
</div>
<Button
onClick={handleRemoveLiquidity}
disabled={isLoading || percentage === 0}
className="w-full bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 h-12"
>
{isLoading ? 'Removing Liquidity...' : 'Remove Liquidity'}
</Button>
</div>
</div>
</div>
);
};
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import PoolDashboard from '@/components/PoolDashboard';
const PoolDashboardPage = () => {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-gray-950 pt-24 pb-12">
<div className="container mx-auto px-4 py-8 relative">
<button
onClick={() => navigate('/wallet')}
className="absolute top-4 left-4 text-red-500 hover:text-red-400 transition-colors flex items-center gap-2 font-semibold"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Wallet</span>
</button>
<PoolDashboard />
</div>
</div>
);
};
export default PoolDashboardPage;
+12 -3
View File
@@ -6,7 +6,7 @@ import { TransferModal } from '@/components/TransferModal';
import { ReceiveModal } from '@/components/ReceiveModal';
import { TransactionHistory } from '@/components/TransactionHistory';
import { Button } from '@/components/ui/button';
import { ArrowUpRight, ArrowDownRight, History, ArrowLeft } from 'lucide-react';
import { ArrowUpRight, ArrowDownRight, History, ArrowLeft, Activity } from 'lucide-react';
const WalletDashboard: React.FC = () => {
const navigate = useNavigate();
@@ -49,7 +49,7 @@ const WalletDashboard: React.FC = () => {
{/* Right Column - Actions */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Actions */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Button
onClick={() => setIsTransferModalOpen(true)}
className="bg-gradient-to-r from-green-600 to-yellow-400 hover:from-green-700 hover:to-yellow-500 h-24 flex flex-col items-center justify-center"
@@ -57,7 +57,7 @@ const WalletDashboard: React.FC = () => {
<ArrowUpRight className="w-6 h-6 mb-2" />
<span>Send</span>
</Button>
<Button
onClick={() => setIsReceiveModalOpen(true)}
variant="outline"
@@ -67,6 +67,15 @@ const WalletDashboard: React.FC = () => {
<span>Receive</span>
</Button>
<Button
onClick={() => navigate('/pool')}
variant="outline"
className="border-blue-600 hover:bg-blue-900/20 text-blue-400 h-24 flex flex-col items-center justify-center"
>
<Activity className="w-6 h-6 mb-2" />
<span>Pool</span>
</Button>
<Button
onClick={() => setIsHistoryModalOpen(true)}
variant="outline"