mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 04:27:56 +00:00
feat: Implement live blockchain staking with score tracking and PEZ rewards
- Add comprehensive staking library (src/lib/staking.ts) with blockchain queries - Rewrite StakingDashboard to use validator nomination staking - Implement real-time staking score from pallet_staking_score - Add PEZ rewards display from pallet_pez_rewards - Fix bonded amount display with stash/controller account handling - Add 5-card dashboard: Total Bonded, Unlocking, Redeemable, Staking Score, PEZ Rewards - Implement bond, nominate, unbond, and withdraw functionality - Add auto-refresh every 30 seconds for real-time data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
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<StakingLedger | null> {
|
||||
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<NominatorInfo | null> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<PezRewardInfo | null> {
|
||||
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<StakingInfo> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
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<number> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user