import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Alert, AlertDescription } from '@/components/ui/alert'; // import { Badge } from '@/components/ui/badge'; import { AlertCircle, CheckCircle2 } from 'lucide-react'; import { usePolkadot } from '@/contexts/PolkadotContext'; import { useWallet } from '@/contexts/WalletContext'; import { toast } from 'sonner'; import { web3FromAddress } from '@polkadot/extension-dapp'; import { getStakingInfo, getActiveValidators, getMinNominatorBond, getBondingDuration, getCurrentEra, parseAmount, type StakingInfo } from '@pezkuwi/lib/staking'; import { LoadingState } from '@pezkuwi/components/AsyncComponent'; import { ValidatorPoolDashboard } from './ValidatorPoolDashboard'; import { handleBlockchainError, handleBlockchainSuccess } from '@pezkuwi/lib/error-handler'; export const StakingDashboard: React.FC = () => { const { api, selectedAccount, isApiReady } = usePolkadot(); const { balances, refreshBalances } = useWallet(); const [stakingInfo, setStakingInfo] = useState(null); const [validators, setValidators] = useState([]); const [minNominatorBond, setMinNominatorBond] = useState('0'); const [bondingDuration, setBondingDuration] = useState(28); const [bondAmount, setBondAmount] = useState(''); const [unbondAmount, setUnbondAmount] = useState(''); const [selectedValidators, setSelectedValidators] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isLoadingData, setIsLoadingData] = useState(false); // Fetch staking data useEffect(() => { const fetchStakingData = async () => { if (!api || !isApiReady || !selectedAccount) { return; } setIsLoadingData(true); try { const [info, activeVals, minBond, duration, era] = await Promise.all([ getStakingInfo(api, selectedAccount.address), getActiveValidators(api), getMinNominatorBond(api), getBondingDuration(api), getCurrentEra(api) ]); setStakingInfo(info); setValidators(activeVals); setMinNominatorBond(minBond); setBondingDuration(duration); // Track current era for future use console.log('Current era:', era); // Pre-select current nominations if any if (info.nominations.length > 0) { setSelectedValidators(info.nominations); } } catch (error) { console.error('Failed to fetch staking data:', error); toast.error('Failed to fetch staking information'); } finally { setIsLoadingData(false); } }; fetchStakingData(); const interval = setInterval(fetchStakingData, 30000); // Refresh every 30s return () => clearInterval(interval); }, [api, isApiReady, selectedAccount]); const handleBond = async () => { if (!api || !selectedAccount || !bondAmount) return; setIsLoading(true); try { const amount = parseAmount(bondAmount); // Validate if (parseFloat(bondAmount) < parseFloat(minNominatorBond)) { throw new Error(`Minimum bond is ${minNominatorBond} HEZ`); } if (parseFloat(bondAmount) > parseFloat(balances.HEZ)) { throw new Error('Insufficient HEZ balance'); } const injector = await web3FromAddress(selectedAccount.address); // If already bonded, use bondExtra, otherwise use bond let tx; if (stakingInfo && parseFloat(stakingInfo.bonded) > 0) { tx = api.tx.staking.bondExtra(amount); } else { // For new bond, also need to specify reward destination tx = api.tx.staking.bond(amount, 'Staked'); // Auto-compound rewards } await tx.signAndSend( selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => { if (status.isInBlock) { console.log('Transaction in block:', status.asInBlock.toHex()); if (dispatchError) { handleBlockchainError(dispatchError, api, toast); setIsLoading(false); } else { handleBlockchainSuccess('staking.bonded', toast, { amount: bondAmount }); setBondAmount(''); refreshBalances(); // Refresh staking data after a delay setTimeout(() => { if (api && selectedAccount) { getStakingInfo(api, selectedAccount.address).then(setStakingInfo); } }, 3000); setIsLoading(false); } } } ); } catch (error) { console.error('Bond failed:', error); toast.error(error instanceof Error ? error.message : 'Failed to bond tokens'); setIsLoading(false); } }; const handleNominate = async () => { if (!api || !selectedAccount || selectedValidators.length === 0) return; if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) { toast.error('You must bond tokens before nominating validators'); return; } setIsLoading(true); try { const injector = await web3FromAddress(selectedAccount.address); const tx = api.tx.staking.nominate(selectedValidators); await tx.signAndSend( selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => { if (status.isInBlock) { if (dispatchError) { handleBlockchainError(dispatchError, api, toast); setIsLoading(false); } else { handleBlockchainSuccess('staking.nominated', toast, { count: selectedValidators.length.toString() }); // Refresh staking data setTimeout(() => { if (api && selectedAccount) { getStakingInfo(api, selectedAccount.address).then(setStakingInfo); } }, 3000); setIsLoading(false); } } } ); } catch (error) { console.error('Nomination failed:', error); toast.error(error instanceof Error ? error.message : 'Failed to nominate validators'); setIsLoading(false); } }; const handleUnbond = async () => { if (!api || !selectedAccount || !unbondAmount) return; setIsLoading(true); try { const amount = parseAmount(unbondAmount); if (!stakingInfo || parseFloat(unbondAmount) > parseFloat(stakingInfo.active)) { throw new Error('Insufficient staked amount'); } const injector = await web3FromAddress(selectedAccount.address); const tx = api.tx.staking.unbond(amount); await tx.signAndSend( selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => { if (status.isInBlock) { if (dispatchError) { handleBlockchainError(dispatchError, api, toast); setIsLoading(false); } else { handleBlockchainSuccess('staking.unbonded', toast, { amount: unbondAmount, duration: bondingDuration.toString() }); setUnbondAmount(''); setTimeout(() => { if (api && selectedAccount) { getStakingInfo(api, selectedAccount.address).then(setStakingInfo); } }, 3000); setIsLoading(false); } } } ); } catch (error) { console.error('Unbond failed:', error); toast.error(error instanceof Error ? error.message : 'Failed to unbond tokens'); setIsLoading(false); } }; const handleWithdrawUnbonded = async () => { if (!api || !selectedAccount) return; if (!stakingInfo || parseFloat(stakingInfo.redeemable) === 0) { toast.info('No tokens available to withdraw'); return; } setIsLoading(true); try { const injector = await web3FromAddress(selectedAccount.address); // Number of slashing spans (usually 0) const tx = api.tx.staking.withdrawUnbonded(0); await tx.signAndSend( selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => { if (status.isInBlock) { if (dispatchError) { let errorMessage = 'Withdrawal failed'; if (dispatchError.isModule) { const decoded = api.registry.findMetaError(dispatchError.asModule); errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; } toast.error(errorMessage); setIsLoading(false); } else { toast.success(`Withdrew ${stakingInfo.redeemable} HEZ`); refreshBalances(); setTimeout(() => { if (api && selectedAccount) { getStakingInfo(api, selectedAccount.address).then(setStakingInfo); } }, 3000); setIsLoading(false); } } } ); } catch (error) { console.error('Withdrawal failed:', error); toast.error(error instanceof Error ? error.message : 'Failed to withdraw tokens'); setIsLoading(false); } }; const handleStartScoreTracking = async () => { if (!api || !selectedAccount) return; if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) { toast.error('You must bond tokens before starting score tracking'); return; } setIsLoading(true); try { const injector = await web3FromAddress(selectedAccount.address); const tx = api.tx.stakingScore.startScoreTracking(); await tx.signAndSend( selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => { if (status.isInBlock) { if (dispatchError) { let errorMessage = 'Failed to start score tracking'; if (dispatchError.isModule) { const decoded = api.registry.findMetaError(dispatchError.asModule); errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; } toast.error(errorMessage); setIsLoading(false); } else { toast.success('Score tracking started successfully! Your staking score will now accumulate over time.'); // Refresh staking data after a delay setTimeout(() => { if (api && selectedAccount) { getStakingInfo(api, selectedAccount.address).then(setStakingInfo); } }, 3000); setIsLoading(false); } } } ); } catch (error) { console.error('Start score tracking failed:', error); toast.error(error instanceof Error ? error.message : 'Failed to start score tracking'); setIsLoading(false); } }; const toggleValidator = (validator: string) => { setSelectedValidators(prev => { if (prev.includes(validator)) { return prev.filter(v => v !== validator); } else { // Max 16 nominations if (prev.length >= 16) { toast.info('Maximum 16 validators can be nominated'); return prev; } return [...prev, validator]; } }); }; if (isLoadingData) { return ; } return (
{/* Overview Cards */}
Total Bonded
{stakingInfo?.bonded || '0'} HEZ

Active: {stakingInfo?.active || '0'} HEZ

Unlocking
{stakingInfo?.unlocking.reduce((sum, u) => sum + parseFloat(u.amount), 0).toFixed(2) || '0'} HEZ

{stakingInfo?.unlocking.length || 0} chunk(s)

Redeemable
{stakingInfo?.redeemable || '0'} HEZ
Staking Score {stakingInfo?.hasStartedScoreTracking ? ( <>
{stakingInfo.stakingScore}/100

Duration: {stakingInfo.stakingDuration ? `${Math.floor(stakingInfo.stakingDuration / (24 * 60 * 10))} days` : '0 days'}

) : ( <>
Not Started
)}
PEZ Rewards {stakingInfo?.pezRewards && stakingInfo.pezRewards.hasPendingClaim ? ( <>
{parseFloat(stakingInfo.pezRewards.totalClaimable).toFixed(2)} PEZ

{stakingInfo.pezRewards.claimableRewards.length} epoch(s) to claim

) : ( <>
0 PEZ

{stakingInfo?.pezRewards ? `Epoch ${stakingInfo.pezRewards.currentEpoch}` : 'No rewards available'}

)}
{/* Main Staking Interface */} Staking Stake HEZ to secure the network and earn rewards. Stake Nominate Validator Pool Unstake {/* STAKE TAB */} Minimum bond: {minNominatorBond} HEZ. Bonded tokens are locked and earn rewards when nominated validators produce blocks.
setBondAmount(e.target.value)} className="bg-gray-800 border-gray-700" disabled={isLoading} />
Available: {balances.HEZ} HEZ
{/* NOMINATE TAB */} Select up to 16 validators to nominate. Your stake will be distributed to active validators. {stakingInfo && parseFloat(stakingInfo.bonded) === 0 && ' You must bond tokens first.'}
{validators.map((validator) => (
toggleValidator(validator)} > {validator.slice(0, 8)}...{validator.slice(-8)} {selectedValidators.includes(validator) && ( )}
))}

Selected: {selectedValidators.length}/16

{/* VALIDATOR POOL TAB */} {/* UNSTAKE TAB */} Unbonded tokens will be locked for {bondingDuration} eras (~{Math.floor(bondingDuration / 4)} days) before withdrawal.
setUnbondAmount(e.target.value)} className="bg-gray-800 border-gray-700" disabled={isLoading} />
Staked: {stakingInfo?.active || '0'} HEZ
{stakingInfo && stakingInfo.unlocking.length > 0 && (
{stakingInfo.unlocking.map((chunk, i) => (
{chunk.amount} HEZ Era {chunk.era} ({chunk.blocksRemaining > 0 ? `~${Math.floor(chunk.blocksRemaining / 600)} blocks` : 'Ready'})
))}
)}
); };