mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-04-21 23:37:55 +00:00
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:
+162
-73
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user