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() {