Files
pezkuwi-telegram-miniapp/src/components/wallet/SwapModal.tsx
T

476 lines
17 KiB
TypeScript

/**
* Swap Modal Component
* Mobile-optimized token swap interface for Telegram miniapp
*/
import { useState, useEffect, useCallback } from 'react';
import { X, ArrowDownUp, RefreshCw, AlertCircle, Check } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { KurdistanSun } from '@/components/KurdistanSun';
interface SwapModalProps {
isOpen: boolean;
onClose: () => void;
}
// Token configuration
const TOKENS = [
{ symbol: 'HEZ', name: 'Hezkurd', assetId: -1, decimals: 12, icon: '/tokens/HEZ.png' },
{ symbol: 'PEZ', name: 'Pezkuwi', assetId: 1, decimals: 12, icon: '/tokens/PEZ.png' },
{ symbol: 'USDT', name: 'Tether', assetId: 1000, decimals: 6, icon: '/tokens/USDT.png' },
{ symbol: 'DOT', name: 'Polkadot', assetId: 1001, decimals: 10, icon: '/tokens/DOT.png' },
];
// Native token ID for relay chain HEZ
const NATIVE_TOKEN_ID = -1;
// Helper to convert asset ID to XCM Location format
const formatAssetLocation = (id: number) => {
if (id === NATIVE_TOKEN_ID) {
return { parents: 1, interior: 'Here' };
}
return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } };
};
export function SwapModal({ isOpen, onClose }: SwapModalProps) {
const { assetHubApi, keypair } = useWallet();
const { hapticImpact, hapticNotification } = useTelegram();
const [fromToken, setFromToken] = useState(TOKENS[0]); // HEZ
const [toToken, setToToken] = useState(TOKENS[1]); // PEZ
const [fromAmount, setFromAmount] = useState('');
const [toAmount, setToAmount] = useState('');
const [exchangeRate, setExchangeRate] = useState<number | null>(null);
const [isLoadingRate, setIsLoadingRate] = useState(false);
const [isSwapping, setIsSwapping] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Token balances
const [balances, setBalances] = useState<Record<string, string>>({
HEZ: '0',
PEZ: '0',
USDT: '0',
DOT: '0',
});
// Fetch balances from Asset Hub (where swaps happen)
useEffect(() => {
if (!isOpen || !assetHubApi || !keypair) return;
// Reset state when modal opens
setError('');
setFromAmount('');
setToAmount('');
const fetchBalances = async () => {
try {
// HEZ balance from Asset Hub (native token for fees and swaps)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hezAccount = await (assetHubApi.query.system as any).account(keypair.address);
const hezFree = hezAccount.data.free.toString();
const hezBalance = (parseInt(hezFree) / 1e12).toFixed(4);
// Helper to extract balance from asset query result
const getAssetBalance = (result: any, decimals: number, fractionDigits: number): string => {
if (!result || result.isEmpty) return '0'.padEnd(fractionDigits + 2, '0');
const data = result.isSome ? result.unwrap().toJSON() : result.toJSON();
if (data && data.balance) {
return (parseInt(data.balance.toString()) / Math.pow(10, decimals)).toFixed(
fractionDigits
);
}
return '0'.padEnd(fractionDigits + 2, '0');
};
// PEZ balance (Asset 1)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pezResult = await (assetHubApi.query.assets as any).account(1, keypair.address);
const pezBalance = getAssetBalance(pezResult, 12, 4);
// USDT balance (Asset 1000)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const usdtResult = await (assetHubApi.query.assets as any).account(1000, keypair.address);
const usdtBalance = getAssetBalance(usdtResult, 6, 2);
// DOT balance (Asset 1001, 10 decimals)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dotResult = await (assetHubApi.query.assets as any).account(1001, keypair.address);
const dotBalance = getAssetBalance(dotResult, 10, 4);
// Update all balances at once
setBalances({
HEZ: hezBalance,
PEZ: pezBalance,
USDT: usdtBalance,
DOT: dotBalance,
});
} catch (err) {
console.error('Failed to fetch balances:', err);
}
};
fetchBalances();
}, [assetHubApi, keypair, isOpen]);
// Fetch exchange rate
const fetchExchangeRate = useCallback(async () => {
if (!assetHubApi || fromToken.symbol === toToken.symbol) {
setExchangeRate(null);
return;
}
setIsLoadingRate(true);
try {
// Sort assets for pool query (native token first)
const fromId = fromToken.assetId;
const toId = toToken.assetId;
const [asset1, asset2] =
fromId === NATIVE_TOKEN_ID
? [fromId, toId]
: toId === NATIVE_TOKEN_ID
? [toId, fromId]
: fromId < toId
? [fromId, toId]
: [toId, fromId];
const poolKey = [formatAssetLocation(asset1), formatAssetLocation(asset2)];
// Check if pool exists
const poolInfo = await assetHubApi.query.assetConversion.pools(poolKey);
if (poolInfo && !poolInfo.isEmpty) {
// Get quote from runtime API
// USDT has 6 decimals, DOT has 10 decimals, others have 12
const getDecimals = (id: number) => {
if (id === 1000) return 6; // USDT
if (id === 1001) return 10; // DOT
return 12; // HEZ, PEZ
};
const decimals1 = getDecimals(asset1);
const decimals2 = getDecimals(asset2);
const oneUnit = BigInt(Math.pow(10, decimals1));
const quote = await (
assetHubApi.call as any
).assetConversionApi.quotePriceExactTokensForTokens(
formatAssetLocation(asset1),
formatAssetLocation(asset2),
oneUnit.toString(),
true
);
if (quote && !quote.isNone) {
const priceRaw = quote.unwrap().toString();
const price = Number(BigInt(priceRaw)) / Math.pow(10, decimals2);
// Calculate rate based on direction
const rate = fromId === asset1 ? price : 1 / price;
setExchangeRate(rate);
} else {
setExchangeRate(null);
}
} else {
setExchangeRate(null);
}
} catch (err) {
console.error('Failed to fetch exchange rate:', err);
setExchangeRate(null);
} finally {
setIsLoadingRate(false);
}
}, [assetHubApi, fromToken, toToken]);
// Fetch rate when tokens change
useEffect(() => {
if (isOpen) {
fetchExchangeRate();
}
}, [isOpen, fetchExchangeRate]);
// Calculate toAmount when fromAmount or rate changes
useEffect(() => {
if (fromAmount && exchangeRate) {
const calculated = parseFloat(fromAmount) * exchangeRate;
setToAmount(calculated.toFixed(toToken.decimals === 6 ? 2 : 4));
} else {
setToAmount('');
}
}, [fromAmount, exchangeRate, toToken.decimals]);
// Swap tokens
const handleSwapTokens = () => {
hapticImpact('light');
const temp = fromToken;
setFromToken(toToken);
setToToken(temp);
setFromAmount('');
setToAmount('');
};
// Execute swap
const handleSwap = async () => {
if (!assetHubApi || !keypair || !fromAmount || !exchangeRate) return;
const fromBalance = parseFloat(balances[fromToken.symbol] || '0');
const swapAmount = parseFloat(fromAmount);
if (swapAmount > fromBalance) {
setError('Bakiye têrê nake');
hapticNotification('error');
return;
}
setIsSwapping(true);
setError('');
try {
const amountIn = BigInt(Math.floor(swapAmount * Math.pow(10, fromToken.decimals)));
const minAmountOut = BigInt(
Math.floor(parseFloat(toAmount) * 0.95 * Math.pow(10, toToken.decimals))
); // 5% slippage
// Build swap path using XCM Locations
const fromLocation = formatAssetLocation(fromToken.assetId);
const toLocation = formatAssetLocation(toToken.assetId);
// Check if we need multi-hop (e.g., PEZ -> USDT needs PEZ -> HEZ -> USDT)
let path;
if (fromToken.assetId !== NATIVE_TOKEN_ID && toToken.assetId !== NATIVE_TOKEN_ID) {
// Multi-hop through native token
const nativeLocation = formatAssetLocation(NATIVE_TOKEN_ID);
path = [fromLocation, nativeLocation, toLocation];
} else {
path = [fromLocation, toLocation];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (assetHubApi.tx.assetConversion as any).swapExactTokensForTokens(
path,
amountIn.toString(),
minAmountOut.toString(),
keypair.address,
true
);
// Wait for transaction to be finalized
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
keypair,
({ status, dispatchError }: { status: any; dispatchError: any }) => {
if (status.isFinalized) {
if (dispatchError) {
let errorMsg = 'Swap neserketî';
if (dispatchError.isModule) {
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else if (dispatchError.toString) {
errorMsg = dispatchError.toString();
}
console.error('Swap error:', errorMsg);
reject(new Error(errorMsg));
} else {
resolve();
}
}
}
).catch(reject);
});
setSuccess(true);
hapticNotification('success');
// Reset after success
setTimeout(() => {
setSuccess(false);
setFromAmount('');
setToAmount('');
onClose();
}, 2000);
} catch (err) {
console.error('Swap failed:', err);
setError(err instanceof Error ? err.message : 'Swap neserketî');
hapticNotification('error');
} finally {
setIsSwapping(false);
}
};
if (!isOpen) return null;
if (success) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl p-6 text-center space-y-4">
<div className="w-16 h-16 mx-auto bg-green-500/20 rounded-full flex items-center justify-center">
<Check className="w-8 h-8 text-green-500" />
</div>
<h2 className="text-xl font-semibold">Swap Serketî!</h2>
<p className="text-muted-foreground">
{fromAmount} {fromToken.symbol} {toAmount} {toToken.symbol}
</p>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">Token Swap</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* From Token */}
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Ji (From)</span>
<select
value={fromToken.symbol}
onChange={(e) => {
const token = TOKENS.find((t) => t.symbol === e.target.value);
if (token) {
if (token.symbol === toToken.symbol) {
setToToken(fromToken);
}
setFromToken(token);
}
}}
className="bg-background border border-border rounded-lg px-3 py-1.5 text-sm font-medium"
>
{TOKENS.map((t) => (
<option key={t.symbol} value={t.symbol}>
{t.symbol}
</option>
))}
</select>
</div>
<input
type="number"
value={fromAmount}
onChange={(e) => setFromAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-transparent text-2xl font-bold outline-none"
/>
<div className="flex justify-between items-center text-sm">
<button
onClick={() => setFromAmount(balances[fromToken.symbol])}
className="text-primary hover:underline"
>
Max
</button>
<span className="text-muted-foreground">
Bakiye: {balances[fromToken.symbol]} {fromToken.symbol}
</span>
</div>
</div>
{/* Swap Button */}
<div className="flex justify-center">
<button
onClick={handleSwapTokens}
className="p-2 bg-muted rounded-full hover:bg-muted/80 transition-colors"
>
<ArrowDownUp className="w-5 h-5" />
</button>
</div>
{/* To Token */}
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Bo (To)</span>
<select
value={toToken.symbol}
onChange={(e) => {
const token = TOKENS.find((t) => t.symbol === e.target.value);
if (token) {
if (token.symbol === fromToken.symbol) {
setFromToken(toToken);
}
setToToken(token);
}
}}
className="bg-background border border-border rounded-lg px-3 py-1.5 text-sm font-medium"
>
{TOKENS.map((t) => (
<option key={t.symbol} value={t.symbol}>
{t.symbol}
</option>
))}
</select>
</div>
<input
type="text"
value={toAmount}
readOnly
placeholder="0.00"
className="w-full bg-transparent text-2xl font-bold outline-none text-muted-foreground"
/>
<div className="flex justify-end text-sm">
<span className="text-muted-foreground">
Bakiye: {balances[toToken.symbol]} {toToken.symbol}
</span>
</div>
</div>
{/* Exchange Rate */}
<div className="bg-muted/30 rounded-xl p-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Rêjeya Guherandinê</span>
<span className="flex items-center gap-2">
{isLoadingRate ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : exchangeRate ? (
`1 ${fromToken.symbol} = ${exchangeRate.toFixed(4)} ${toToken.symbol}`
) : (
<span className="text-yellow-500">Pool tune</span>
)}
<button onClick={fetchExchangeRate} className="p-1 hover:bg-muted rounded">
<RefreshCw className="w-3 h-3" />
</button>
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Slippage</span>
<span>5%</span>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{/* Swap Button */}
{/* Swap Button or Loading Animation */}
{isSwapping ? (
<div className="flex flex-col items-center justify-center py-4 space-y-3">
<KurdistanSun size={80} />
<p className="text-sm text-muted-foreground animate-pulse"> guhertin...</p>
</div>
) : (
<button
onClick={handleSwap}
disabled={!fromAmount || !exchangeRate || parseFloat(fromAmount) <= 0}
className="w-full py-4 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
>
{!exchangeRate ? 'Pool Tune' : 'Swap Bike'}
</button>
)}
</div>
</div>
</div>
);
}