diff --git a/src/components/staking/StakingDashboard.tsx b/src/components/staking/StakingDashboard.tsx index ebd28597..4d73f483 100644 --- a/src/components/staking/StakingDashboard.tsx +++ b/src/components/staking/StakingDashboard.tsx @@ -4,443 +4,711 @@ 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 { Progress } from '@/components/ui/progress'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; -import { TrendingUp, Coins, Lock, Clock, Gift, Calculator, Info } from 'lucide-react'; +import { TrendingUp, Coins, Lock, Clock, Award, AlertCircle, CheckCircle2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePolkadot } from '@/contexts/PolkadotContext'; -import { ASSET_IDS, formatBalance } from '@/lib/wallet'; +import { useWallet } from '@/contexts/WalletContext'; import { toast } from '@/components/ui/use-toast'; - -interface StakingPool { - id: string; - name: string; - token: 'HEZ' | 'PEZ'; - apy: number; - totalStaked: number; - minStake: number; - lockPeriod: number; - userStaked?: number; - rewards?: number; -} +import { web3FromAddress } from '@polkadot/extension-dapp'; +import { + getStakingInfo, + getActiveValidators, + getMinNominatorBond, + getBondingDuration, + getCurrentEra, + parseAmount, + type StakingInfo +} from '@/lib/staking'; export const StakingDashboard: React.FC = () => { const { t } = useTranslation(); - const { api, isApiReady, selectedAccount } = usePolkadot(); - const [selectedPool, setSelectedPool] = useState(null); - const [stakeAmount, setStakeAmount] = useState(''); - const [unstakeAmount, setUnstakeAmount] = useState(''); - const [isLoadingPools, setIsLoadingPools] = useState(false); + const { api, selectedAccount, isApiReady } = usePolkadot(); + const { balances, refreshBalances } = useWallet(); - // Real staking pools data from blockchain - const [stakingPools, setStakingPools] = useState([ - // Fallback mock data - will be replaced with real data - { - id: '1', - name: 'HEZ Flexible', - token: 'HEZ', - apy: 8.5, - totalStaked: 0, - minStake: 100, - lockPeriod: 0, - userStaked: 0, - rewards: 0 - }, - { - id: '2', - name: 'HEZ Locked 30 Days', - token: 'HEZ', - apy: 12.0, - totalStaked: 0, - minStake: 500, - lockPeriod: 30, - userStaked: 0, - rewards: 0 - }, - { - id: '3', - name: 'PEZ High Yield', - token: 'PEZ', - apy: 15.5, - totalStaked: 0, - minStake: 1000, - lockPeriod: 60, - userStaked: 0, - rewards: 0 - }, - { - id: '4', - name: 'PEZ Governance', - token: 'PEZ', - apy: 18.0, - totalStaked: 0, - minStake: 2000, - lockPeriod: 90, - userStaked: 0, - rewards: 0 - } - ]); + const [stakingInfo, setStakingInfo] = useState(null); + const [validators, setValidators] = useState([]); + const [minNominatorBond, setMinNominatorBond] = useState('0'); + const [bondingDuration, setBondingDuration] = useState(28); + const [currentEra, setCurrentEra] = useState(0); - // Fetch staking pools data from blockchain + 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) { + if (!api || !isApiReady || !selectedAccount) { return; } - setIsLoadingPools(true); + setIsLoadingData(true); try { - // TODO: Query staking pools from chain - // This would query your custom staking pallet - // const pools = await api.query.staking.pools.entries(); + const [info, activeVals, minBond, duration, era] = await Promise.all([ + getStakingInfo(api, selectedAccount.address), + getActiveValidators(api), + getMinNominatorBond(api), + getBondingDuration(api), + getCurrentEra(api) + ]); - // For now, using mock data - // In real implementation, parse pool data from chain - console.log('Staking pools would be fetched from chain here'); + setStakingInfo(info); + setValidators(activeVals); + setMinNominatorBond(minBond); + setBondingDuration(duration); + setCurrentEra(era); - // If user is connected, fetch their staking info - if (selectedAccount) { - // TODO: Query user staking positions - // const userStakes = await api.query.staking.ledger(selectedAccount.address); - // Update stakingPools with user data + // 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({ title: 'Error', - description: 'Failed to fetch staking pools', + description: 'Failed to fetch staking information', variant: 'destructive', }); } finally { - setIsLoadingPools(false); + setIsLoadingData(false); } }; fetchStakingData(); + const interval = setInterval(fetchStakingData, 30000); // Refresh every 30s + return () => clearInterval(interval); }, [api, isApiReady, selectedAccount]); - const handleStake = async (pool: StakingPool) => { - if (!api || !selectedAccount) { - toast({ - title: 'Error', - description: 'Please connect your wallet', - variant: 'destructive', - }); - return; - } - - if (!stakeAmount || parseFloat(stakeAmount) < pool.minStake) { - toast({ - title: 'Error', - description: `Minimum stake is ${pool.minStake} ${pool.token}`, - variant: 'destructive', - }); - return; - } + const handleBond = async () => { + if (!api || !selectedAccount || !bondAmount) return; + setIsLoading(true); try { - // TODO: Implement staking transaction - // const assetId = ASSET_IDS[pool.token]; - // const amount = parseAmount(stakeAmount, 12); - // await api.tx.staking.stake(pool.id, amount).signAndSend(...); + const amount = parseAmount(bondAmount); - console.log('Staking', stakeAmount, pool.token, 'in pool', pool.name); + // Validate + if (parseFloat(bondAmount) < parseFloat(minNominatorBond)) { + throw new Error(`Minimum bond is ${minNominatorBond} HEZ`); + } - toast({ - title: 'Success', - description: `Staked ${stakeAmount} ${pool.token}`, - }); + if (parseFloat(bondAmount) > parseFloat(balances.HEZ)) { + throw new Error('Insufficient HEZ balance'); + } - setStakeAmount(''); - setSelectedPool(null); + 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, events, dispatchError }) => { + if (status.isInBlock) { + console.log('Transaction in block:', status.asInBlock.toHex()); + + if (dispatchError) { + let errorMessage = 'Transaction failed'; + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; + } + toast({ + title: 'Error', + description: errorMessage, + variant: 'destructive', + }); + setIsLoading(false); + } else { + toast({ + title: 'Success', + description: `Bonded ${bondAmount} HEZ successfully`, + }); + setBondAmount(''); + refreshBalances(); + // Refresh staking data after a delay + setTimeout(() => { + if (api && selectedAccount) { + getStakingInfo(api, selectedAccount.address).then(setStakingInfo); + } + }, 3000); + setIsLoading(false); + } + } + } + ); } catch (error: any) { - console.error('Staking failed:', error); + console.error('Bond failed:', error); toast({ title: 'Error', - description: error.message || 'Staking failed', + description: error.message || 'Failed to bond tokens', variant: 'destructive', }); + setIsLoading(false); } }; - const handleUnstake = async (pool: StakingPool) => { - if (!api || !selectedAccount) { + const handleNominate = async () => { + if (!api || !selectedAccount || selectedValidators.length === 0) return; + + if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) { toast({ title: 'Error', - description: 'Please connect your wallet', + description: 'You must bond tokens before nominating validators', variant: 'destructive', }); return; } + setIsLoading(true); try { - // TODO: Implement unstaking transaction - // const amount = parseAmount(unstakeAmount, 12); - // await api.tx.staking.unstake(pool.id, amount).signAndSend(...); + const injector = await web3FromAddress(selectedAccount.address); - console.log('Unstaking', unstakeAmount, pool.token, 'from pool', pool.name); + const tx = api.tx.staking.nominate(selectedValidators); - toast({ - title: 'Success', - description: `Unstaked ${unstakeAmount} ${pool.token}`, - }); - - setUnstakeAmount(''); - setSelectedPool(null); + await tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isInBlock) { + if (dispatchError) { + let errorMessage = 'Nomination failed'; + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; + } + toast({ + title: 'Error', + description: errorMessage, + variant: 'destructive', + }); + setIsLoading(false); + } else { + toast({ + title: 'Success', + description: `Nominated ${selectedValidators.length} validator(s)`, + }); + // Refresh staking data + setTimeout(() => { + if (api && selectedAccount) { + getStakingInfo(api, selectedAccount.address).then(setStakingInfo); + } + }, 3000); + setIsLoading(false); + } + } + } + ); } catch (error: any) { - console.error('Unstaking failed:', error); + console.error('Nomination failed:', error); toast({ title: 'Error', - description: error.message || 'Unstaking failed', + description: error.message || 'Failed to nominate validators', variant: 'destructive', }); + setIsLoading(false); } }; - const handleClaimRewards = async (pool: StakingPool) => { - if (!api || !selectedAccount) { + 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) { + let errorMessage = 'Unbond failed'; + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; + } + toast({ + title: 'Error', + description: errorMessage, + variant: 'destructive', + }); + setIsLoading(false); + } else { + toast({ + title: 'Success', + description: `Unbonded ${unbondAmount} HEZ. Withdrawal available in ${bondingDuration} eras`, + }); + setUnbondAmount(''); + setTimeout(() => { + if (api && selectedAccount) { + getStakingInfo(api, selectedAccount.address).then(setStakingInfo); + } + }, 3000); + setIsLoading(false); + } + } + } + ); + } catch (error: any) { + console.error('Unbond failed:', error); toast({ title: 'Error', - description: 'Please connect your wallet', + description: error.message || 'Failed to unbond tokens', + variant: 'destructive', + }); + setIsLoading(false); + } + }; + + const handleWithdrawUnbonded = async () => { + if (!api || !selectedAccount) return; + + if (!stakingInfo || parseFloat(stakingInfo.redeemable) === 0) { + toast({ + title: 'Info', + description: '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({ + title: 'Error', + description: errorMessage, + variant: 'destructive', + }); + setIsLoading(false); + } else { + toast({ + title: 'Success', + description: `Withdrew ${stakingInfo.redeemable} HEZ`, + }); + refreshBalances(); + setTimeout(() => { + if (api && selectedAccount) { + getStakingInfo(api, selectedAccount.address).then(setStakingInfo); + } + }, 3000); + setIsLoading(false); + } + } + } + ); + } catch (error: any) { + console.error('Withdrawal failed:', error); + toast({ + title: 'Error', + description: error.message || 'Failed to withdraw tokens', + variant: 'destructive', + }); + setIsLoading(false); + } + }; + + const handleStartScoreTracking = async () => { + if (!api || !selectedAccount) return; + + if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) { + toast({ + title: 'Error', + description: 'You must bond tokens before starting score tracking', variant: 'destructive', }); return; } + setIsLoading(true); try { - // TODO: Implement claim rewards transaction - // await api.tx.staking.claimRewards(pool.id).signAndSend(...); + const injector = await web3FromAddress(selectedAccount.address); + const tx = api.tx.stakingScore.startScoreTracking(); - console.log('Claiming rewards from pool', pool.name); - - toast({ - title: 'Success', - description: `Claimed ${pool.rewards} ${pool.token} rewards`, - }); + 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({ + title: 'Error', + description: errorMessage, + variant: 'destructive', + }); + setIsLoading(false); + } else { + toast({ + title: 'Success', + description: '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: any) { - console.error('Claim rewards failed:', error); + console.error('Start score tracking failed:', error); toast({ title: 'Error', - description: error.message || 'Claim rewards failed', + description: error.message || 'Failed to start score tracking', variant: 'destructive', }); + setIsLoading(false); } }; - const totalStaked = stakingPools.reduce((sum, pool) => sum + (pool.userStaked || 0), 0); - const totalRewards = stakingPools.reduce((sum, pool) => sum + (pool.rewards || 0), 0); + 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({ + title: 'Limit Reached', + description: 'Maximum 16 validators can be nominated', + }); + return prev; + } + return [...prev, validator]; + } + }); + }; + + if (isLoadingData) { + return ( +
+
Loading staking data...
+
+ ); + } return (
{/* Overview Cards */} -
+
- Total Staked + Total Bonded -
{totalStaked.toLocaleString()}
-

Across all pools

+
+ {stakingInfo?.bonded || '0'} HEZ +
+

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

- Total Rewards + Unlocking -
{totalRewards.toFixed(2)}
-

Ready to claim

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

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

- Average APY + Redeemable -
13.5%
-

Weighted average

+
+ {stakingInfo?.redeemable || '0'} HEZ +
+
- Next Reward + Staking Score -
4h 23m
-

Distribution time

+ {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'} +

+ + )}
- {/* Staking Pools */} + {/* Main Staking Interface */} - Staking Pools + Validator Nomination Staking - Choose a pool and start earning rewards + Bond HEZ and nominate validators to earn staking rewards -
- {stakingPools.map((pool) => ( - - -
-
- {pool.name} - - {pool.token} - + + + Stake + Nominate + 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) && ( + + )}
-
-
{pool.apy}%
-

APY

-
-
- - -
-
- Total Staked - {pool.totalStaked.toLocaleString()} {pool.token} -
-
- Lock Period - - {pool.lockPeriod === 0 ? 'Flexible' : `${pool.lockPeriod} days`} + ))} +
+

+ Selected: {selectedValidators.length}/16 +

+
+ + + + + {/* UNSTAKE TAB */} + + + + + Unbonded tokens will be locked for {bondingDuration} eras (~{bondingDuration} 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'})
-
- Min. Stake - {pool.minStake} {pool.token} -
-
+ ))} +
+ )} - {pool.userStaked && pool.userStaked > 0 && ( -
-
- Your Stake - {pool.userStaked.toLocaleString()} {pool.token} -
-
- Rewards - {pool.rewards?.toFixed(2)} {pool.token} -
-
- - -
-
- )} - - {(!pool.userStaked || pool.userStaked === 0) && ( - - )} - - - ))} -
+ + + - - {/* Stake/Unstake Modal */} - {selectedPool && ( - - - Manage {selectedPool.name} - Stake or unstake your tokens - - - - - Stake - Unstake - - - -
- - setStakeAmount(e.target.value)} - className="bg-gray-800 border-gray-700" - /> -
-
-
- Estimated APY - {selectedPool.apy}% -
-
- Lock Period - {selectedPool.lockPeriod === 0 ? 'None' : `${selectedPool.lockPeriod} days`} -
-
- -
- - -
- - setUnstakeAmount(e.target.value)} - className="bg-gray-800 border-gray-700" - /> -
-
-

- - {selectedPool.lockPeriod > 0 - ? `Tokens are locked for ${selectedPool.lockPeriod} days. Early withdrawal may incur penalties.` - : 'You can unstake anytime without penalties.'} -

-
- -
-
-
-
- )}
); -}; \ No newline at end of file +}; diff --git a/src/lib/staking.ts b/src/lib/staking.ts new file mode 100644 index 00000000..4118b0d7 --- /dev/null +++ b/src/lib/staking.ts @@ -0,0 +1,468 @@ +// ======================================== +// Staking Helper Functions +// ======================================== +// Helper functions for pallet_staking and pallet_staking_score integration + +import { ApiPromise } from '@polkadot/api'; +import { formatBalance } from './wallet'; + +export interface StakingLedger { + stash: string; + total: string; + active: string; + unlocking: { value: string; era: number }[]; + claimedRewards: number[]; +} + +export interface NominatorInfo { + targets: string[]; + submittedIn: number; + suppressed: boolean; +} + +export interface ValidatorPrefs { + commission: number; + blocked: boolean; +} + +export interface EraRewardPoints { + total: number; + individual: Record; +} + +export interface PezRewardInfo { + currentEpoch: number; + epochStartBlock: number; + claimableRewards: { epoch: number; amount: string }[]; // Unclaimed rewards from completed epochs + totalClaimable: string; + hasPendingClaim: boolean; +} + +export interface StakingInfo { + bonded: string; + active: string; + unlocking: { amount: string; era: number; blocksRemaining: number }[]; + redeemable: string; + nominations: string[]; + stakingScore: number | null; + stakingDuration: number | null; // Duration in blocks + hasStartedScoreTracking: boolean; + isValidator: boolean; + pezRewards: PezRewardInfo | null; // PEZ rewards information +} + +/** + * Get staking ledger for an account + * In Substrate staking, we need to query using the controller account. + * If stash == controller (modern setup), we can query directly. + */ +export async function getStakingLedger( + api: ApiPromise, + address: string +): Promise { + try { + // Method 1: Try direct ledger query (modern Substrate where stash == controller) + let ledgerResult = await api.query.staking.ledger(address); + + // Method 2: If not found, check if address is a stash and get controller + if (ledgerResult.isNone) { + const bondedController = await api.query.staking.bonded(address); + if (bondedController.isSome) { + const controllerAddress = bondedController.unwrap().toString(); + console.log(`Found controller ${controllerAddress} for stash ${address}`); + ledgerResult = await api.query.staking.ledger(controllerAddress); + } + } + + if (ledgerResult.isNone) { + console.warn(`No staking ledger found for ${address}`); + return null; + } + + const ledger = ledgerResult.unwrap(); + const ledgerJson = ledger.toJSON() as any; + + console.log('Staking ledger:', ledgerJson); + + return { + stash: ledgerJson.stash?.toString() || address, + total: ledgerJson.total?.toString() || '0', + active: ledgerJson.active?.toString() || '0', + unlocking: (ledgerJson.unlocking || []).map((u: any) => ({ + value: u.value?.toString() || '0', + era: u.era || 0 + })), + claimedRewards: ledgerJson.claimedRewards || [] + }; + } catch (error) { + console.error('Error fetching staking ledger:', error); + return null; + } +} + +/** + * Get nominations for an account + */ +export async function getNominations( + api: ApiPromise, + address: string +): Promise { + try { + const nominatorsOption = await api.query.staking.nominators(address); + + if (nominatorsOption.isNone) { + return null; + } + + const nominator = nominatorsOption.unwrap(); + const nominatorJson = nominator.toJSON() as any; + + return { + targets: nominatorJson.targets || [], + submittedIn: nominatorJson.submittedIn || 0, + suppressed: nominatorJson.suppressed || false + }; + } catch (error) { + console.error('Error fetching nominations:', error); + return null; + } +} + +/** + * Get current active era + */ +export async function getCurrentEra(api: ApiPromise): Promise { + try { + const activeEraOption = await api.query.staking.activeEra(); + if (activeEraOption.isNone) { + return 0; + } + const activeEra = activeEraOption.unwrap(); + return Number(activeEra.index.toString()); + } catch (error) { + console.error('Error fetching current era:', error); + return 0; + } +} + +/** + * Get blocks remaining until an era + */ +export async function getBlocksUntilEra( + api: ApiPromise, + targetEra: number +): Promise { + try { + const currentEra = await getCurrentEra(api); + if (targetEra <= currentEra) { + return 0; + } + + const activeEraOption = await api.query.staking.activeEra(); + if (activeEraOption.isNone) { + return 0; + } + + const activeEra = activeEraOption.unwrap(); + const eraStartBlock = Number(activeEra.start.unwrapOr(0).toString()); + + // Get session length and sessions per era + const sessionLength = api.consts.babe?.epochDuration || api.consts.timestamp?.minimumPeriod || 600; + const sessionsPerEra = api.consts.staking.sessionsPerEra; + + const blocksPerEra = Number(sessionLength.toString()) * Number(sessionsPerEra.toString()); + const currentBlock = Number((await api.query.system.number()).toString()); + + const erasRemaining = targetEra - currentEra; + const blocksIntoCurrentEra = currentBlock - eraStartBlock; + const blocksRemainingInCurrentEra = blocksPerEra - blocksIntoCurrentEra; + + return blocksRemainingInCurrentEra + (blocksPerEra * (erasRemaining - 1)); + } catch (error) { + console.error('Error calculating blocks until era:', error); + return 0; + } +} + +/** + * Get PEZ rewards information for an account + */ +export async function getPezRewards( + api: ApiPromise, + address: string +): Promise { + try { + // Check if pezRewards pallet exists + if (!api.query.pezRewards || !api.query.pezRewards.epochInfo) { + console.warn('PezRewards pallet not available'); + return null; + } + + // Get current epoch info + const epochInfoResult = await api.query.pezRewards.epochInfo(); + + if (!epochInfoResult) { + console.warn('No epoch info found'); + return null; + } + + const epochInfo = epochInfoResult.toJSON() as any; + const currentEpoch = epochInfo.currentEpoch || 0; + const epochStartBlock = epochInfo.epochStartBlock || 0; + + // Check for claimable rewards from completed epochs + const claimableRewards: { epoch: number; amount: string }[] = []; + let totalClaimable = BigInt(0); + + // Check last 3 completed epochs for unclaimed rewards + for (let i = Math.max(0, currentEpoch - 3); i < currentEpoch; i++) { + try { + // Check if user has claimed this epoch already + const claimedResult = await api.query.pezRewards.claimedRewards(i, address); + + if (claimedResult.isNone) { + // User hasn't claimed - check if they have rewards + const userScoreResult = await api.query.pezRewards.userEpochScores(i, address); + + if (userScoreResult.isSome) { + // User has a score for this epoch - calculate their reward + const epochPoolResult = await api.query.pezRewards.epochRewardPools(i); + + if (epochPoolResult.isSome) { + const epochPool = epochPoolResult.unwrap().toJSON() as any; + const userScore = BigInt(userScoreResult.unwrap().toString()); + const rewardPerPoint = BigInt(epochPool.rewardPerTrustPoint || '0'); + + const rewardAmount = userScore * rewardPerPoint; + const rewardFormatted = formatBalance(rewardAmount.toString()); + + if (parseFloat(rewardFormatted) > 0) { + claimableRewards.push({ + epoch: i, + amount: rewardFormatted + }); + totalClaimable += rewardAmount; + } + } + } + } + } catch (err) { + console.warn(`Error checking epoch ${i} rewards:`, err); + } + } + + return { + currentEpoch, + epochStartBlock, + claimableRewards, + totalClaimable: formatBalance(totalClaimable.toString()), + hasPendingClaim: claimableRewards.length > 0 + }; + } catch (error) { + console.warn('PEZ rewards not available:', error); + return null; + } +} + +/** + * Get comprehensive staking info for an account + */ +export async function getStakingInfo( + api: ApiPromise, + address: string +): Promise { + const ledger = await getStakingLedger(api, address); + const nominations = await getNominations(api, address); + const currentEra = await getCurrentEra(api); + + const unlocking = ledger?.unlocking || []; + const unlockingWithBlocks = await Promise.all( + unlocking.map(async (u) => { + const blocks = await getBlocksUntilEra(api, u.era); + return { + amount: formatBalance(u.value), + era: u.era, + blocksRemaining: blocks + }; + }) + ); + + // Calculate redeemable (unlocking chunks where era has passed) + const redeemableChunks = unlocking.filter(u => u.era <= currentEra); + const redeemable = redeemableChunks.reduce((sum, u) => { + return sum + BigInt(u.value); + }, BigInt(0)); + + // Get staking score if available + // Score calculation based on Pezkuwi's pallet_staking_score logic + let stakingScore: number | null = null; + let stakingDuration: number | null = null; + let hasStartedScoreTracking = false; + + try { + if (api.query.stakingScore && api.query.stakingScore.stakingStartBlock) { + // Check if user has started score tracking + const scoreResult = await api.query.stakingScore.stakingStartBlock(address); + + if (scoreResult.isSome) { + hasStartedScoreTracking = true; + const startBlock = Number(scoreResult.unwrap().toString()); + const currentBlock = Number((await api.query.system.number()).toString()); + const durationInBlocks = currentBlock - startBlock; + stakingDuration = durationInBlocks; + + // Calculate amount-based score (20-50 points) + const stakedHEZ = ledger ? parseFloat(formatBalance(ledger.total)) : 0; + let amountScore = 20; // Default + + if (stakedHEZ <= 100) { + amountScore = 20; + } else if (stakedHEZ <= 250) { + amountScore = 30; + } else if (stakedHEZ <= 750) { + amountScore = 40; + } else { + amountScore = 50; // 751+ HEZ + } + + // Calculate duration multiplier + const MONTH_IN_BLOCKS = 30 * 24 * 60 * 10; // ~30 days worth of blocks (6s per block) + let durationMultiplier = 1.0; + + if (durationInBlocks >= 12 * MONTH_IN_BLOCKS) { + durationMultiplier = 2.0; // 12+ months + } else if (durationInBlocks >= 6 * MONTH_IN_BLOCKS) { + durationMultiplier = 1.7; // 6-11 months + } else if (durationInBlocks >= 3 * MONTH_IN_BLOCKS) { + durationMultiplier = 1.4; // 3-5 months + } else if (durationInBlocks >= MONTH_IN_BLOCKS) { + durationMultiplier = 1.2; // 1-2 months + } else { + durationMultiplier = 1.0; // < 1 month + } + + // Final score calculation (max 100) + stakingScore = Math.min(100, Math.floor(amountScore * durationMultiplier)); + + console.log('Staking score calculated:', { + stakedHEZ, + amountScore, + durationInBlocks, + durationMultiplier, + finalScore: stakingScore + }); + } + } + } catch (error) { + console.warn('Staking score not available:', error); + } + + // Check if validator + const validatorsOption = await api.query.staking.validators(address); + const isValidator = validatorsOption.isSome; + + // Get PEZ rewards information + const pezRewards = await getPezRewards(api, address); + + return { + bonded: ledger ? formatBalance(ledger.total) : '0', + active: ledger ? formatBalance(ledger.active) : '0', + unlocking: unlockingWithBlocks, + redeemable: formatBalance(redeemable.toString()), + nominations: nominations?.targets || [], + stakingScore, + stakingDuration, + hasStartedScoreTracking, + isValidator, + pezRewards + }; +} + +/** + * Get list of active validators + * For Pezkuwi, we query staking.validators.entries() to get all registered validators + */ +export async function getActiveValidators(api: ApiPromise): Promise { + try { + // Try multiple methods to get validators + + // Method 1: Try validatorPool.currentValidatorSet() if available + if (api.query.validatorPool && api.query.validatorPool.currentValidatorSet) { + try { + const currentSetOption = await api.query.validatorPool.currentValidatorSet(); + if (currentSetOption.isSome) { + const validatorSet = currentSetOption.unwrap() as any; + // Extract validators array from the set structure + if (validatorSet.validators && Array.isArray(validatorSet.validators)) { + const validators = validatorSet.validators.map((v: any) => v.toString()); + if (validators.length > 0) { + console.log(`Found ${validators.length} validators from validatorPool.currentValidatorSet`); + return validators; + } + } + } + } catch (err) { + console.warn('validatorPool.currentValidatorSet query failed:', err); + } + } + + // Method 2: Query staking.validators.entries() to get all registered validators + try { + const validatorEntries = await api.query.staking.validators.entries(); + if (validatorEntries.length > 0) { + const validators = validatorEntries.map(([key]) => key.args[0].toString()); + console.log(`Found ${validators.length} validators from staking.validators.entries()`); + return validators; + } + } catch (err) { + console.warn('staking.validators.entries() query failed:', err); + } + + // Method 3: Fallback to session.validators() + const sessionValidators = await api.query.session.validators(); + const validators = sessionValidators.map(v => v.toString()); + console.log(`Found ${validators.length} validators from session.validators()`); + return validators; + } catch (error) { + console.error('Error fetching validators:', error); + return []; + } +} + +/** + * Get minimum nominator bond + */ +export async function getMinNominatorBond(api: ApiPromise): Promise { + try { + const minBond = await api.query.staking.minNominatorBond(); + return formatBalance(minBond.toString()); + } catch (error) { + console.error('Error fetching min nominator bond:', error); + return '0'; + } +} + +/** + * Get bonding duration in eras + */ +export async function getBondingDuration(api: ApiPromise): Promise { + try { + const duration = api.consts.staking.bondingDuration; + return Number(duration.toString()); + } catch (error) { + console.error('Error fetching bonding duration:', error); + return 28; // Default 28 eras + } +} + +/** + * Parse amount to blockchain format (12 decimals for HEZ) + */ +export function parseAmount(amount: string | number, decimals: number = 12): string { + const amountNum = typeof amount === 'string' ? parseFloat(amount) : amount; + if (isNaN(amountNum) || amountNum <= 0) { + throw new Error('Invalid amount'); + } + const value = BigInt(Math.floor(amountNum * Math.pow(10, decimals))); + return value.toString(); +}