fix: use ClaimedRewards double-map for accurate unclaimed reward detection

- Replace legacy ledger.claimedRewards (empty in paged-rewards Substrate)
  with ClaimedRewards(era, validator) storage double-map
- Track user's page in erasStakersPaged and check against claimed pages
- Use historyDepth instead of hardcoded 10 eras
- Split batch payouts into groups of 5 to avoid block weight overflow
- Use utility.batch instead of batchAll for resilience
- Pass page to payoutStakersByPage when available
This commit is contained in:
2026-02-27 03:07:14 +03:00
parent b56760b22e
commit b800b36b9f
2 changed files with 169 additions and 77 deletions
+162 -73
View File
@@ -1,14 +1,18 @@
/** /**
* Staking Rewards - Unclaimed reward detection and payout for Asset Hub * Staking Rewards - Unclaimed reward detection and payout for Asset Hub
* Queries on-chain data to find unclaimed era rewards and submits payoutStakers calls * Queries on-chain data to find unclaimed era rewards and submits payoutStakers calls.
*
* Uses ClaimedRewards(era, validator) double-map storage to accurately detect
* which (era, validator, page) combinations have been claimed.
*/ */
import type { ApiPromise } from '@pezkuwi/api'; import type { ApiPromise } from '@pezkuwi/api';
import type { KeyringPair } from '@pezkuwi/keyring/types'; import type { KeyringPair } from '@pezkuwi/keyring/types';
const UNITS = 1_000_000_000_000; // 10^12 const UNITS = 1_000_000_000_000; // 10^12
const MAX_ERAS_TO_CHECK = 10; const DEFAULT_HISTORY_DEPTH = 84;
const MAX_PAGES_PER_VALIDATOR = 3; const MAX_PAGES_PER_VALIDATOR = 3;
const MAX_BATCH_SIZE = 5; // max payout calls per batch TX
// ======================================== // ========================================
// TYPES // TYPES
@@ -17,6 +21,7 @@ const MAX_PAGES_PER_VALIDATOR = 3;
export interface UnclaimedEraReward { export interface UnclaimedEraReward {
era: number; era: number;
validator: string; validator: string;
page: number;
estimatedReward: string; // formatted HEZ estimatedReward: string; // formatted HEZ
estimatedRewardRaw: bigint; estimatedRewardRaw: bigint;
} }
@@ -45,7 +50,8 @@ function formatHez(raw: bigint): string {
// ======================================== // ========================================
/** /**
* Find unclaimed staking rewards for an address on Asset Hub * Find unclaimed staking rewards for an address on Asset Hub.
* Checks the ClaimedRewards(era, validator) double-map for accurate detection.
*/ */
export async function getUnclaimedRewards( export async function getUnclaimedRewards(
assetHubApi: ApiPromise, assetHubApi: ApiPromise,
@@ -71,37 +77,55 @@ export async function getUnclaimedRewards(
// 2. Get ledger - if no ledger, user is not staking // 2. Get ledger - if no ledger, user is not staking
const ledgerOpt = await staking.ledger(address); const ledgerOpt = await staking.ledger(address);
if (!ledgerOpt || ledgerOpt.isNone) return { ...empty, currentEra }; if (!ledgerOpt || ledgerOpt.isNone) return { ...empty, currentEra };
const ledgerJson = ledgerOpt.unwrap().toJSON();
// Get claimed eras from ledger // 3. Get nominated validators (current + historical from exposure)
const claimedEras: number[] = ledgerJson.claimedRewards || ledgerJson.legacyClaimedRewards || [];
const claimedSet = new Set(claimedEras);
// 3. Get nominated validators
const nominatorsOpt = await staking.nominators(address); const nominatorsOpt = await staking.nominators(address);
if (!nominatorsOpt || nominatorsOpt.isNone) return { ...empty, currentEra }; if (!nominatorsOpt || nominatorsOpt.isNone) return { ...empty, currentEra };
const nominatorsJson = nominatorsOpt.unwrap().toJSON(); const nominatorsJson = nominatorsOpt.unwrap().toJSON();
const nominatedValidators: string[] = nominatorsJson.targets || []; const nominatedValidators: string[] = nominatorsJson.targets || [];
if (nominatedValidators.length === 0) return { ...empty, currentEra }; if (nominatedValidators.length === 0) return { ...empty, currentEra };
// 4. Check last N eras for unclaimed rewards // 4. Determine era range to check
const startEra = Math.max(0, currentEra - 1); let historyDepth = DEFAULT_HISTORY_DEPTH;
const endEra = Math.max(0, currentEra - MAX_ERAS_TO_CHECK); try {
if (staking.historyDepth) {
const hd = await staking.historyDepth();
const hdVal = hd?.toJSON?.() ?? hd?.toNumber?.();
if (typeof hdVal === 'number' && hdVal > 0) historyDepth = hdVal;
}
} catch {
// historyDepth might be a const, not a storage item
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const consts = (assetHubApi.consts.staking as any)?.historyDepth;
if (consts) {
const val = consts.toNumber?.() ?? Number(consts.toString());
if (val > 0) historyDepth = val;
}
} catch {
// use default
}
}
const startEra = Math.max(0, currentEra - 1); // current era is not yet finalized
const endEra = Math.max(0, currentEra - historyDepth);
const unclaimed: UnclaimedEraReward[] = []; const unclaimed: UnclaimedEraReward[] = [];
// Process eras in parallel batches // Process eras in parallel batches of 5 to avoid overwhelming RPC
const eraPromises: Promise<UnclaimedEraReward[]>[] = []; const PARALLEL_BATCH = 5;
for (let batchStart = startEra; batchStart >= endEra; batchStart -= PARALLEL_BATCH) {
const batchEnd = Math.max(endEra, batchStart - PARALLEL_BATCH + 1);
const eraPromises: Promise<UnclaimedEraReward[]>[] = [];
for (let era = startEra; era >= endEra; era--) { for (let era = batchStart; era >= batchEnd; era--) {
if (claimedSet.has(era)) continue; eraPromises.push(checkEraRewards(staking, era, address, nominatedValidators));
}
eraPromises.push(checkEraRewards(staking, era, address, nominatedValidators)); const results = await Promise.all(eraPromises);
} for (const eraResults of results) {
unclaimed.push(...eraResults);
const results = await Promise.all(eraPromises); }
for (const eraResults of results) {
unclaimed.push(...eraResults);
} }
// Sort by era descending // Sort by era descending
@@ -118,9 +142,9 @@ export async function getUnclaimedRewards(
} }
/** /**
* Check a single era for unclaimed rewards across all nominated validators * Check a single era for unclaimed rewards across all nominated validators.
* Uses ClaimedRewards(era, validator) to determine which pages are already claimed.
*/ */
async function checkEraRewards( async function checkEraRewards(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
staking: any, staking: any,
@@ -141,8 +165,26 @@ async function checkEraRewards(
const totalStake = BigInt(overview.total || '0'); const totalStake = BigInt(overview.total || '0');
if (totalStake === 0n) continue; if (totalStake === 0n) continue;
// Check if user is in the validator's exposure pages // Get claimed pages for this (era, validator) from ClaimedRewards double-map
let claimedPages: number[] = [];
try {
if (staking.claimedRewards) {
const claimedOpt = await staking.claimedRewards(era, validator);
if (claimedOpt) {
const claimedJson = claimedOpt.toJSON?.() ?? claimedOpt;
if (Array.isArray(claimedJson)) {
claimedPages = claimedJson;
}
}
}
} catch {
// claimedRewards storage might not exist on older runtimes
}
const claimedPageSet = new Set(claimedPages);
// Find user in the validator's exposure pages and check if their page is claimed
let userStake = 0n; let userStake = 0n;
let userPage = -1;
const pagesToCheck = Math.min(pageCount, MAX_PAGES_PER_VALIDATOR); const pagesToCheck = Math.min(pageCount, MAX_PAGES_PER_VALIDATOR);
for (let page = 0; page < pagesToCheck; page++) { for (let page = 0; page < pagesToCheck; page++) {
@@ -155,13 +197,17 @@ async function checkEraRewards(
for (const nominator of others) { for (const nominator of others) {
if (nominator.who === address) { if (nominator.who === address) {
userStake = BigInt(nominator.value); userStake = BigInt(nominator.value);
userPage = page;
break; break;
} }
} }
if (userStake > 0n) break; if (userStake > 0n) break;
} }
if (userStake === 0n) continue; if (userStake === 0n || userPage < 0) continue;
// Skip if user's page is already claimed
if (claimedPageSet.has(userPage)) continue;
// Calculate estimated reward // Calculate estimated reward
const reward = await calculateEraReward(staking, era, validator, userStake, totalStake); const reward = await calculateEraReward(staking, era, validator, userStake, totalStake);
@@ -170,6 +216,7 @@ async function checkEraRewards(
results.push({ results.push({
era, era,
validator, validator,
page: userPage,
estimatedReward: formatHez(reward), estimatedReward: formatHez(reward),
estimatedRewardRaw: reward, estimatedRewardRaw: reward,
}); });
@@ -184,9 +231,8 @@ async function checkEraRewards(
/** /**
* Calculate estimated reward for a nominator in a specific era * Calculate estimated reward for a nominator in a specific era
* Formula: eraReward × (valPoints/totalPoints) × (1 - commission/1e9) × (userStake/totalValStake) * Formula: eraReward * (valPoints/totalPoints) * (1 - commission/1e9) * (userStake/totalValStake)
*/ */
async function calculateEraReward( async function calculateEraReward(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
staking: any, staking: any,
@@ -238,18 +284,26 @@ async function calculateEraReward(
// ======================================== // ========================================
/** /**
* Submit payoutStakers for a single era+validator * Submit payoutStakersByPage for a single era+validator+page.
* Falls back to payoutStakers if payoutStakersByPage is not available.
*/ */
export async function payoutStakingReward( export async function payoutStakingReward(
assetHubApi: ApiPromise, assetHubApi: ApiPromise,
keypair: KeyringPair, keypair: KeyringPair,
validator: string, validator: string,
era: number era: number,
page?: number
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => { return new Promise((resolve) => {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (assetHubApi.tx.staking as any).payoutStakers(validator, era); const stakingTx = assetHubApi.tx.staking as any;
// Use payoutStakersByPage if available and page is specified
const tx =
page !== undefined && stakingTx.payoutStakersByPage
? stakingTx.payoutStakersByPage(validator, era, page)
: stakingTx.payoutStakers(validator, era);
tx.signAndSend( tx.signAndSend(
keypair, keypair,
@@ -278,66 +332,101 @@ export async function payoutStakingReward(
} }
/** /**
* Submit payoutStakers for all unclaimed rewards using utility.batchAll * Submit payoutStakers for all unclaimed rewards in batches.
* Falls back to sequential calls if utility pallet is not available * Splits into batches of MAX_BATCH_SIZE to avoid block weight limits.
* Uses utility.batch (not batchAll) so one failure doesn't abort the whole batch.
*/ */
export async function payoutAllRewards( export async function payoutAllRewards(
assetHubApi: ApiPromise, assetHubApi: ApiPromise,
keypair: KeyringPair, keypair: KeyringPair,
unclaimed: UnclaimedEraReward[] unclaimed: UnclaimedEraReward[]
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string; completed?: number }> {
if (unclaimed.length === 0) return { success: true }; if (unclaimed.length === 0) return { success: true, completed: 0 };
// Single reward - no need for batch // Single reward - no need for batch
if (unclaimed.length === 1) { if (unclaimed.length === 1) {
return payoutStakingReward(assetHubApi, keypair, unclaimed[0].validator, unclaimed[0].era); const r = unclaimed[0];
const result = await payoutStakingReward(assetHubApi, keypair, r.validator, r.era, r.page);
return { ...result, completed: result.success ? 1 : 0 };
} }
// Try utility.batchAll
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const utilityTx = (assetHubApi.tx as any).utility; const utilityTx = (assetHubApi.tx as any).utility;
if (utilityTx?.batchAll) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const calls = unclaimed.map((r) => const stakingTx = assetHubApi.tx.staking as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(assetHubApi.tx.staking as any).payoutStakers(r.validator, r.era)
);
return new Promise((resolve) => { let totalCompleted = 0;
try {
utilityTx // Split into batches
.batchAll(calls) for (let i = 0; i < unclaimed.length; i += MAX_BATCH_SIZE) {
.signAndSend( const batch = unclaimed.slice(i, i + MAX_BATCH_SIZE);
keypair,
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (batch.length === 1) {
({ status, dispatchError }: any) => { const r = batch[0];
if (status.isFinalized) { const result = await payoutStakingReward(assetHubApi, keypair, r.validator, r.era, r.page);
if (dispatchError) { if (result.success) {
if (dispatchError.isModule) { totalCompleted++;
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); } else {
resolve({ success: false, error: `${decoded.section}.${decoded.name}` }); return { success: false, error: result.error, completed: totalCompleted };
}
continue;
}
// Use utility.batch for this chunk
if (utilityTx?.batch) {
const calls = batch.map((r) =>
r.page !== undefined && stakingTx.payoutStakersByPage
? stakingTx.payoutStakersByPage(r.validator, r.era, r.page)
: stakingTx.payoutStakers(r.validator, r.era)
);
const result = await new Promise<{ success: boolean; error?: string }>((resolve) => {
try {
utilityTx
.batch(calls)
.signAndSend(
keypair,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ status, dispatchError }: any) => {
if (status.isFinalized) {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
resolve({ success: false, error: `${decoded.section}.${decoded.name}` });
} else {
resolve({ success: false, error: dispatchError.toString() });
}
} else { } else {
resolve({ success: false, error: dispatchError.toString() }); resolve({ success: true });
} }
} else {
resolve({ success: true });
} }
} }
} )
) .catch((err: Error) => {
.catch((err: Error) => { resolve({ success: false, error: err.message });
resolve({ success: false, error: err.message }); });
}); } catch (err) {
} catch (err) { resolve({ success: false, error: err instanceof Error ? err.message : String(err) });
resolve({ success: false, error: err instanceof Error ? err.message : String(err) }); }
});
if (result.success) {
totalCompleted += batch.length;
} else {
return { success: false, error: result.error, completed: totalCompleted };
} }
}); } else {
// Fallback: sequential calls
for (const r of batch) {
const result = await payoutStakingReward(assetHubApi, keypair, r.validator, r.era, r.page);
if (result.success) {
totalCompleted++;
} else {
return { success: false, error: result.error, completed: totalCompleted };
}
}
}
} }
// Fallback: sequential calls return { success: true, completed: totalCompleted };
for (const reward of unclaimed) {
const result = await payoutStakingReward(assetHubApi, keypair, reward.validator, reward.era);
if (!result.success) return result;
}
return { success: true };
} }
+7 -4
View File
@@ -300,14 +300,14 @@ export function RewardsSection() {
} }
}; };
const handleClaimStakingReward = async (validator: string, era: number) => { const handleClaimStakingReward = async (validator: string, era: number, page?: number) => {
if (!assetHubApi || !keypair) return; if (!assetHubApi || !keypair) return;
setClaimingStakingEra(era); setClaimingStakingEra(era);
setTrackingAnimationText(t('rewards.claimingStakingReward')); setTrackingAnimationText(t('rewards.claimingStakingReward'));
setShowTrackingAnimation(true); setShowTrackingAnimation(true);
hapticImpact('medium'); hapticImpact('medium');
try { try {
const result = await payoutStakingReward(assetHubApi, keypair, validator, era); const result = await payoutStakingReward(assetHubApi, keypair, validator, era, page);
if (result.success) { if (result.success) {
hapticNotification('success'); hapticNotification('success');
showAlert(t('rewards.stakingClaimSuccess')); showAlert(t('rewards.stakingClaimSuccess'));
@@ -1145,7 +1145,9 @@ export function RewardsSection() {
</p> </p>
</div> </div>
<button <button
onClick={() => handleClaimStakingReward(reward.validator, reward.era)} onClick={() =>
handleClaimStakingReward(reward.validator, reward.era, reward.page)
}
disabled={!keypair || claimingStaking || claimingStakingEra !== null} disabled={!keypair || claimingStaking || claimingStakingEra !== null}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 transition-all disabled:opacity-50 ml-2" className="px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 transition-all disabled:opacity-50 ml-2"
> >
@@ -1176,7 +1178,8 @@ export function RewardsSection() {
onClick={() => onClick={() =>
handleClaimStakingReward( handleClaimStakingReward(
unclaimedRewards.unclaimed[0].validator, unclaimedRewards.unclaimed[0].validator,
unclaimedRewards.unclaimed[0].era unclaimedRewards.unclaimed[0].era,
unclaimedRewards.unclaimed[0].page
) )
} }
disabled={!keypair || claimingStaking || claimingStakingEra !== null} disabled={!keypair || claimingStaking || claimingStakingEra !== null}