diff --git a/src/lib/staking-rewards.ts b/src/lib/staking-rewards.ts
index aec6795..a981fd0 100644
--- a/src/lib/staking-rewards.ts
+++ b/src/lib/staking-rewards.ts
@@ -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[] = [];
+ // 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[] = [];
- 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 };
}
diff --git a/src/sections/Rewards.tsx b/src/sections/Rewards.tsx
index 8588782..6ace15f 100644
--- a/src/sections/Rewards.tsx
+++ b/src/sections/Rewards.tsx
@@ -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() {