mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 18:17:58 +00:00
Reorganize repository into monorepo structure
Restructured the project to support multiple frontend applications: - Move web app to web/ directory - Create pezkuwi-sdk-ui/ for Polkadot SDK clone (planned) - Create mobile/ directory for mobile app development - Add shared/ directory with common utilities, types, and blockchain code - Update README.md with comprehensive documentation - Remove obsolete DKSweb/ directory This monorepo structure enables better code sharing and organized development across web, mobile, and SDK UI projects.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, Plus, AlertCircle, Loader2, CheckCircle, Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PoolInfo } from '@/types/dex';
|
||||
import { parseTokenInput, formatTokenBalance, quote } from '@/utils/dex';
|
||||
|
||||
interface AddLiquidityModalProps {
|
||||
isOpen: boolean;
|
||||
pool: PoolInfo | null;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export const AddLiquidityModal: React.FC<AddLiquidityModalProps> = ({
|
||||
isOpen,
|
||||
pool,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePolkadot();
|
||||
const { account, signer } = useWallet();
|
||||
|
||||
const [amount1Input, setAmount1Input] = useState('');
|
||||
const [amount2Input, setAmount2Input] = useState('');
|
||||
const [slippage, setSlippage] = useState(1); // 1% default
|
||||
|
||||
const [balance1, setBalance1] = useState<string>('0');
|
||||
const [balance2, setBalance2] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Reset form when modal closes or pool changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pool) {
|
||||
setAmount1Input('');
|
||||
setAmount2Input('');
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen, pool]);
|
||||
|
||||
// Fetch balances
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account || !pool) return;
|
||||
|
||||
try {
|
||||
const balance1Data = await api.query.assets.account(pool.asset1, account);
|
||||
const balance2Data = await api.query.assets.account(pool.asset2, account);
|
||||
|
||||
setBalance1(balance1Data.isSome ? balance1Data.unwrap().balance.toString() : '0');
|
||||
setBalance2(balance2Data.isSome ? balance2Data.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balances:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account, pool]);
|
||||
|
||||
// Auto-calculate amount2 when amount1 changes
|
||||
const handleAmount1Change = (value: string) => {
|
||||
setAmount1Input(value);
|
||||
|
||||
if (!pool || !value || parseFloat(value) === 0) {
|
||||
setAmount2Input('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const amount1Raw = parseTokenInput(value, pool.asset1Decimals);
|
||||
const amount2Raw = quote(amount1Raw, pool.reserve2, pool.reserve1);
|
||||
const amount2Display = formatTokenBalance(amount2Raw, pool.asset2Decimals, 6);
|
||||
setAmount2Input(amount2Display);
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate amount2:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-calculate amount1 when amount2 changes
|
||||
const handleAmount2Change = (value: string) => {
|
||||
setAmount2Input(value);
|
||||
|
||||
if (!pool || !value || parseFloat(value) === 0) {
|
||||
setAmount1Input('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const amount2Raw = parseTokenInput(value, pool.asset2Decimals);
|
||||
const amount1Raw = quote(amount2Raw, pool.reserve1, pool.reserve2);
|
||||
const amount1Display = formatTokenBalance(amount1Raw, pool.asset1Decimals, 6);
|
||||
setAmount1Input(amount1Display);
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate amount1:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateInputs = (): string | null => {
|
||||
if (!pool) return 'No pool selected';
|
||||
if (!amount1Input || !amount2Input) return 'Please enter amounts';
|
||||
|
||||
const amount1Raw = parseTokenInput(amount1Input, pool.asset1Decimals);
|
||||
const amount2Raw = parseTokenInput(amount2Input, pool.asset2Decimals);
|
||||
|
||||
if (BigInt(amount1Raw) <= BigInt(0) || BigInt(amount2Raw) <= BigInt(0)) {
|
||||
return 'Amounts must be greater than zero';
|
||||
}
|
||||
|
||||
if (BigInt(amount1Raw) > BigInt(balance1)) {
|
||||
return `Insufficient ${pool.asset1Symbol} balance`;
|
||||
}
|
||||
|
||||
if (BigInt(amount2Raw) > BigInt(balance2)) {
|
||||
return `Insufficient ${pool.asset2Symbol} balance`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAddLiquidity = async () => {
|
||||
if (!api || !isApiReady || !signer || !account || !pool) {
|
||||
setErrorMessage('Wallet not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateInputs();
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const amount1Raw = parseTokenInput(amount1Input, pool.asset1Decimals);
|
||||
const amount2Raw = parseTokenInput(amount2Input, pool.asset2Decimals);
|
||||
|
||||
// Calculate minimum amounts with slippage tolerance
|
||||
const minAmount1 = (BigInt(amount1Raw) * BigInt(100 - slippage * 100)) / BigInt(10000);
|
||||
const minAmount2 = (BigInt(amount2Raw) * BigInt(100 - slippage * 100)) / BigInt(10000);
|
||||
|
||||
try {
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
const tx = api.tx.assetConversion.addLiquidity(
|
||||
pool.asset1,
|
||||
pool.asset2,
|
||||
amount1Raw,
|
||||
amount2Raw,
|
||||
minAmount1.toString(),
|
||||
minAmount2.toString(),
|
||||
account
|
||||
);
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await tx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
|
||||
} else {
|
||||
setErrorMessage(dispatchError.toString());
|
||||
}
|
||||
setTxStatus('error');
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Add liquidity failed:', error);
|
||||
setErrorMessage(error.message || 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !pool) return null;
|
||||
|
||||
const shareOfPool =
|
||||
amount1Input && parseFloat(amount1Input) > 0
|
||||
? (
|
||||
(parseFloat(
|
||||
formatTokenBalance(
|
||||
parseTokenInput(amount1Input, pool.asset1Decimals),
|
||||
pool.asset1Decimals,
|
||||
6
|
||||
)
|
||||
) /
|
||||
(parseFloat(formatTokenBalance(pool.reserve1, pool.asset1Decimals, 6)) +
|
||||
parseFloat(
|
||||
formatTokenBalance(
|
||||
parseTokenInput(amount1Input, pool.asset1Decimals),
|
||||
pool.asset1Decimals,
|
||||
6
|
||||
)
|
||||
))) *
|
||||
100
|
||||
).toFixed(4)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Add Liquidity
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
{pool.asset1Symbol} / {pool.asset2Symbol} Pool
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-blue-400">
|
||||
Add liquidity in proportion to the pool's current ratio. You'll receive LP tokens representing your share.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Token 1 Input */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">{pool.asset1Symbol}</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
Balance: {formatTokenBalance(balance1, pool.asset1Decimals, 4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={amount1Input}
|
||||
onChange={(e) => handleAmount1Change(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleAmount1Change(formatTokenBalance(balance1, pool.asset1Decimals, 6))
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plus Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">
|
||||
<Plus className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token 2 Input */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">{pool.asset2Symbol}</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
Balance: {formatTokenBalance(balance2, pool.asset2Decimals, 4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={amount2Input}
|
||||
onChange={(e) => handleAmount2Change(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleAmount2Change(formatTokenBalance(balance2, pool.asset2Decimals, 6))
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slippage Tolerance */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Slippage Tolerance</label>
|
||||
<div className="flex gap-2">
|
||||
{[0.5, 1, 2].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setSlippage(value)}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
slippage === value
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
{value}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pool Share Preview */}
|
||||
{amount1Input && amount2Input && (
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Share of Pool</span>
|
||||
<span className="text-white font-mono">{shareOfPool}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Exchange Rate</span>
|
||||
<span className="text-cyan-400 font-mono">
|
||||
1 {pool.asset1Symbol} ={' '}
|
||||
{(
|
||||
parseFloat(formatTokenBalance(pool.reserve2, pool.asset2Decimals, 6)) /
|
||||
parseFloat(formatTokenBalance(pool.reserve1, pool.asset1Decimals, 6))
|
||||
).toFixed(6)}{' '}
|
||||
{pool.asset2Symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-green-400">
|
||||
Liquidity added successfully!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLiquidity}
|
||||
className="flex-1 px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium flex items-center justify-center gap-2"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success'
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Add Liquidity'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,413 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, Plus, AlertCircle, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { KNOWN_TOKENS } from '@/types/dex';
|
||||
import { parseTokenInput, formatTokenBalance } from '@/utils/dex';
|
||||
|
||||
interface CreatePoolModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export const CreatePoolModal: React.FC<CreatePoolModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePolkadot();
|
||||
const { account, signer } = useWallet();
|
||||
|
||||
const [asset1Id, setAsset1Id] = useState<number | null>(null);
|
||||
const [asset2Id, setAsset2Id] = useState<number | null>(null);
|
||||
const [amount1Input, setAmount1Input] = useState('');
|
||||
const [amount2Input, setAmount2Input] = useState('');
|
||||
|
||||
const [balance1, setBalance1] = useState<string>('0');
|
||||
const [balance2, setBalance2] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Available tokens
|
||||
const availableTokens = Object.values(KNOWN_TOKENS);
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setAsset1Id(null);
|
||||
setAsset2Id(null);
|
||||
setAmount1Input('');
|
||||
setAmount2Input('');
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch balances when assets selected
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account || asset1Id === null) return;
|
||||
|
||||
try {
|
||||
console.log('🔍 Fetching balance for asset', asset1Id, 'account', account);
|
||||
const balance1Data = await api.query.assets.account(asset1Id, account);
|
||||
if (balance1Data.isSome) {
|
||||
const balance = balance1Data.unwrap().balance.toString();
|
||||
console.log('✅ Balance found for asset', asset1Id, ':', balance);
|
||||
setBalance1(balance);
|
||||
} else {
|
||||
console.warn('⚠️ No balance found for asset', asset1Id);
|
||||
setBalance1('0');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch balance 1:', error);
|
||||
setBalance1('0');
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account, asset1Id]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account || asset2Id === null) return;
|
||||
|
||||
try {
|
||||
console.log('🔍 Fetching balance for asset', asset2Id, 'account', account);
|
||||
const balance2Data = await api.query.assets.account(asset2Id, account);
|
||||
if (balance2Data.isSome) {
|
||||
const balance = balance2Data.unwrap().balance.toString();
|
||||
console.log('✅ Balance found for asset', asset2Id, ':', balance);
|
||||
setBalance2(balance);
|
||||
} else {
|
||||
console.warn('⚠️ No balance found for asset', asset2Id);
|
||||
setBalance2('0');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch balance 2:', error);
|
||||
setBalance2('0');
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account, asset2Id]);
|
||||
|
||||
const validateInputs = (): string | null => {
|
||||
if (asset1Id === null || asset2Id === null) {
|
||||
return 'Please select both tokens';
|
||||
}
|
||||
|
||||
if (asset1Id === asset2Id) {
|
||||
return 'Cannot create pool with same token';
|
||||
}
|
||||
|
||||
if (!amount1Input || !amount2Input) {
|
||||
return 'Please enter amounts for both tokens';
|
||||
}
|
||||
|
||||
const token1 = KNOWN_TOKENS[asset1Id];
|
||||
const token2 = KNOWN_TOKENS[asset2Id];
|
||||
|
||||
if (!token1 || !token2) {
|
||||
return 'Invalid token selected';
|
||||
}
|
||||
|
||||
const amount1Raw = parseTokenInput(amount1Input, token1.decimals);
|
||||
const amount2Raw = parseTokenInput(amount2Input, token2.decimals);
|
||||
|
||||
console.log('💰 Validation check:', {
|
||||
token1: token1.symbol,
|
||||
amount1Input,
|
||||
amount1Raw,
|
||||
balance1,
|
||||
hasEnough1: BigInt(amount1Raw) <= BigInt(balance1),
|
||||
token2: token2.symbol,
|
||||
amount2Input,
|
||||
amount2Raw,
|
||||
balance2,
|
||||
hasEnough2: BigInt(amount2Raw) <= BigInt(balance2),
|
||||
});
|
||||
|
||||
if (BigInt(amount1Raw) <= BigInt(0) || BigInt(amount2Raw) <= BigInt(0)) {
|
||||
return 'Amounts must be greater than zero';
|
||||
}
|
||||
|
||||
if (BigInt(amount1Raw) > BigInt(balance1)) {
|
||||
return `Insufficient ${token1.symbol} balance`;
|
||||
}
|
||||
|
||||
if (BigInt(amount2Raw) > BigInt(balance2)) {
|
||||
return `Insufficient ${token2.symbol} balance`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreatePool = async () => {
|
||||
if (!api || !isApiReady || !signer || !account) {
|
||||
setErrorMessage('Wallet not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateInputs();
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const token1 = KNOWN_TOKENS[asset1Id!];
|
||||
const token2 = KNOWN_TOKENS[asset2Id!];
|
||||
const amount1Raw = parseTokenInput(amount1Input, token1.decimals);
|
||||
const amount2Raw = parseTokenInput(amount2Input, token2.decimals);
|
||||
|
||||
try {
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
// Create pool extrinsic
|
||||
const createPoolTx = api.tx.assetConversion.createPool(asset1Id, asset2Id);
|
||||
|
||||
// Add liquidity extrinsic
|
||||
const addLiquidityTx = api.tx.assetConversion.addLiquidity(
|
||||
asset1Id,
|
||||
asset2Id,
|
||||
amount1Raw,
|
||||
amount2Raw,
|
||||
amount1Raw, // min amount1
|
||||
amount2Raw, // min amount2
|
||||
account
|
||||
);
|
||||
|
||||
// Batch transactions
|
||||
const batchTx = api.tx.utility.batchAll([createPoolTx, addLiquidityTx]);
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await batchTx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
|
||||
} else {
|
||||
setErrorMessage(dispatchError.toString());
|
||||
}
|
||||
setTxStatus('error');
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Pool creation failed:', error);
|
||||
setErrorMessage(error.message || 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const token1 = asset1Id !== null ? KNOWN_TOKENS[asset1Id] : null;
|
||||
const token2 = asset2Id !== null ? KNOWN_TOKENS[asset2Id] : null;
|
||||
|
||||
const exchangeRate =
|
||||
amount1Input && amount2Input && parseFloat(amount1Input) > 0
|
||||
? (parseFloat(amount2Input) / parseFloat(amount1Input)).toFixed(6)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Create New Pool
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30 w-fit mt-2">
|
||||
Founder Only
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Token 1 Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Token 1</label>
|
||||
<select
|
||||
value={asset1Id ?? ''}
|
||||
onChange={(e) => setAsset1Id(Number(e.target.value))}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<option value="">Select token...</option>
|
||||
{availableTokens.map((token) => (
|
||||
<option key={token.id} value={token.id}>
|
||||
{token.symbol} - {token.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{token1 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Balance: {formatTokenBalance(balance1, token1.decimals, 4)} {token1.symbol}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount 1 Input */}
|
||||
{token1 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">
|
||||
Amount of {token1.symbol}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={amount1Input}
|
||||
onChange={(e) => setAmount1Input(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plus Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">
|
||||
<Plus className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token 2 Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Token 2</label>
|
||||
<select
|
||||
value={asset2Id ?? ''}
|
||||
onChange={(e) => setAsset2Id(Number(e.target.value))}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<option value="">Select token...</option>
|
||||
{availableTokens.map((token) => (
|
||||
<option key={token.id} value={token.id} disabled={token.id === asset1Id}>
|
||||
{token.symbol} - {token.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{token2 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Balance: {formatTokenBalance(balance2, token2.decimals, 4)} {token2.symbol}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount 2 Input */}
|
||||
{token2 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">
|
||||
Amount of {token2.symbol}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={amount2Input}
|
||||
onChange={(e) => setAmount2Input(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchange Rate Preview */}
|
||||
{token1 && token2 && amount1Input && amount2Input && (
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-2">Initial Exchange Rate</div>
|
||||
<div className="text-white font-mono">
|
||||
1 {token1.symbol} = {exchangeRate} {token2.symbol}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-green-400">
|
||||
Pool created successfully!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreatePool}
|
||||
className="flex-1 px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium flex items-center justify-center gap-2"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success'
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Create Pool'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import TokenSwap from '@/components/TokenSwap';
|
||||
import PoolDashboard from '@/components/PoolDashboard';
|
||||
import { CreatePoolModal } from './CreatePoolModal';
|
||||
import { InitializeHezPoolModal } from './InitializeHezPoolModal';
|
||||
import { ArrowRightLeft, Droplet, Settings } from 'lucide-react';
|
||||
import { isFounderWallet } from '@/utils/auth';
|
||||
|
||||
export const DEXDashboard: React.FC = () => {
|
||||
const { account } = useWallet();
|
||||
const [activeTab, setActiveTab] = useState('swap');
|
||||
|
||||
// Admin modal states
|
||||
const [showCreatePoolModal, setShowCreatePoolModal] = useState(false);
|
||||
const [showInitializeHezPoolModal, setShowInitializeHezPoolModal] = useState(false);
|
||||
|
||||
const isFounder = account ? isFounderWallet(account) : false;
|
||||
|
||||
const handleCreatePool = () => {
|
||||
setShowCreatePoolModal(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setShowCreatePoolModal(false);
|
||||
setShowInitializeHezPoolModal(false);
|
||||
};
|
||||
|
||||
const handleSuccess = async () => {
|
||||
// Pool modals will refresh their own data
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-green-900/30 via-yellow-900/30 to-red-900/30 border-b border-gray-800 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-green-400 via-yellow-400 to-red-400 bg-clip-text text-transparent">
|
||||
Pezkuwi DEX
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg">
|
||||
Decentralized exchange for trading tokens on PezkuwiChain
|
||||
</p>
|
||||
|
||||
{/* Wallet status */}
|
||||
{account && (
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<div className="px-4 py-2 bg-gray-900/80 rounded-lg border border-gray-800">
|
||||
<span className="text-xs text-gray-400">Connected: </span>
|
||||
<span className="text-sm font-mono text-white">
|
||||
{account.slice(0, 6)}...{account.slice(-4)}
|
||||
</span>
|
||||
</div>
|
||||
{isFounder && (
|
||||
<div className="px-4 py-2 bg-green-600/20 border border-green-600/30 rounded-lg">
|
||||
<span className="text-xs text-green-400 font-semibold">
|
||||
FOUNDER ACCESS
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{!account ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mb-4 text-gray-400 text-lg">
|
||||
Please connect your Polkadot wallet to use the DEX
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className={`grid w-full ${isFounder ? 'grid-cols-3' : 'grid-cols-2'} gap-2 bg-gray-900/50 p-1 rounded-lg mb-8`}>
|
||||
<TabsTrigger value="swap" className="flex items-center gap-2">
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Swap</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pools" className="flex items-center gap-2">
|
||||
<Droplet className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Pools</span>
|
||||
</TabsTrigger>
|
||||
{isFounder && (
|
||||
<TabsTrigger value="admin" className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Admin</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="swap" className="mt-6">
|
||||
<TokenSwap />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pools" className="mt-6">
|
||||
<PoolDashboard />
|
||||
</TabsContent>
|
||||
|
||||
{isFounder && (
|
||||
<TabsContent value="admin" className="mt-6">
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="p-6 bg-gray-900 border border-blue-900/30 rounded-lg">
|
||||
<h3 className="text-xl font-bold text-white mb-2">Token Wrapping</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Convert native HEZ to wrapped wHEZ for use in DEX pools
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowInitializeHezPoolModal(true)}
|
||||
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Wrap HEZ to wHEZ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-900 border border-gray-800 rounded-lg">
|
||||
<h3 className="text-xl font-bold text-white mb-2">Pool Management</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Create new liquidity pools for token pairs on PezkuwiChain
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCreatePool}
|
||||
className="w-full px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Create New Pool
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-900 border border-gray-800 rounded-lg">
|
||||
<h3 className="text-xl font-bold text-white mb-2">Pool Statistics</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
View detailed pool statistics in the Pools tab
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin Modals */}
|
||||
<CreatePoolModal
|
||||
isOpen={showCreatePoolModal}
|
||||
onClose={handleModalClose}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<InitializeHezPoolModal
|
||||
isOpen={showInitializeHezPoolModal}
|
||||
onClose={handleModalClose}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, AlertCircle, Loader2, CheckCircle, Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface InitializeHezPoolModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export const InitializeHezPoolModal: React.FC<InitializeHezPoolModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePolkadot();
|
||||
const { account, signer } = useWallet();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [hezAmount, setHezAmount] = useState('100000');
|
||||
|
||||
const [hezBalance, setHezBalance] = useState<string>('0');
|
||||
const [whezBalance, setWhezBalance] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setHezAmount('100000');
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch balances
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account) return;
|
||||
|
||||
try {
|
||||
// HEZ balance (native)
|
||||
const balance = await api.query.system.account(account);
|
||||
const freeBalance = balance.data.free.toString();
|
||||
setHezBalance(freeBalance);
|
||||
|
||||
// wHEZ balance (asset 0)
|
||||
const whezData = await api.query.assets.account(0, account);
|
||||
setWhezBalance(whezData.isSome ? whezData.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balances:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account]);
|
||||
|
||||
const handleWrap = async () => {
|
||||
if (!api || !isApiReady || !signer || !account) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const hezAmountRaw = BigInt(parseFloat(hezAmount) * 10 ** 12);
|
||||
|
||||
if (hezAmountRaw <= BigInt(0)) {
|
||||
setErrorMessage('Amount must be greater than zero');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hezAmountRaw > BigInt(hezBalance)) {
|
||||
setErrorMessage('Insufficient HEZ balance');
|
||||
return;
|
||||
}
|
||||
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
console.log('🔄 Wrapping HEZ to wHEZ...', {
|
||||
hezAmount,
|
||||
hezAmountRaw: hezAmountRaw.toString(),
|
||||
});
|
||||
|
||||
const wrapTx = api.tx.tokenWrapper.wrap(hezAmountRaw.toString());
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await wrapTx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError, events }) => {
|
||||
console.log('📦 Transaction status:', status.type);
|
||||
|
||||
if (status.isInBlock) {
|
||||
console.log('✅ In block:', status.asInBlock.toHex());
|
||||
|
||||
if (dispatchError) {
|
||||
let errorMsg = '';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
console.error('❌ Module error:', errorMsg);
|
||||
} else {
|
||||
errorMsg = dispatchError.toString();
|
||||
console.error('❌ Dispatch error:', errorMsg);
|
||||
}
|
||||
|
||||
setErrorMessage(errorMsg);
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Transaction Failed',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
console.log('✅ Wrap successful!');
|
||||
console.log('📋 Events:', events.map(e => e.event.method).join(', '));
|
||||
setTxStatus('success');
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: `Successfully wrapped ${hezAmount} HEZ to wHEZ`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Wrap failed:', error);
|
||||
setErrorMessage(error.message || 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'Wrap failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hezBalanceDisplay = (parseFloat(hezBalance) / 10 ** 12).toFixed(4);
|
||||
const whezBalanceDisplay = (parseFloat(whezBalance) / 10 ** 12).toFixed(4);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Wrap HEZ to wHEZ
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Badge className="bg-blue-600/20 text-blue-400 border-blue-600/30 w-fit mt-2">
|
||||
Admin Only - Token Wrapping
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Info Banner */}
|
||||
<Alert className="bg-blue-500/10 border-blue-500/30">
|
||||
<Info className="h-4 w-4 text-blue-400" />
|
||||
<AlertDescription className="text-blue-300 text-sm">
|
||||
Convert native HEZ tokens to wHEZ (wrapped HEZ) tokens for use in DEX pools.
|
||||
Ratio is always 1:1.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* HEZ Amount */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">HEZ Amount</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
Balance: {hezBalanceDisplay} HEZ
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={hezAmount}
|
||||
onChange={(e) => setHezAmount(e.target.value)}
|
||||
placeholder="1000"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-lg"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setHezAmount((parseFloat(hezBalance) / 10 ** 12).toFixed(4))}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
💡 You will receive {hezAmount} wHEZ (1:1 ratio)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current wHEZ Balance */}
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-1">Current wHEZ Balance</div>
|
||||
<div className="text-2xl font-bold text-cyan-400 font-mono">
|
||||
{whezBalanceDisplay} wHEZ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<Alert className="bg-red-500/10 border-red-500/30">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<AlertDescription className="text-red-300 text-sm">
|
||||
{errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<Alert className="bg-green-500/10 border-green-500/30">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
<AlertDescription className="text-green-300 text-sm">
|
||||
Successfully wrapped {hezAmount} HEZ to wHEZ!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="flex-1 border-gray-700 hover:bg-gray-800"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleWrap}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success'
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Wrapping...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Wrap HEZ'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, Droplet, BarChart3, Search, Plus } from 'lucide-react';
|
||||
import { PoolInfo } from '@/types/dex';
|
||||
import { fetchPools, formatTokenBalance } from '@/utils/dex';
|
||||
import { isFounderWallet } from '@/utils/auth';
|
||||
|
||||
interface PoolBrowserProps {
|
||||
onAddLiquidity?: (pool: PoolInfo) => void;
|
||||
onRemoveLiquidity?: (pool: PoolInfo) => void;
|
||||
onSwap?: (pool: PoolInfo) => void;
|
||||
onCreatePool?: () => void;
|
||||
}
|
||||
|
||||
export const PoolBrowser: React.FC<PoolBrowserProps> = ({
|
||||
onAddLiquidity,
|
||||
onRemoveLiquidity,
|
||||
onSwap,
|
||||
onCreatePool,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePolkadot();
|
||||
const { account } = useWallet();
|
||||
const [pools, setPools] = useState<PoolInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'tvl' | 'volume' | 'apr'>('tvl');
|
||||
|
||||
const isFounder = account ? isFounderWallet(account.address) : false;
|
||||
|
||||
useEffect(() => {
|
||||
const loadPools = async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const poolsData = await fetchPools(api);
|
||||
setPools(poolsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pools:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPools();
|
||||
|
||||
// Refresh pools every 10 seconds
|
||||
const interval = setInterval(loadPools, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady]);
|
||||
|
||||
const filteredPools = pools.filter((pool) => {
|
||||
if (!searchTerm) return true;
|
||||
const search = searchTerm.toLowerCase();
|
||||
return (
|
||||
pool.asset1Symbol.toLowerCase().includes(search) ||
|
||||
pool.asset2Symbol.toLowerCase().includes(search) ||
|
||||
pool.id.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
if (loading && pools.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-400">Loading pools...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with search and create */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div className="relative flex-1 w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search pools by token..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-800 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isFounder && onCreatePool && (
|
||||
<button
|
||||
onClick={onCreatePool}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Pool
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pools grid */}
|
||||
{filteredPools.length === 0 ? (
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center text-gray-400">
|
||||
{searchTerm
|
||||
? 'No pools found matching your search'
|
||||
: 'No liquidity pools available yet'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredPools.map((pool) => (
|
||||
<PoolCard
|
||||
key={pool.id}
|
||||
pool={pool}
|
||||
onAddLiquidity={onAddLiquidity}
|
||||
onRemoveLiquidity={onRemoveLiquidity}
|
||||
onSwap={onSwap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PoolCardProps {
|
||||
pool: PoolInfo;
|
||||
onAddLiquidity?: (pool: PoolInfo) => void;
|
||||
onRemoveLiquidity?: (pool: PoolInfo) => void;
|
||||
onSwap?: (pool: PoolInfo) => void;
|
||||
}
|
||||
|
||||
const PoolCard: React.FC<PoolCardProps> = ({
|
||||
pool,
|
||||
onAddLiquidity,
|
||||
onRemoveLiquidity,
|
||||
onSwap,
|
||||
}) => {
|
||||
const reserve1Display = formatTokenBalance(
|
||||
pool.reserve1,
|
||||
pool.asset1Decimals,
|
||||
2
|
||||
);
|
||||
const reserve2Display = formatTokenBalance(
|
||||
pool.reserve2,
|
||||
pool.asset2Decimals,
|
||||
2
|
||||
);
|
||||
|
||||
// Calculate exchange rate
|
||||
const rate =
|
||||
BigInt(pool.reserve1) > BigInt(0)
|
||||
? (Number(pool.reserve2) / Number(pool.reserve1)).toFixed(4)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/50 border-gray-800 hover:border-gray-700 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span className="text-green-400">{pool.asset1Symbol}</span>
|
||||
<span className="text-gray-500">/</span>
|
||||
<span className="text-yellow-400">{pool.asset2Symbol}</span>
|
||||
</CardTitle>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/20">
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Reserves */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Reserve {pool.asset1Symbol}</span>
|
||||
<span className="text-white font-mono">
|
||||
{reserve1Display} {pool.asset1Symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Reserve {pool.asset2Symbol}</span>
|
||||
<span className="text-white font-mono">
|
||||
{reserve2Display} {pool.asset2Symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exchange rate */}
|
||||
<div className="p-3 bg-gray-800/50 rounded-lg">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Exchange Rate</span>
|
||||
<span className="text-cyan-400 font-mono">
|
||||
1 {pool.asset1Symbol} = {rate} {pool.asset2Symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-gray-800">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Fee</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{pool.feeRate || '0.3'}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Volume 24h</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{pool.volume24h || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">APR</div>
|
||||
<div className="text-sm font-semibold text-green-400">
|
||||
{pool.apr7d || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="grid grid-cols-3 gap-2 pt-2">
|
||||
{onAddLiquidity && (
|
||||
<button
|
||||
onClick={() => onAddLiquidity(pool)}
|
||||
className="px-3 py-2 bg-green-600/20 hover:bg-green-600/30 text-green-400 border border-green-600/30 rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<Droplet className="w-3 h-3" />
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
{onRemoveLiquidity && (
|
||||
<button
|
||||
onClick={() => onRemoveLiquidity(pool)}
|
||||
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-600/30 rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{onSwap && (
|
||||
<button
|
||||
onClick={() => onSwap(pool)}
|
||||
className="px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
Swap
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { X, Minus, AlertCircle, Loader2, CheckCircle, Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PoolInfo } from '@/types/dex';
|
||||
import { formatTokenBalance } from '@/utils/dex';
|
||||
|
||||
interface RemoveLiquidityModalProps {
|
||||
isOpen: boolean;
|
||||
pool: PoolInfo | null;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export const RemoveLiquidityModal: React.FC<RemoveLiquidityModalProps> = ({
|
||||
isOpen,
|
||||
pool,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePolkadot();
|
||||
const { account, signer } = useWallet();
|
||||
|
||||
const [lpTokenBalance, setLpTokenBalance] = useState<string>('0');
|
||||
const [removePercentage, setRemovePercentage] = useState(25);
|
||||
const [slippage, setSlippage] = useState(1); // 1% default
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Reset form when modal closes or pool changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pool) {
|
||||
setRemovePercentage(25);
|
||||
setTxStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen, pool]);
|
||||
|
||||
// Fetch LP token balance
|
||||
useEffect(() => {
|
||||
const fetchLPBalance = async () => {
|
||||
if (!api || !isApiReady || !account || !pool) return;
|
||||
|
||||
try {
|
||||
// Get pool account
|
||||
const poolAccount = await api.query.assetConversion.pools([
|
||||
pool.asset1,
|
||||
pool.asset2,
|
||||
]);
|
||||
|
||||
if (poolAccount.isNone) {
|
||||
setLpTokenBalance('0');
|
||||
return;
|
||||
}
|
||||
|
||||
// LP token ID is derived from pool ID
|
||||
// For now, we'll query the pool's LP token supply
|
||||
// In a real implementation, you'd need to query the specific LP token for the user
|
||||
const lpAssetId = api.query.assetConversion.nextPoolAssetId
|
||||
? await api.query.assetConversion.nextPoolAssetId()
|
||||
: null;
|
||||
|
||||
// This is a simplified version - you'd need to track LP tokens properly
|
||||
setLpTokenBalance('0'); // Placeholder
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch LP balance:', error);
|
||||
setLpTokenBalance('0');
|
||||
}
|
||||
};
|
||||
|
||||
fetchLPBalance();
|
||||
}, [api, isApiReady, account, pool]);
|
||||
|
||||
const calculateOutputAmounts = () => {
|
||||
if (!pool || BigInt(lpTokenBalance) === BigInt(0)) {
|
||||
return { amount1: '0', amount2: '0' };
|
||||
}
|
||||
|
||||
// Calculate amounts based on percentage
|
||||
const lpAmount = (BigInt(lpTokenBalance) * BigInt(removePercentage)) / BigInt(100);
|
||||
|
||||
// Simplified calculation - in reality, this depends on total LP supply
|
||||
const totalLiquidity = BigInt(pool.reserve1) + BigInt(pool.reserve2);
|
||||
const userShare = lpAmount;
|
||||
|
||||
// Proportional amounts
|
||||
const amount1 = (BigInt(pool.reserve1) * userShare) / totalLiquidity;
|
||||
const amount2 = (BigInt(pool.reserve2) * userShare) / totalLiquidity;
|
||||
|
||||
return {
|
||||
amount1: amount1.toString(),
|
||||
amount2: amount2.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const handleRemoveLiquidity = async () => {
|
||||
if (!api || !isApiReady || !signer || !account || !pool) {
|
||||
setErrorMessage('Wallet not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (BigInt(lpTokenBalance) === BigInt(0)) {
|
||||
setErrorMessage('No liquidity to remove');
|
||||
return;
|
||||
}
|
||||
|
||||
const lpAmount = (BigInt(lpTokenBalance) * BigInt(removePercentage)) / BigInt(100);
|
||||
const { amount1, amount2 } = calculateOutputAmounts();
|
||||
|
||||
// Calculate minimum amounts with slippage tolerance
|
||||
const minAmount1 = (BigInt(amount1) * BigInt(100 - slippage * 100)) / BigInt(10000);
|
||||
const minAmount2 = (BigInt(amount2) * BigInt(100 - slippage * 100)) / BigInt(10000);
|
||||
|
||||
try {
|
||||
setTxStatus('signing');
|
||||
setErrorMessage('');
|
||||
|
||||
const tx = api.tx.assetConversion.removeLiquidity(
|
||||
pool.asset1,
|
||||
pool.asset2,
|
||||
lpAmount.toString(),
|
||||
minAmount1.toString(),
|
||||
minAmount2.toString(),
|
||||
account
|
||||
);
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await tx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
|
||||
} else {
|
||||
setErrorMessage(dispatchError.toString());
|
||||
}
|
||||
setTxStatus('error');
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Remove liquidity failed:', error);
|
||||
setErrorMessage(error.message || 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !pool) return null;
|
||||
|
||||
const { amount1, amount2 } = calculateOutputAmounts();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<Card className="bg-gray-900 border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">
|
||||
Remove Liquidity
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
{pool.asset1Symbol} / {pool.asset2Symbol} Pool
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-blue-400">
|
||||
Remove liquidity to receive your tokens back. You'll burn LP tokens in proportion to your withdrawal.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* LP Token Balance */}
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-1">Your LP Tokens</div>
|
||||
<div className="text-2xl font-bold text-white font-mono">
|
||||
{formatTokenBalance(lpTokenBalance, 12, 6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Percentage Selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-400">Remove Amount</label>
|
||||
<span className="text-lg font-bold text-white">{removePercentage}%</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value={removePercentage}
|
||||
onChange={(e) => setRemovePercentage(Number(e.target.value))}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[25, 50, 75, 100].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setRemovePercentage(value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
removePercentage === value
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
{value}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center">
|
||||
<Minus className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Preview */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400 mb-2">You will receive</div>
|
||||
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">{pool.asset1Symbol}</span>
|
||||
<span className="text-white font-mono text-lg">
|
||||
{formatTokenBalance(amount1, pool.asset1Decimals, 6)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">{pool.asset2Symbol}</span>
|
||||
<span className="text-white font-mono text-lg">
|
||||
{formatTokenBalance(amount2, pool.asset2Decimals, 6)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slippage Tolerance */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Slippage Tolerance</label>
|
||||
<div className="flex gap-2">
|
||||
{[0.5, 1, 2].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setSlippage(value)}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
slippage === value
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
{value}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{txStatus === 'success' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-green-400">
|
||||
Liquidity removed successfully!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemoveLiquidity}
|
||||
className="flex-1 px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors font-medium flex items-center justify-center gap-2"
|
||||
disabled={
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting' ||
|
||||
txStatus === 'success' ||
|
||||
BigInt(lpTokenBalance) === BigInt(0)
|
||||
}
|
||||
>
|
||||
{txStatus === 'signing' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Removing...
|
||||
</>
|
||||
)}
|
||||
{txStatus === 'idle' && 'Remove Liquidity'}
|
||||
{txStatus === 'error' && 'Retry'}
|
||||
{txStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Success
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,641 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { ArrowDownUp, AlertCircle, Loader2, CheckCircle, Info, Settings, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { PoolInfo } from '@/types/dex';
|
||||
import {
|
||||
parseTokenInput,
|
||||
formatTokenBalance,
|
||||
getAmountOut,
|
||||
calculatePriceImpact,
|
||||
} from '@/utils/dex';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface SwapInterfaceProps {
|
||||
initialPool?: PoolInfo | null;
|
||||
pools: PoolInfo[];
|
||||
}
|
||||
|
||||
type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error';
|
||||
|
||||
// User-facing tokens (wHEZ is hidden from users, shown as HEZ)
|
||||
const USER_TOKENS = [
|
||||
{ symbol: 'HEZ', emoji: '🟡', assetId: 0, name: 'HEZ', decimals: 12, displaySymbol: 'HEZ' }, // actually wHEZ (asset 0)
|
||||
{ symbol: 'PEZ', emoji: '🟣', assetId: 1, name: 'PEZ', decimals: 12, displaySymbol: 'PEZ' },
|
||||
{ symbol: 'USDT', emoji: '💵', assetId: 2, name: 'USDT', decimals: 6, displaySymbol: 'USDT' },
|
||||
] as const;
|
||||
|
||||
export const SwapInterface: React.FC<SwapInterfaceProps> = ({ initialPool, pools }) => {
|
||||
const { api, isApiReady } = usePolkadot();
|
||||
const { account, signer } = useWallet();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [fromToken, setFromToken] = useState('HEZ');
|
||||
const [toToken, setToToken] = useState('PEZ');
|
||||
const [fromAmount, setFromAmount] = useState('');
|
||||
const [toAmount, setToAmount] = useState('');
|
||||
const [slippage, setSlippage] = useState(0.5); // 0.5% default
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const [fromBalance, setFromBalance] = useState<string>('0');
|
||||
const [toBalance, setToBalance] = useState<string>('0');
|
||||
|
||||
const [txStatus, setTxStatus] = useState<TransactionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// Get asset IDs (for pool lookup)
|
||||
const getAssetId = (symbol: string) => {
|
||||
const token = USER_TOKENS.find(t => t.symbol === symbol);
|
||||
return token?.assetId ?? null;
|
||||
};
|
||||
|
||||
const fromAssetId = getAssetId(fromToken);
|
||||
const toAssetId = getAssetId(toToken);
|
||||
|
||||
// Find active pool for selected pair
|
||||
const activePool = pools.find(
|
||||
(p) =>
|
||||
(p.asset1 === fromAssetId && p.asset2 === toAssetId) ||
|
||||
(p.asset1 === toAssetId && p.asset2 === fromAssetId)
|
||||
);
|
||||
|
||||
// Get token info
|
||||
const fromTokenInfo = USER_TOKENS.find(t => t.symbol === fromToken);
|
||||
const toTokenInfo = USER_TOKENS.find(t => t.symbol === toToken);
|
||||
|
||||
// Fetch balances
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!api || !isApiReady || !account) return;
|
||||
|
||||
// For HEZ, fetch native balance (not wHEZ asset balance)
|
||||
if (fromToken === 'HEZ') {
|
||||
try {
|
||||
const balance = await api.query.system.account(account);
|
||||
const freeBalance = balance.data.free.toString();
|
||||
setFromBalance(freeBalance);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch HEZ balance:', error);
|
||||
setFromBalance('0');
|
||||
}
|
||||
} else if (fromAssetId !== null) {
|
||||
try {
|
||||
const balanceData = await api.query.assets.account(fromAssetId, account);
|
||||
setFromBalance(balanceData.isSome ? balanceData.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch from balance:', error);
|
||||
setFromBalance('0');
|
||||
}
|
||||
}
|
||||
|
||||
// For HEZ, fetch native balance
|
||||
if (toToken === 'HEZ') {
|
||||
try {
|
||||
const balance = await api.query.system.account(account);
|
||||
const freeBalance = balance.data.free.toString();
|
||||
setToBalance(freeBalance);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch HEZ balance:', error);
|
||||
setToBalance('0');
|
||||
}
|
||||
} else if (toAssetId !== null) {
|
||||
try {
|
||||
const balanceData = await api.query.assets.account(toAssetId, account);
|
||||
setToBalance(balanceData.isSome ? balanceData.unwrap().balance.toString() : '0');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch to balance:', error);
|
||||
setToBalance('0');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [api, isApiReady, account, fromToken, toToken, fromAssetId, toAssetId]);
|
||||
|
||||
// Calculate output amount when input changes
|
||||
useEffect(() => {
|
||||
if (!fromAmount || !activePool || !fromTokenInfo || !toTokenInfo) {
|
||||
setToAmount('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fromAmountRaw = parseTokenInput(fromAmount, fromTokenInfo.decimals);
|
||||
|
||||
// Determine direction and calculate output
|
||||
const isForward = activePool.asset1 === fromAssetId;
|
||||
const reserveIn = isForward ? activePool.reserve1 : activePool.reserve2;
|
||||
const reserveOut = isForward ? activePool.reserve2 : activePool.reserve1;
|
||||
|
||||
const toAmountRaw = getAmountOut(fromAmountRaw, reserveIn, reserveOut, 30); // 3% fee
|
||||
const toAmountDisplay = formatTokenBalance(toAmountRaw, toTokenInfo.decimals, 6);
|
||||
|
||||
setToAmount(toAmountDisplay);
|
||||
} catch (error) {
|
||||
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]);
|
||||
|
||||
// Check if user has insufficient balance
|
||||
const hasInsufficientBalance = React.useMemo(() => {
|
||||
const fromAmountNum = parseFloat(fromAmount || '0');
|
||||
const fromBalanceNum = parseFloat(formatTokenBalance(fromBalance, fromTokenInfo?.decimals ?? 12, 6));
|
||||
return fromAmountNum > 0 && fromAmountNum > fromBalanceNum;
|
||||
}, [fromAmount, fromBalance, fromTokenInfo]);
|
||||
|
||||
const handleSwapDirection = () => {
|
||||
const tempToken = fromToken;
|
||||
const tempAmount = fromAmount;
|
||||
const tempBalance = fromBalance;
|
||||
|
||||
setFromToken(toToken);
|
||||
setToToken(tempToken);
|
||||
setFromAmount(toAmount);
|
||||
setFromBalance(toBalance);
|
||||
setToBalance(tempBalance);
|
||||
};
|
||||
|
||||
const handleMaxClick = () => {
|
||||
if (fromTokenInfo) {
|
||||
const maxAmount = formatTokenBalance(fromBalance, fromTokenInfo.decimals, 6);
|
||||
setFromAmount(maxAmount);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSwap = async () => {
|
||||
if (!api || !signer || !account || !fromTokenInfo || !toTokenInfo) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activePool) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'No liquidity pool available for this pair',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTxStatus('signing');
|
||||
setShowConfirm(false);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const amountIn = parseTokenInput(fromAmount, fromTokenInfo.decimals);
|
||||
const minAmountOut = parseTokenInput(
|
||||
(parseFloat(toAmount) * (1 - slippage / 100)).toString(),
|
||||
toTokenInfo.decimals
|
||||
);
|
||||
|
||||
console.log('💰 Swap transaction:', {
|
||||
from: fromToken,
|
||||
to: toToken,
|
||||
amount: fromAmount,
|
||||
minOut: minAmountOut.toString(),
|
||||
});
|
||||
|
||||
let tx;
|
||||
|
||||
if (fromToken === 'HEZ' && toToken === 'PEZ') {
|
||||
// HEZ → PEZ: wrap(HEZ→wHEZ) then swap(wHEZ→PEZ)
|
||||
const wrapTx = api.tx.tokenWrapper.wrap(amountIn.toString());
|
||||
const swapTx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[0, 1], // wHEZ → PEZ
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
tx = api.tx.utility.batchAll([wrapTx, swapTx]);
|
||||
|
||||
} else if (fromToken === 'PEZ' && toToken === 'HEZ') {
|
||||
// PEZ → HEZ: swap(PEZ→wHEZ) then unwrap(wHEZ→HEZ)
|
||||
const swapTx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[1, 0], // PEZ → wHEZ
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
const unwrapTx = api.tx.tokenWrapper.unwrap(minAmountOut.toString());
|
||||
tx = api.tx.utility.batchAll([swapTx, unwrapTx]);
|
||||
|
||||
} else if (fromToken === 'HEZ') {
|
||||
// HEZ → Any Asset: wrap(HEZ→wHEZ) then swap(wHEZ→Asset)
|
||||
const wrapTx = api.tx.tokenWrapper.wrap(amountIn.toString());
|
||||
const swapTx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[0, toAssetId!], // wHEZ → target asset
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
tx = api.tx.utility.batchAll([wrapTx, swapTx]);
|
||||
|
||||
} else if (toToken === 'HEZ') {
|
||||
// Any Asset → HEZ: swap(Asset→wHEZ) then unwrap(wHEZ→HEZ)
|
||||
const swapTx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[fromAssetId!, 0], // source asset → wHEZ
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
const unwrapTx = api.tx.tokenWrapper.unwrap(minAmountOut.toString());
|
||||
tx = api.tx.utility.batchAll([swapTx, unwrapTx]);
|
||||
|
||||
} else {
|
||||
// Direct swap between assets (PEZ ↔ USDT, etc.)
|
||||
tx = api.tx.assetConversion.swapExactTokensForTokens(
|
||||
[fromAssetId!, toAssetId!],
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
account,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
setTxStatus('submitting');
|
||||
|
||||
await tx.signAndSend(
|
||||
account,
|
||||
{ signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
|
||||
} else {
|
||||
setErrorMessage(dispatchError.toString());
|
||||
}
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Transaction Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: `Swapped ${fromAmount} ${fromToken} for ~${toAmount} ${toToken}`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
setFromAmount('');
|
||||
setToAmount('');
|
||||
setTxStatus('idle');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Swap failed:', error);
|
||||
setErrorMessage(error.message || 'Transaction failed');
|
||||
setTxStatus('error');
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'Swap transaction failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exchangeRate = activePool && fromTokenInfo && toTokenInfo
|
||||
? (
|
||||
parseFloat(formatTokenBalance(activePool.reserve2, toTokenInfo.decimals, 6)) /
|
||||
parseFloat(formatTokenBalance(activePool.reserve1, fromTokenInfo.decimals, 6))
|
||||
).toFixed(6)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
{/* Transaction Loading Overlay */}
|
||||
{(txStatus === 'signing' || txStatus === 'submitting') && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-16 h-16 animate-spin text-green-400" />
|
||||
<p className="text-white text-xl font-semibold">
|
||||
{txStatus === 'signing' ? 'Waiting for signature...' : 'Processing swap...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-white">Swap Tokens</CardTitle>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowSettings(true)}>
|
||||
<Settings className="h-5 w-5 text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
{!account && (
|
||||
<Alert className="bg-yellow-500/10 border-yellow-500/30">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
<AlertDescription className="text-yellow-300">
|
||||
Please connect your wallet to swap tokens
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* From Token */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">From</span>
|
||||
<span className="text-gray-400">
|
||||
Balance: {formatTokenBalance(fromBalance, fromTokenInfo?.decimals ?? 12, 4)} {fromToken}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
value={fromAmount}
|
||||
onChange={(e) => setFromAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="text-2xl font-bold border-0 bg-transparent text-white placeholder:text-gray-600 focus-visible:ring-0"
|
||||
disabled={!account}
|
||||
/>
|
||||
<Select
|
||||
value={fromToken}
|
||||
onValueChange={(value) => {
|
||||
setFromToken(value);
|
||||
if (value === toToken) {
|
||||
const otherToken = USER_TOKENS.find(t => t.symbol !== value);
|
||||
if (otherToken) setToToken(otherToken.symbol);
|
||||
}
|
||||
}}
|
||||
disabled={!account}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] border-gray-600 bg-gray-900">
|
||||
<SelectValue>
|
||||
{(() => {
|
||||
const token = USER_TOKENS.find(t => t.symbol === fromToken);
|
||||
return <span className="flex items-center gap-2">{token?.emoji} {token?.displaySymbol}</span>;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-gray-700">
|
||||
{USER_TOKENS.map((token) => (
|
||||
<SelectItem key={token.symbol} value={token.symbol}>
|
||||
<span className="flex items-center gap-2">{token.emoji} {token.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleMaxClick}
|
||||
className="px-3 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded border border-green-600/30 transition-colors"
|
||||
disabled={!account}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swap Direction Button */}
|
||||
<div className="flex justify-center -my-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwapDirection}
|
||||
className="rounded-full bg-gray-800 border-2 border-gray-700 hover:bg-gray-700"
|
||||
disabled={!account}
|
||||
>
|
||||
<ArrowDownUp className="h-5 w-5 text-gray-300" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* To Token */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">To</span>
|
||||
<span className="text-gray-400">
|
||||
Balance: {formatTokenBalance(toBalance, toTokenInfo?.decimals ?? 12, 4)} {toToken}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={toAmount}
|
||||
readOnly
|
||||
placeholder="0.0"
|
||||
className="text-2xl font-bold border-0 bg-transparent text-white placeholder:text-gray-600 focus-visible:ring-0"
|
||||
/>
|
||||
<Select
|
||||
value={toToken}
|
||||
onValueChange={(value) => {
|
||||
setToToken(value);
|
||||
if (value === fromToken) {
|
||||
const otherToken = USER_TOKENS.find(t => t.symbol !== value);
|
||||
if (otherToken) setFromToken(otherToken.symbol);
|
||||
}
|
||||
}}
|
||||
disabled={!account}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] border-gray-600 bg-gray-900">
|
||||
<SelectValue>
|
||||
{(() => {
|
||||
const token = USER_TOKENS.find(t => t.symbol === toToken);
|
||||
return <span className="flex items-center gap-2">{token?.emoji} {token?.displaySymbol}</span>;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-gray-700">
|
||||
{USER_TOKENS.map((token) => (
|
||||
<SelectItem key={token.symbol} value={token.symbol}>
|
||||
<span className="flex items-center gap-2">{token.emoji} {token.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swap Details */}
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400 flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
Exchange Rate
|
||||
</span>
|
||||
<span className="text-white">
|
||||
{activePool ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : 'No pool available'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{fromAmount && parseFloat(fromAmount) > 0 && priceImpact > 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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{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">
|
||||
Insufficient {fromToken} balance
|
||||
</AlertDescription>
|
||||
</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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Swap Button */}
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={
|
||||
!account ||
|
||||
!fromAmount ||
|
||||
parseFloat(fromAmount) <= 0 ||
|
||||
!activePool ||
|
||||
hasInsufficientBalance ||
|
||||
txStatus === 'signing' ||
|
||||
txStatus === 'submitting'
|
||||
}
|
||||
>
|
||||
{!account
|
||||
? 'Connect Wallet'
|
||||
: hasInsufficientBalance
|
||||
? `Insufficient ${fromToken} Balance`
|
||||
: !activePool
|
||||
? 'No Pool Available'
|
||||
: 'Swap Tokens'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Swap Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-300">Slippage Tolerance</label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{[0.1, 0.5, 1.0, 2.0].map(val => (
|
||||
<Button
|
||||
key={val}
|
||||
variant={slippage === val ? 'default' : 'outline'}
|
||||
onClick={() => setSlippage(val)}
|
||||
className="flex-1"
|
||||
>
|
||||
{val}%
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Confirm Swap</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">You Pay</span>
|
||||
<span className="font-bold text-white">{fromAmount} {fromToken}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-300">You Receive</span>
|
||||
<span className="font-bold text-white">{toAmount} {toToken}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-gray-700">
|
||||
<span className="text-gray-400">Exchange Rate</span>
|
||||
<span className="text-gray-400">1 {fromToken} = {exchangeRate} {toToken}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Slippage</span>
|
||||
<span className="text-gray-400">{slippage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleConfirmSwap}
|
||||
disabled={txStatus === 'signing' || txStatus === 'submitting'}
|
||||
>
|
||||
{txStatus === 'signing' ? 'Signing...' : txStatus === 'submitting' ? 'Swapping...' : 'Confirm Swap'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user