/** * LP Staking Modal for Telegram Mini App * Allows users to stake LP tokens and earn PEZ rewards */ import { useState, useEffect } from 'react'; import { X, Lock, Unlock, Gift, AlertCircle, Loader2 } from 'lucide-react'; import { useWallet } from '@/contexts/WalletContext'; import { useTelegram } from '@/hooks/useTelegram'; import { useTranslation } from '@/i18n'; import { cn } from '@/lib/utils'; interface StakingPool { poolId: number; stakedAsset: string; rewardAsset: string; rewardRatePerBlock: string; totalStaked: string; userStaked: string; pendingRewards: string; lpTokenId: number; lpBalance: string; } interface LPStakingModalProps { isOpen: boolean; onClose: () => void; } // Native token ID for Asset Hub const NATIVE_TOKEN_ID = -1; // Pool pairs for staking pools (staking pool ID -> [asset0, asset1]) const STAKING_POOL_PAIRS: Record = { 0: [NATIVE_TOKEN_ID, 1], // HEZ-PEZ 1: [NATIVE_TOKEN_ID, 1000], // HEZ-USDT 2: [NATIVE_TOKEN_ID, 1001], // HEZ-DOT }; const LP_TOKEN_NAMES: Record = { 0: 'HEZ-PEZ LP', 1: 'HEZ-USDT LP', 2: 'HEZ-DOT LP', }; // Format asset location for assetConversion queries function formatAssetLocation(assetId: number): object { if (assetId === NATIVE_TOKEN_ID) { return { parents: 0, interior: 'Here' }; } return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: assetId }] }, }; } export function LPStakingModal({ isOpen, onClose }: LPStakingModalProps) { const { assetHubApi, keypair, address } = useWallet(); const { hapticImpact, hapticNotification, showAlert } = useTelegram(); const { t } = useTranslation(); const [pools, setPools] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); const [selectedPool, setSelectedPool] = useState(null); const [stakeAmount, setStakeAmount] = useState(''); const [unstakeAmount, setUnstakeAmount] = useState(''); const [activeTab, setActiveTab] = useState<'stake' | 'unstake' | 'rewards'>('stake'); useEffect(() => { if (!assetHubApi || !isOpen) return; const fetchPools = async () => { setIsLoading(true); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const poolEntries = await (assetHubApi.query.assetRewards as any)?.pools?.entries(); if (!poolEntries) { setError(t('lpStaking.palletNotReady')); setIsLoading(false); return; } const stakingPools: StakingPool[] = []; for (const [key, value] of poolEntries) { const poolId = parseInt(key.args[0].toString()); // eslint-disable-next-line @typescript-eslint/no-explicit-any const poolData = value.toJSON() as any; // Get LP token ID from assetConversion pool let lpTokenId = poolId; // fallback const poolPair = STAKING_POOL_PAIRS[poolId]; if (poolPair) { try { const poolKey = [formatAssetLocation(poolPair[0]), formatAssetLocation(poolPair[1])]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const poolInfo = await (assetHubApi.query.assetConversion as any).pools(poolKey); // eslint-disable-next-line @typescript-eslint/no-explicit-any if (poolInfo && !(poolInfo as any).isEmpty) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const conversionPoolData = (poolInfo as any).unwrap().toJSON() as { lpToken: number; }; lpTokenId = conversionPoolData.lpToken; } } catch { // Use fallback lpTokenId } } let userStaked = '0'; let pendingRewards = '0'; let lpBalance = '0'; if (address) { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const stakeInfo = await (assetHubApi.query.assetRewards as any).poolStakers( poolId, address ); if (stakeInfo && !stakeInfo.isEmpty && !stakeInfo.isNone) { const stakeData = stakeInfo.isSome ? stakeInfo.unwrap().toJSON() : stakeInfo.toJSON(); userStaked = stakeData.amount?.toString() || '0'; pendingRewards = stakeData.rewards?.toString() || '0'; } } catch { // Ignore stake info errors } // Fetch LP balance from poolAssets using correct lpTokenId try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const lpBal = await (assetHubApi.query.poolAssets as any).account(lpTokenId, address); if (lpBal) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const lpData = (lpBal as any).isSome ? // eslint-disable-next-line @typescript-eslint/no-explicit-any (lpBal as any).unwrap().toJSON() : lpBal.toJSON(); if (lpData && lpData.balance) { lpBalance = lpData.balance.toString(); } } } catch { // Ignore LP balance errors } } stakingPools.push({ poolId, stakedAsset: LP_TOKEN_NAMES[poolId] || `LP Token #${lpTokenId}`, rewardAsset: 'PEZ', rewardRatePerBlock: poolData.rewardRatePerBlock || '0', totalStaked: poolData.totalTokensStaked || '0', userStaked, pendingRewards, lpTokenId, lpBalance, }); } setPools(stakingPools); if (stakingPools.length > 0 && selectedPool === null) { setSelectedPool(stakingPools[0].poolId); } } catch (err) { console.error('Error fetching staking pools:', err); setError(t('lpStaking.poolsNotLoaded')); } finally { setIsLoading(false); } }; fetchPools(); // `t` is intentionally omitted: it is only used for error messages and its // identity changes only on language switch — including it would re-run the // blockchain fetch (extra RPC calls) on every language change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [assetHubApi, isOpen, address, selectedPool]); const formatAmount = (amount: string, decimals: number = 12): string => { const value = Number(amount) / Math.pow(10, decimals); return value.toLocaleString(undefined, { maximumFractionDigits: 4 }); }; const handleStake = async () => { if (!assetHubApi || !keypair || selectedPool === null || !stakeAmount) return; setIsProcessing(true); setError(null); try { const pool = pools.find((p) => p.poolId === selectedPool); if (!pool) throw new Error('Pool not found'); const amountBN = BigInt(Math.floor(parseFloat(stakeAmount) * 1e12)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const tx = (assetHubApi.tx.assetRewards as any).stake(selectedPool, amountBN.toString()); await new Promise((resolve, reject) => { tx.signAndSend( keypair, ({ status, dispatchError, }: { status: { isFinalized: boolean }; // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatchError?: { isModule: boolean; asModule: any; toString: () => string }; }) => { if (status.isFinalized) { if (dispatchError) { let errorMsg = t('lpStaking.stakeFailed'); if (dispatchError.isModule) { const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); errorMsg = `${decoded.section}.${decoded.name}`; } reject(new Error(errorMsg)); } else { resolve(); } } } ).catch(reject); }); hapticNotification('success'); showAlert(t('lpStaking.stakeSuccess')); setStakeAmount(''); setTimeout(() => { onClose(); }, 1500); } catch (err) { setError(err instanceof Error ? err.message : t('lpStaking.stakeFailed')); hapticNotification('error'); } finally { setIsProcessing(false); } }; const handleUnstake = async () => { if (!assetHubApi || !keypair || selectedPool === null || !unstakeAmount) return; setIsProcessing(true); setError(null); try { const amountBN = BigInt(Math.floor(parseFloat(unstakeAmount) * 1e12)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const tx = (assetHubApi.tx.assetRewards as any).unstake(selectedPool, amountBN.toString()); await new Promise((resolve, reject) => { tx.signAndSend( keypair, ({ status, dispatchError, }: { status: { isFinalized: boolean }; // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatchError?: { isModule: boolean; asModule: any; toString: () => string }; }) => { if (status.isFinalized) { if (dispatchError) { let errorMsg = t('lpStaking.unstakeFailed'); if (dispatchError.isModule) { const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); errorMsg = `${decoded.section}.${decoded.name}`; } reject(new Error(errorMsg)); } else { resolve(); } } } ).catch(reject); }); hapticNotification('success'); showAlert(t('lpStaking.unstakeSuccess')); setUnstakeAmount(''); setTimeout(() => { onClose(); }, 1500); } catch (err) { setError(err instanceof Error ? err.message : t('lpStaking.unstakeFailed')); hapticNotification('error'); } finally { setIsProcessing(false); } }; const handleClaimRewards = async () => { if (!assetHubApi || !keypair || selectedPool === null) return; setIsProcessing(true); setError(null); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const tx = (assetHubApi.tx.assetRewards as any).harvestRewards(selectedPool); await new Promise((resolve, reject) => { tx.signAndSend( keypair, ({ status, dispatchError, }: { status: { isFinalized: boolean }; // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatchError?: { isModule: boolean; asModule: any; toString: () => string }; }) => { if (status.isFinalized) { if (dispatchError) { let errorMsg = t('lpStaking.claimFailed'); if (dispatchError.isModule) { const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); errorMsg = `${decoded.section}.${decoded.name}`; } reject(new Error(errorMsg)); } else { resolve(); } } } ).catch(reject); }); hapticNotification('success'); showAlert(t('lpStaking.claimSuccess')); setTimeout(() => { onClose(); }, 1500); } catch (err) { setError(err instanceof Error ? err.message : t('lpStaking.claimFailed')); hapticNotification('error'); } finally { setIsProcessing(false); } }; const currentPool = pools.find((p) => p.poolId === selectedPool); if (!isOpen) return null; return (
{/* Header */}

LP Staking

{/* Content */}
{isLoading ? (
) : pools.length === 0 ? (

{t('lpStaking.noPoolsYet')}

) : ( <> {/* Pool Selector */}
{pools.map((pool) => ( ))}
{/* Pool Stats */} {currentPool && (
{t('lpStaking.totalStaked')}
{formatAmount(currentPool.totalStaked)}
{t('lpStaking.youStaked')}
{formatAmount(currentPool.userStaked)}
{t('lpStaking.lpBalance')}
{formatAmount(currentPool.lpBalance)}
{t('lpStaking.reward')}
{formatAmount(currentPool.pendingRewards)} PEZ
)} {/* Tabs */}
{[ { id: 'stake' as const, label: t('lpStaking.stakeTab'), icon: Lock }, { id: 'unstake' as const, label: t('lpStaking.unstakeTab'), icon: Unlock }, { id: 'rewards' as const, label: t('lpStaking.rewardTab'), icon: Gift }, ].map(({ id, label, icon: Icon }) => ( ))}
{/* Error */} {error && (
{error}
)} {/* Stake Tab */} {activeTab === 'stake' && currentPool && (
setStakeAmount(e.target.value)} placeholder="0.0000" className="w-full bg-secondary border border-border rounded-xl p-4 pr-20 text-lg" />
{t('lpStaking.balanceLabel')} {formatAmount(currentPool.lpBalance)}
)} {/* Unstake Tab */} {activeTab === 'unstake' && currentPool && (
setUnstakeAmount(e.target.value)} placeholder="0.0000" className="w-full bg-secondary border border-border rounded-xl p-4 pr-20 text-lg" />
{t('lpStaking.stakedLabel')} {formatAmount(currentPool.userStaked)}
)} {/* Rewards Tab */} {activeTab === 'rewards' && currentPool && (
{formatAmount(currentPool.pendingRewards)} PEZ
{t('lpStaking.pendingRewards')}
)} )}
); }