diff --git a/web/src/components/LPStakingModal.tsx b/web/src/components/LPStakingModal.tsx new file mode 100644 index 00000000..b52bd4c5 --- /dev/null +++ b/web/src/components/LPStakingModal.tsx @@ -0,0 +1,430 @@ +import React, { useState, useEffect } from 'react'; +import { X, Lock, Unlock, Gift, AlertCircle, Loader2, Info } from 'lucide-react'; +import { web3FromAddress } from '@pezkuwi/extension-dapp'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +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; +} + +const LP_TOKEN_NAMES: Record = { + 0: 'HEZ-PEZ LP', + 1: 'HEZ-USDT LP', + 2: 'HEZ-DOT LP', +}; + +export const LPStakingModal: React.FC = ({ isOpen, onClose }) => { + const { assetHubApi, selectedAccount, isAssetHubReady } = usePezkuwi(); + const [pools, setPools] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [selectedPool, setSelectedPool] = useState(null); + const [stakeAmount, setStakeAmount] = useState(''); + const [unstakeAmount, setUnstakeAmount] = useState(''); + const [activeTab, setActiveTab] = useState('stake'); + + useEffect(() => { + if (!assetHubApi || !isAssetHubReady || !isOpen) return; + + const fetchPools = async () => { + setIsLoading(true); + try { + const poolEntries = await assetHubApi.query.assetRewards.pools.entries(); + const stakingPools: StakingPool[] = []; + + for (const [key, value] of poolEntries) { + const poolId = parseInt(key.args[0].toString()); + const poolData = value.toJSON() as { + stakedAssetId: { interior: { x2: [{ palletInstance: number }, { generalIndex: number }] } }; + rewardAssetId: { interior: { x2: [{ palletInstance: number }, { generalIndex: number }] } }; + rewardRatePerBlock: string; + totalTokensStaked: string; + }; + + const lpTokenId = poolData.stakedAssetId?.interior?.x2?.[1]?.generalIndex ?? poolId; + + let userStaked = '0'; + const pendingRewards = '0'; + let lpBalance = '0'; + + if (selectedAccount) { + try { + const stakeInfo = await assetHubApi.query.assetRewards.poolStakers([poolId, selectedAccount.address]); + if (stakeInfo && (stakeInfo as { isSome: boolean }).isSome) { + const stakeData = (stakeInfo as { unwrap: () => { toJSON: () => { amount: string } } }).unwrap().toJSON(); + userStaked = stakeData.amount || '0'; + } + + const lpBal = await assetHubApi.query.poolAssets.account(lpTokenId, selectedAccount.address); + if (lpBal && (lpBal as { isSome: boolean }).isSome) { + const lpData = (lpBal as { unwrap: () => { toJSON: () => { balance: string } } }).unwrap().toJSON(); + lpBalance = lpData.balance || '0'; + } + } catch { + // Ignore errors + } + } + + stakingPools.push({ + poolId, + stakedAsset: LP_TOKEN_NAMES[lpTokenId] || `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('Failed to fetch staking pools'); + } finally { + setIsLoading(false); + } + }; + + fetchPools(); + }, [assetHubApi, isAssetHubReady, isOpen, selectedAccount, 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 || !selectedAccount || selectedPool === null || !stakeAmount) return; + + setIsProcessing(true); + setError(null); + setSuccess(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)); + const injector = await web3FromAddress(selectedAccount.address); + + const tx = assetHubApi.tx.assetRewards.stake(selectedPool, amountBN.toString()); + + await new Promise((resolve, reject) => { + tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isFinalized) { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)); + } else { + reject(new Error(dispatchError.toString())); + } + } else { + resolve(); + } + } + } + ); + }); + + setSuccess(`Successfully staked ${stakeAmount} ${pool.stakedAsset}!`); + setStakeAmount(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to stake'); + } finally { + setIsProcessing(false); + } + }; + + const handleUnstake = async () => { + if (!assetHubApi || !selectedAccount || selectedPool === null || !unstakeAmount) return; + + setIsProcessing(true); + setError(null); + setSuccess(null); + + try { + const pool = pools.find(p => p.poolId === selectedPool); + if (!pool) throw new Error('Pool not found'); + + const amountBN = BigInt(Math.floor(parseFloat(unstakeAmount) * 1e12)); + const injector = await web3FromAddress(selectedAccount.address); + + const tx = assetHubApi.tx.assetRewards.unstake(selectedPool, amountBN.toString()); + + await new Promise((resolve, reject) => { + tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isFinalized) { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)); + } else { + reject(new Error(dispatchError.toString())); + } + } else { + resolve(); + } + } + } + ); + }); + + setSuccess(`Successfully unstaked ${unstakeAmount} ${pool.stakedAsset}!`); + setUnstakeAmount(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to unstake'); + } finally { + setIsProcessing(false); + } + }; + + const handleHarvest = async () => { + if (!assetHubApi || !selectedAccount || selectedPool === null) return; + + setIsProcessing(true); + setError(null); + setSuccess(null); + + try { + const injector = await web3FromAddress(selectedAccount.address); + const tx = assetHubApi.tx.assetRewards.harvestRewards(selectedPool); + + await new Promise((resolve, reject) => { + tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isFinalized) { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)); + } else { + reject(new Error(dispatchError.toString())); + } + } else { + resolve(); + } + } + } + ); + }); + + setSuccess('Successfully harvested rewards!'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to harvest rewards'); + } finally { + setIsProcessing(false); + } + }; + + if (!isOpen) return null; + + const currentPool = pools.find(p => p.poolId === selectedPool); + + return ( +
+
+
+

LP Staking

+ +
+ + {error && ( + + + {error} + + )} + + {success && ( + + {success} + + )} + + {isLoading ? ( +
+ +

Loading staking pools...

+
+ ) : pools.length === 0 ? ( + + + No staking pools available. + + ) : ( + <> +
+ + +
+ + {currentPool && ( +
+
+ Total Staked: + {formatAmount(currentPool.totalStaked)} LP +
+
+ Your Staked: + {formatAmount(currentPool.userStaked)} LP +
+
+ Your LP Balance: + {formatAmount(currentPool.lpBalance)} LP +
+
+ Reward Rate: + {formatAmount(currentPool.rewardRatePerBlock)} PEZ/block +
+
+ )} + + + + + + Stake + + + + Unstake + + + + Harvest + + + + +
+
+ +
+ setStakeAmount(e.target.value)} + placeholder="0.0" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white" + disabled={isProcessing} + /> + +
+
+ +
+
+ + +
+
+ +
+ setUnstakeAmount(e.target.value)} + placeholder="0.0" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white" + disabled={isProcessing} + /> + +
+
+ +
+
+ + +
+
+

Pending Rewards

+

+ {currentPool ? formatAmount(currentPool.pendingRewards) : '0'} PEZ +

+
+ +
+
+
+ + )} +
+
+ ); +}; diff --git a/web/src/components/PoolDashboard.tsx b/web/src/components/PoolDashboard.tsx index 6343d643..e3d5183a 100644 --- a/web/src/components/PoolDashboard.tsx +++ b/web/src/components/PoolDashboard.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { TrendingUp, Droplet, DollarSign, Percent, Info, AlertTriangle, BarChart3, Clock } from 'lucide-react'; +import { TrendingUp, Droplet, DollarSign, Percent, Info, AlertTriangle, BarChart3, Clock, Lock } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -11,6 +11,7 @@ import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet'; import { NATIVE_TOKEN_ID } from '@/types/dex'; import { AddLiquidityModal } from '@/components/AddLiquidityModal'; import { RemoveLiquidityModal } from '@/components/RemoveLiquidityModal'; +import { LPStakingModal } from '@/components/LPStakingModal'; // Helper function to convert asset IDs to user-friendly display names // Users should only see HEZ, PEZ, USDT - wrapped tokens are backend details @@ -51,6 +52,7 @@ const PoolDashboard = () => { const [error, setError] = useState(null); const [isAddLiquidityModalOpen, setIsAddLiquidityModalOpen] = useState(false); const [isRemoveLiquidityModalOpen, setIsRemoveLiquidityModalOpen] = useState(false); + const [isStakingModalOpen, setIsStakingModalOpen] = useState(false); // Pool selection state const [availablePools, setAvailablePools] = useState>([]); @@ -577,13 +579,20 @@ const PoolDashboard = () => { -
+
+
); };