feat: migrate staking from Relay Chain to Asset Hub

- shared/staking.ts: update for AH (remove babe dep, remove validatorPool, bonding 2 eras)
- StakingDashboard: switch all staking operations from api (RC) to assetHubApi (AH)
This commit is contained in:
2026-02-21 02:55:27 +03:00
parent 9fd6b79249
commit d9d5112383
2 changed files with 80 additions and 87 deletions
+48 -55
View File
@@ -1,7 +1,9 @@
// ======================================== // ========================================
// Staking Helper Functions // Staking Helper Functions (Asset Hub)
// ======================================== // ========================================
// Helper functions for pallet_staking and pallet_staking_score integration // Helper functions for pallet_staking_async on Asset Hub and pallet_staking_score on People Chain.
// Staking was moved from Relay Chain to Asset Hub.
// The `api` parameter in all functions refers to the Asset Hub API connection.
import { ApiPromise } from '@pezkuwi/api'; import { ApiPromise } from '@pezkuwi/api';
import { formatBalance } from './wallet'; import { formatBalance } from './wallet';
@@ -143,7 +145,9 @@ export async function getCurrentEra(api: ApiPromise): Promise<number> {
} }
/** /**
* Get blocks remaining until an era * Get estimated time remaining until an era (in seconds).
* Asset Hub uses Aura (no babe.epochDuration), so we estimate based on
* activeEra.start timestamp + sessionsPerEra * estimated session duration.
*/ */
export async function getBlocksUntilEra( export async function getBlocksUntilEra(
api: ApiPromise, api: ApiPromise,
@@ -155,26 +159,33 @@ export async function getBlocksUntilEra(
return 0; return 0;
} }
const erasRemaining = targetEra - currentEra;
// Try to get sessionsPerEra from staking constants
const sessionsPerEra = api.consts.staking?.sessionsPerEra
? Number(api.consts.staking.sessionsPerEra.toString())
: 6; // Default: 6 sessions per era on AH
// Estimate session duration: ~1 hour (3600 seconds / 6s block time = 600 blocks)
const ESTIMATED_SESSION_BLOCKS = 600;
const blocksPerEra = sessionsPerEra * ESTIMATED_SESSION_BLOCKS;
// Try to estimate blocks remaining in current era using activeEra.start
const activeEraOption = await api.query.staking.activeEra(); const activeEraOption = await api.query.staking.activeEra();
if (activeEraOption.isNone) { if (activeEraOption.isSome) {
return 0; const activeEra = activeEraOption.unwrap() as { start: { unwrapOr: (def: number) => { toString: () => string } } };
const eraStartSlot = Number(activeEra.start.unwrapOr(0).toString());
if (eraStartSlot > 0) {
const currentBlock = Number((await api.query.system.number()).toString());
const blocksIntoCurrentEra = currentBlock - eraStartSlot;
const blocksRemainingInCurrentEra = Math.max(0, blocksPerEra - blocksIntoCurrentEra);
return blocksRemainingInCurrentEra + (blocksPerEra * (erasRemaining - 1));
}
} }
const activeEra = activeEraOption.unwrap() as { start: { unwrapOr: (def: number) => { toString: () => string } } }; // Fallback: just multiply eras remaining by estimated blocks per era
const eraStartBlock = Number(activeEra.start.unwrapOr(0).toString()); return blocksPerEra * erasRemaining;
// 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) { } catch (error) {
console.error('Error calculating blocks until era:', error); console.error('Error calculating blocks until era:', error);
return 0; return 0;
@@ -183,7 +194,7 @@ export async function getBlocksUntilEra(
/** /**
* Get comprehensive staking info for an account * Get comprehensive staking info for an account
* @param api - Relay Chain API (for staking pallet) * @param api - Asset Hub API (staking pallet moved from RC to AH)
* @param address - User address * @param address - User address
* @param peopleApi - Optional People Chain API (for pezRewards and stakingScore pallets) * @param peopleApi - Optional People Chain API (for pezRewards and stakingScore pallets)
*/ */
@@ -356,34 +367,12 @@ export async function getStakingInfo(
} }
/** /**
* Get list of active validators * Get list of active validators from Asset Hub staking pallet.
* For Pezkuwi, we query staking.validators.entries() to get all registered validators * Note: validatorPool pallet is on Relay Chain, not AH. We only use staking.validators here.
*/ */
export async function getActiveValidators(api: ApiPromise): Promise<string[]> { export async function getActiveValidators(api: ApiPromise): Promise<string[]> {
try { try {
// Try multiple methods to get validators // Method 1: Query staking.validators.entries() on Asset Hub
// 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 { try {
const validatorEntries = await api.query.staking.validators.entries(); const validatorEntries = await api.query.staking.validators.entries();
if (validatorEntries.length > 0) { if (validatorEntries.length > 0) {
@@ -395,14 +384,18 @@ export async function getActiveValidators(api: ApiPromise): Promise<string[]> {
console.warn('staking.validators.entries() query failed:', err); console.warn('staking.validators.entries() query failed:', err);
} }
// Method 3: Fallback to session.validators() // Method 2: Fallback to session.validators() if available on AH
const sessionValidators = await api.query.session.validators(); if (api.query.session?.validators) {
const validatorArray = Array.isArray(sessionValidators) const sessionValidators = await api.query.session.validators();
? sessionValidators const validatorArray = Array.isArray(sessionValidators)
: (sessionValidators as unknown as { toJSON: () => string[] }).toJSON(); ? sessionValidators
const validators = validatorArray.map((v: unknown) => String(v)); : (sessionValidators as unknown as { toJSON: () => string[] }).toJSON();
console.log(`Found ${validators.length} validators from session.validators()`); const validators = validatorArray.map((v: unknown) => String(v));
return validators; console.log(`Found ${validators.length} validators from session.validators()`);
return validators;
}
return [];
} catch (error) { } catch (error) {
console.error('Error fetching validators:', error); console.error('Error fetching validators:', error);
return []; return [];
@@ -431,7 +424,7 @@ export async function getBondingDuration(api: ApiPromise): Promise<number> {
return Number(duration.toString()); return Number(duration.toString());
} catch (error) { } catch (error) {
console.error('Error fetching bonding duration:', error); console.error('Error fetching bonding duration:', error);
return 28; // Default 28 eras return 2; // Default 2 eras (AH bonding duration)
} }
} }
+32 -32
View File
@@ -44,7 +44,7 @@ async function getInjectorSigner(address: string) {
} }
export const StakingDashboard: React.FC = () => { export const StakingDashboard: React.FC = () => {
const { api, peopleApi, selectedAccount, isApiReady, isPeopleReady } = usePezkuwi(); const { assetHubApi, peopleApi, selectedAccount, isAssetHubReady, isPeopleReady } = usePezkuwi();
const { balances, refreshBalances } = useWallet(); const { balances, refreshBalances } = useWallet();
const [stakingInfo, setStakingInfo] = useState<StakingInfo | null>(null); const [stakingInfo, setStakingInfo] = useState<StakingInfo | null>(null);
@@ -62,21 +62,21 @@ export const StakingDashboard: React.FC = () => {
const [isRecordingScore, setIsRecordingScore] = useState(false); const [isRecordingScore, setIsRecordingScore] = useState(false);
const [isClaimingReward, setIsClaimingReward] = useState(false); const [isClaimingReward, setIsClaimingReward] = useState(false);
// Fetch staking data // Fetch staking data from Asset Hub
useEffect(() => { useEffect(() => {
const fetchStakingData = async () => { const fetchStakingData = async () => {
if (!api || !isApiReady || !selectedAccount) { if (!assetHubApi || !isAssetHubReady || !selectedAccount) {
return; return;
} }
setIsLoadingData(true); setIsLoadingData(true);
try { try {
const [info, activeVals, minBond, duration, era] = await Promise.all([ const [info, activeVals, minBond, duration, era] = await Promise.all([
getStakingInfo(api, selectedAccount.address, peopleApi || undefined), getStakingInfo(assetHubApi, selectedAccount.address, peopleApi || undefined),
getActiveValidators(api), getActiveValidators(assetHubApi),
getMinNominatorBond(api), getMinNominatorBond(assetHubApi),
getBondingDuration(api), getBondingDuration(assetHubApi),
getCurrentEra(api) getCurrentEra(assetHubApi)
]); ]);
setStakingInfo(info); setStakingInfo(info);
@@ -101,7 +101,7 @@ export const StakingDashboard: React.FC = () => {
fetchStakingData(); fetchStakingData();
const interval = setInterval(fetchStakingData, 30000); // Refresh every 30s const interval = setInterval(fetchStakingData, 30000); // Refresh every 30s
return () => clearInterval(interval); return () => clearInterval(interval);
}, [api, peopleApi, isApiReady, isPeopleReady, selectedAccount]); }, [assetHubApi, peopleApi, isAssetHubReady, isPeopleReady, selectedAccount]);
// Fetch PEZ rewards data separately from People Chain // Fetch PEZ rewards data separately from People Chain
useEffect(() => { useEffect(() => {
@@ -180,7 +180,7 @@ export const StakingDashboard: React.FC = () => {
}; };
const handleBond = async () => { const handleBond = async () => {
if (!api || !selectedAccount || !bondAmount) return; if (!assetHubApi || !selectedAccount || !bondAmount) return;
setIsLoading(true); setIsLoading(true);
try { try {
@@ -200,10 +200,10 @@ export const StakingDashboard: React.FC = () => {
// If already bonded, use bondExtra, otherwise use bond // If already bonded, use bondExtra, otherwise use bond
let tx; let tx;
if (stakingInfo && parseFloat(stakingInfo.bonded) > 0) { if (stakingInfo && parseFloat(stakingInfo.bonded) > 0) {
tx = api.tx.staking.bondExtra(amount); tx = assetHubApi.tx.staking.bondExtra(amount);
} else { } else {
// For new bond, also need to specify reward destination // For new bond, also need to specify reward destination
tx = api.tx.staking.bond(amount, 'Staked'); // Auto-compound rewards tx = assetHubApi.tx.staking.bond(amount, 'Staked'); // Auto-compound rewards
} }
await tx.signAndSend( await tx.signAndSend(
@@ -214,7 +214,7 @@ export const StakingDashboard: React.FC = () => {
if (import.meta.env.DEV) console.log('Transaction in block:', status.asInBlock.toHex()); if (import.meta.env.DEV) console.log('Transaction in block:', status.asInBlock.toHex());
if (dispatchError) { if (dispatchError) {
handleBlockchainError(dispatchError, api, toast); handleBlockchainError(dispatchError, assetHubApi, toast);
setIsLoading(false); setIsLoading(false);
} else { } else {
handleBlockchainSuccess('staking.bonded', toast, { amount: bondAmount }); handleBlockchainSuccess('staking.bonded', toast, { amount: bondAmount });
@@ -222,8 +222,8 @@ export const StakingDashboard: React.FC = () => {
refreshBalances(); refreshBalances();
// Refresh staking data after a delay // Refresh staking data after a delay
setTimeout(() => { setTimeout(() => {
if (api && selectedAccount) { if (assetHubApi && selectedAccount) {
getStakingInfo(api, selectedAccount.address, peopleApi || undefined).then(setStakingInfo); getStakingInfo(assetHubApi, selectedAccount.address, peopleApi || undefined).then(setStakingInfo);
} }
}, 3000); }, 3000);
setIsLoading(false); setIsLoading(false);
@@ -239,7 +239,7 @@ export const StakingDashboard: React.FC = () => {
}; };
const handleNominate = async () => { const handleNominate = async () => {
if (!api || !selectedAccount || selectedValidators.length === 0) return; if (!assetHubApi || !selectedAccount || selectedValidators.length === 0) return;
if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) { if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) {
toast.error('You must bond tokens before nominating validators'); toast.error('You must bond tokens before nominating validators');
@@ -250,7 +250,7 @@ export const StakingDashboard: React.FC = () => {
try { try {
const injector = await getInjectorSigner(selectedAccount.address); const injector = await getInjectorSigner(selectedAccount.address);
const tx = api.tx.staking.nominate(selectedValidators); const tx = assetHubApi.tx.staking.nominate(selectedValidators);
await tx.signAndSend( await tx.signAndSend(
selectedAccount.address, selectedAccount.address,
@@ -258,14 +258,14 @@ export const StakingDashboard: React.FC = () => {
({ status, dispatchError }) => { ({ status, dispatchError }) => {
if (status.isInBlock) { if (status.isInBlock) {
if (dispatchError) { if (dispatchError) {
handleBlockchainError(dispatchError, api, toast); handleBlockchainError(dispatchError, assetHubApi, toast);
setIsLoading(false); setIsLoading(false);
} else { } else {
handleBlockchainSuccess('staking.nominated', toast, { count: selectedValidators.length.toString() }); handleBlockchainSuccess('staking.nominated', toast, { count: selectedValidators.length.toString() });
// Refresh staking data // Refresh staking data
setTimeout(() => { setTimeout(() => {
if (api && selectedAccount) { if (assetHubApi && selectedAccount) {
getStakingInfo(api, selectedAccount.address, peopleApi || undefined).then(setStakingInfo); getStakingInfo(assetHubApi, selectedAccount.address, peopleApi || undefined).then(setStakingInfo);
} }
}, 3000); }, 3000);
setIsLoading(false); setIsLoading(false);
@@ -281,7 +281,7 @@ export const StakingDashboard: React.FC = () => {
}; };
const handleUnbond = async () => { const handleUnbond = async () => {
if (!api || !selectedAccount || !unbondAmount) return; if (!assetHubApi || !selectedAccount || !unbondAmount) return;
setIsLoading(true); setIsLoading(true);
try { try {
@@ -292,7 +292,7 @@ export const StakingDashboard: React.FC = () => {
} }
const injector = await getInjectorSigner(selectedAccount.address); const injector = await getInjectorSigner(selectedAccount.address);
const tx = api.tx.staking.unbond(amount); const tx = assetHubApi.tx.staking.unbond(amount);
await tx.signAndSend( await tx.signAndSend(
selectedAccount.address, selectedAccount.address,
@@ -300,7 +300,7 @@ export const StakingDashboard: React.FC = () => {
({ status, dispatchError }) => { ({ status, dispatchError }) => {
if (status.isInBlock) { if (status.isInBlock) {
if (dispatchError) { if (dispatchError) {
handleBlockchainError(dispatchError, api, toast); handleBlockchainError(dispatchError, assetHubApi, toast);
setIsLoading(false); setIsLoading(false);
} else { } else {
handleBlockchainSuccess('staking.unbonded', toast, { handleBlockchainSuccess('staking.unbonded', toast, {
@@ -309,8 +309,8 @@ export const StakingDashboard: React.FC = () => {
}); });
setUnbondAmount(''); setUnbondAmount('');
setTimeout(() => { setTimeout(() => {
if (api && selectedAccount) { if (assetHubApi && selectedAccount) {
getStakingInfo(api, selectedAccount.address, peopleApi || undefined).then(setStakingInfo); getStakingInfo(assetHubApi, selectedAccount.address, peopleApi || undefined).then(setStakingInfo);
} }
}, 3000); }, 3000);
setIsLoading(false); setIsLoading(false);
@@ -326,7 +326,7 @@ export const StakingDashboard: React.FC = () => {
}; };
const handleWithdrawUnbonded = async () => { const handleWithdrawUnbonded = async () => {
if (!api || !selectedAccount) return; if (!assetHubApi || !selectedAccount) return;
if (!stakingInfo || parseFloat(stakingInfo.redeemable) === 0) { if (!stakingInfo || parseFloat(stakingInfo.redeemable) === 0) {
toast.info('No tokens available to withdraw'); toast.info('No tokens available to withdraw');
@@ -338,7 +338,7 @@ export const StakingDashboard: React.FC = () => {
const injector = await getInjectorSigner(selectedAccount.address); const injector = await getInjectorSigner(selectedAccount.address);
// Number of slashing spans (usually 0) // Number of slashing spans (usually 0)
const tx = api.tx.staking.withdrawUnbonded(0); const tx = assetHubApi.tx.staking.withdrawUnbonded(0);
await tx.signAndSend( await tx.signAndSend(
selectedAccount.address, selectedAccount.address,
@@ -348,7 +348,7 @@ export const StakingDashboard: React.FC = () => {
if (dispatchError) { if (dispatchError) {
let errorMessage = 'Withdrawal failed'; let errorMessage = 'Withdrawal failed';
if (dispatchError.isModule) { if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule); const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} }
toast.error(errorMessage); toast.error(errorMessage);
@@ -357,8 +357,8 @@ export const StakingDashboard: React.FC = () => {
toast.success(`Withdrew ${stakingInfo.redeemable} HEZ`); toast.success(`Withdrew ${stakingInfo.redeemable} HEZ`);
refreshBalances(); refreshBalances();
setTimeout(() => { setTimeout(() => {
if (api && selectedAccount) { if (assetHubApi && selectedAccount) {
getStakingInfo(api, selectedAccount.address, peopleApi || undefined).then(setStakingInfo); getStakingInfo(assetHubApi, selectedAccount.address, peopleApi || undefined).then(setStakingInfo);
} }
}, 3000); }, 3000);
setIsLoading(false); setIsLoading(false);
@@ -399,8 +399,8 @@ export const StakingDashboard: React.FC = () => {
toast.success('Score tracking started successfully! Your staking score will now accumulate over time.'); toast.success('Score tracking started successfully! Your staking score will now accumulate over time.');
// Refresh staking data after a delay // Refresh staking data after a delay
setTimeout(() => { setTimeout(() => {
if (api && selectedAccount) { if (assetHubApi && selectedAccount) {
getStakingInfo(api, selectedAccount.address, peopleApi || undefined).then(setStakingInfo); getStakingInfo(assetHubApi, selectedAccount.address, peopleApi || undefined).then(setStakingInfo);
} }
}, 3000); }, 3000);
setIsLoading(false); setIsLoading(false);