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
* 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 { KeyringPair } from '@pezkuwi/keyring/types';
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_BATCH_SIZE = 5; // max payout calls per batch TX
// ========================================
// TYPES
@@ -17,6 +21,7 @@ const MAX_PAGES_PER_VALIDATOR = 3;
export interface UnclaimedEraReward {
era: number;
validator: string;
page: number;
estimatedReward: string; // formatted HEZ
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(
assetHubApi: ApiPromise,
@@ -71,37 +77,55 @@ export async function getUnclaimedRewards(
// 2. Get ledger - if no ledger, user is not staking
const ledgerOpt = await staking.ledger(address);
if (!ledgerOpt || ledgerOpt.isNone) return { ...empty, currentEra };
const ledgerJson = ledgerOpt.unwrap().toJSON();
// Get claimed eras from ledger
const claimedEras: number[] = ledgerJson.claimedRewards || ledgerJson.legacyClaimedRewards || [];
const claimedSet = new Set(claimedEras);
// 3. Get nominated validators
// 3. Get nominated validators (current + historical from exposure)
const nominatorsOpt = await staking.nominators(address);
if (!nominatorsOpt || nominatorsOpt.isNone) return { ...empty, currentEra };
const nominatorsJson = nominatorsOpt.unwrap().toJSON();
const nominatedValidators: string[] = nominatorsJson.targets || [];
if (nominatedValidators.length === 0) return { ...empty, currentEra };
// 4. Check last N eras for unclaimed rewards
const startEra = Math.max(0, currentEra - 1);
const endEra = Math.max(0, currentEra - MAX_ERAS_TO_CHECK);
// 4. Determine era range to check
let historyDepth = DEFAULT_HISTORY_DEPTH;
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[] = [];
// Process eras in parallel batches
const eraPromises: Promise<UnclaimedEraReward[]>[] = [];
// Process eras in parallel batches of 5 to avoid overwhelming RPC
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--) {
if (claimedSet.has(era)) continue;
for (let era = batchStart; era >= batchEnd; era--) {
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
@@ -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(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
staking: any,
@@ -141,8 +165,26 @@ async function checkEraRewards(
const totalStake = BigInt(overview.total || '0');
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 userPage = -1;
const pagesToCheck = Math.min(pageCount, MAX_PAGES_PER_VALIDATOR);
for (let page = 0; page < pagesToCheck; page++) {
@@ -155,13 +197,17 @@ async function checkEraRewards(
for (const nominator of others) {
if (nominator.who === address) {
userStake = BigInt(nominator.value);
userPage = page;
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
const reward = await calculateEraReward(staking, era, validator, userStake, totalStake);
@@ -170,6 +216,7 @@ async function checkEraRewards(
results.push({
era,
validator,
page: userPage,
estimatedReward: formatHez(reward),
estimatedRewardRaw: reward,
});
@@ -184,9 +231,8 @@ async function checkEraRewards(
/**
* 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(
// eslint-disable-next-line @typescript-eslint/no-explicit-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(
assetHubApi: ApiPromise,
keypair: KeyringPair,
validator: string,
era: number
era: number,
page?: number
): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
try {
// 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(
keypair,
@@ -278,66 +332,101 @@ export async function payoutStakingReward(
}
/**
* Submit payoutStakers for all unclaimed rewards using utility.batchAll
* Falls back to sequential calls if utility pallet is not available
* Submit payoutStakers for all unclaimed rewards in batches.
* 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(
assetHubApi: ApiPromise,
keypair: KeyringPair,
unclaimed: UnclaimedEraReward[]
): Promise<{ success: boolean; error?: string }> {
if (unclaimed.length === 0) return { success: true };
): Promise<{ success: boolean; error?: string; completed?: number }> {
if (unclaimed.length === 0) return { success: true, completed: 0 };
// Single reward - no need for batch
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
const utilityTx = (assetHubApi.tx as any).utility;
if (utilityTx?.batchAll) {
const calls = unclaimed.map((r) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(assetHubApi.tx.staking as any).payoutStakers(r.validator, r.era)
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stakingTx = assetHubApi.tx.staking as any;
return new Promise((resolve) => {
try {
utilityTx
.batchAll(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}` });
let totalCompleted = 0;
// Split into batches
for (let i = 0; i < unclaimed.length; i += MAX_BATCH_SIZE) {
const batch = unclaimed.slice(i, i + MAX_BATCH_SIZE);
if (batch.length === 1) {
const r = batch[0];
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 };
}
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 {
resolve({ success: false, error: dispatchError.toString() });
resolve({ success: true });
}
} else {
resolve({ success: true });
}
}
}
)
.catch((err: Error) => {
resolve({ success: false, error: err.message });
});
} catch (err) {
resolve({ success: false, error: err instanceof Error ? err.message : String(err) });
)
.catch((err: Error) => {
resolve({ success: false, error: err.message });
});
} catch (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
for (const reward of unclaimed) {
const result = await payoutStakingReward(assetHubApi, keypair, reward.validator, reward.era);
if (!result.success) return result;
}
return { success: true };
return { success: true, completed: totalCompleted };
}
+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;
setClaimingStakingEra(era);
setTrackingAnimationText(t('rewards.claimingStakingReward'));
setShowTrackingAnimation(true);
hapticImpact('medium');
try {
const result = await payoutStakingReward(assetHubApi, keypair, validator, era);
const result = await payoutStakingReward(assetHubApi, keypair, validator, era, page);
if (result.success) {
hapticNotification('success');
showAlert(t('rewards.stakingClaimSuccess'));
@@ -1145,7 +1145,9 @@ export function RewardsSection() {
</p>
</div>
<button
onClick={() => handleClaimStakingReward(reward.validator, reward.era)}
onClick={() =>
handleClaimStakingReward(reward.validator, reward.era, reward.page)
}
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"
>
@@ -1176,7 +1178,8 @@ export function RewardsSection() {
onClick={() =>
handleClaimStakingReward(
unclaimedRewards.unclaimed[0].validator,
unclaimedRewards.unclaimed[0].era
unclaimedRewards.unclaimed[0].era,
unclaimedRewards.unclaimed[0].page
)
}
disabled={!keypair || claimingStaking || claimingStakingEra !== null}