mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-04-21 23:37:55 +00:00
feat: add HEZ staking reward claim functionality
- Add staking-rewards.ts with unclaimed era detection and payoutStakers - Show unclaimed rewards with per-era claim buttons in Scores tab - Support batch claim via utility.batchAll - Add translations for 6 languages
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pezkuwi-telegram-miniapp",
|
||||
"version": "1.0.217",
|
||||
"version": "1.0.218",
|
||||
"type": "module",
|
||||
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
|
||||
"author": "Pezkuwichain Team",
|
||||
|
||||
@@ -161,6 +161,15 @@ const ar: Translations = {
|
||||
claimSuccess: 'تم المطالبة بالمكافأة بنجاح!',
|
||||
claimFailed: 'فشلت المطالبة بالمكافأة',
|
||||
noPezRewards: 'لا توجد مكافآت PEZ قابلة للمطالبة',
|
||||
unclaimedRewards: 'المكافآت غير المطالب بها',
|
||||
claimStakingReward: 'مطالبة',
|
||||
claimAllStaking: 'مطالبة الكل',
|
||||
claimingStakingReward: 'جارٍ المطالبة بمكافآت الستيكينغ...',
|
||||
stakingClaimSuccess: 'تم المطالبة بمكافآت الستيكينغ!',
|
||||
stakingClaimFailed: 'فشل المطالبة بمكافآت الستيكينغ',
|
||||
noUnclaimedRewards: 'لا توجد مكافآت غير مطالب بها',
|
||||
rewardHistory: 'سجل المكافآت',
|
||||
era: 'حقبة',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
|
||||
@@ -162,6 +162,15 @@ const ckb: Translations = {
|
||||
claimSuccess: 'خەڵات بە سەرکەوتوویی داوا کرا!',
|
||||
claimFailed: 'داواکردنی خەڵات سەرنەکەوت',
|
||||
noPezRewards: 'خەڵاتی PEZ ی داواکراو نییە',
|
||||
unclaimedRewards: 'خەڵاتە داوانەکراوەکان',
|
||||
claimStakingReward: 'داواکردن',
|
||||
claimAllStaking: 'هەموو داوابکە',
|
||||
claimingStakingReward: 'خەڵاتەکانی ستەیکینگ داوادەکرێن...',
|
||||
stakingClaimSuccess: 'خەڵاتەکانی ستەیکینگ داواکران!',
|
||||
stakingClaimFailed: 'داواکردنی خەڵاتەکانی ستەیکینگ سەرکەوتوو نەبوو',
|
||||
noUnclaimedRewards: 'خەڵاتی داوانەکراو نییە',
|
||||
rewardHistory: 'مێژووی خەڵاتەکان',
|
||||
era: 'سەردەم',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
|
||||
@@ -161,6 +161,15 @@ const en: Translations = {
|
||||
claimSuccess: 'Reward claimed successfully!',
|
||||
claimFailed: 'Failed to claim reward',
|
||||
noPezRewards: 'No claimable PEZ rewards',
|
||||
unclaimedRewards: 'Unclaimed Rewards',
|
||||
claimStakingReward: 'Claim',
|
||||
claimAllStaking: 'Claim All',
|
||||
claimingStakingReward: 'Claiming staking rewards...',
|
||||
stakingClaimSuccess: 'Staking rewards claimed!',
|
||||
stakingClaimFailed: 'Failed to claim staking rewards',
|
||||
noUnclaimedRewards: 'No unclaimed rewards',
|
||||
rewardHistory: 'Reward History',
|
||||
era: 'Era',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
|
||||
@@ -161,6 +161,15 @@ const fa: Translations = {
|
||||
claimSuccess: 'پاداش با موفقیت مطالبه شد!',
|
||||
claimFailed: 'مطالبه پاداش ناموفق بود',
|
||||
noPezRewards: 'پاداش PEZ قابل مطالبهای وجود ندارد',
|
||||
unclaimedRewards: 'پاداشهای مطالبه نشده',
|
||||
claimStakingReward: 'مطالبه',
|
||||
claimAllStaking: 'مطالبه همه',
|
||||
claimingStakingReward: 'در حال مطالبه پاداشهای استیکینگ...',
|
||||
stakingClaimSuccess: 'پاداشهای استیکینگ مطالبه شد!',
|
||||
stakingClaimFailed: 'مطالبه پاداشهای استیکینگ ناموفق بود',
|
||||
noUnclaimedRewards: 'پاداش مطالبه نشدهای وجود ندارد',
|
||||
rewardHistory: 'تاریخچه پاداشها',
|
||||
era: 'دوره',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
|
||||
@@ -166,6 +166,15 @@ const krd: Translations = {
|
||||
claimSuccess: 'Xelat bi serkeftin hat daxwazkirin!',
|
||||
claimFailed: 'Daxwazkirina xelatê biserneket',
|
||||
noPezRewards: 'Xelatên PEZ yên daxwazkir tune ne',
|
||||
unclaimedRewards: 'Xelatên Nedaxwazkir',
|
||||
claimStakingReward: 'Daxwaz bike',
|
||||
claimAllStaking: 'Hemûyan Daxwaz Bike',
|
||||
claimingStakingReward: 'Xelatên staking tên daxwazkirin...',
|
||||
stakingClaimSuccess: 'Xelatên staking hatin daxwazkirin!',
|
||||
stakingClaimFailed: 'Daxwazkirina xelatên staking bi ser neket',
|
||||
noUnclaimedRewards: 'Xelatên nedaxwazkir tune ne',
|
||||
rewardHistory: 'Dîroka Xelatan',
|
||||
era: 'Era',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
|
||||
@@ -161,6 +161,15 @@ const tr: Translations = {
|
||||
claimSuccess: 'Ödül başarıyla talep edildi!',
|
||||
claimFailed: 'Ödül talep edilemedi',
|
||||
noPezRewards: 'Talep edilebilir PEZ ödülü yok',
|
||||
unclaimedRewards: 'Talep Edilmemiş Ödüller',
|
||||
claimStakingReward: 'Talep Et',
|
||||
claimAllStaking: 'Tümünü Talep Et',
|
||||
claimingStakingReward: 'Staking ödülleri talep ediliyor...',
|
||||
stakingClaimSuccess: 'Staking ödülleri talep edildi!',
|
||||
stakingClaimFailed: 'Staking ödülleri talep edilemedi',
|
||||
noUnclaimedRewards: 'Talep edilmemiş ödül yok',
|
||||
rewardHistory: 'Ödül Geçmişi',
|
||||
era: 'Era',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
|
||||
@@ -163,6 +163,15 @@ export interface Translations {
|
||||
claimSuccess: string;
|
||||
claimFailed: string;
|
||||
noPezRewards: string;
|
||||
unclaimedRewards: string;
|
||||
claimStakingReward: string;
|
||||
claimAllStaking: string;
|
||||
claimingStakingReward: string;
|
||||
stakingClaimSuccess: string;
|
||||
stakingClaimFailed: string;
|
||||
noUnclaimedRewards: string;
|
||||
rewardHistory: string;
|
||||
era: string;
|
||||
};
|
||||
|
||||
// Wallet section
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Staking Rewards - Unclaimed reward detection and payout for Asset Hub
|
||||
* Queries on-chain data to find unclaimed era rewards and submits payoutStakers calls
|
||||
*/
|
||||
|
||||
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 MAX_PAGES_PER_VALIDATOR = 3;
|
||||
|
||||
// ========================================
|
||||
// TYPES
|
||||
// ========================================
|
||||
|
||||
export interface UnclaimedEraReward {
|
||||
era: number;
|
||||
validator: string;
|
||||
estimatedReward: string; // formatted HEZ
|
||||
estimatedRewardRaw: bigint;
|
||||
}
|
||||
|
||||
export interface UnclaimedRewardsResult {
|
||||
unclaimed: UnclaimedEraReward[];
|
||||
totalUnclaimedHez: string;
|
||||
totalUnclaimedRaw: bigint;
|
||||
currentEra: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// HELPERS
|
||||
// ========================================
|
||||
|
||||
function formatHez(raw: bigint): string {
|
||||
const num = Number(raw) / UNITS;
|
||||
if (num >= 1) return num.toFixed(4);
|
||||
if (num >= 0.001) return num.toFixed(6);
|
||||
if (num > 0) return num.toFixed(10);
|
||||
return '0';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// UNCLAIMED REWARD DETECTION
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Find unclaimed staking rewards for an address on Asset Hub
|
||||
*/
|
||||
export async function getUnclaimedRewards(
|
||||
assetHubApi: ApiPromise,
|
||||
address: string
|
||||
): Promise<UnclaimedRewardsResult> {
|
||||
const empty: UnclaimedRewardsResult = {
|
||||
unclaimed: [],
|
||||
totalUnclaimedHez: '0',
|
||||
totalUnclaimedRaw: 0n,
|
||||
currentEra: 0,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const staking = assetHubApi.query.staking as any;
|
||||
if (!staking) return empty;
|
||||
|
||||
// 1. Get current era
|
||||
const activeEraOpt = await staking.activeEra();
|
||||
if (!activeEraOpt || activeEraOpt.isNone) return empty;
|
||||
const activeEraJson = activeEraOpt.unwrap().toJSON();
|
||||
const currentEra: number = activeEraJson.index ?? activeEraJson;
|
||||
|
||||
// 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
|
||||
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);
|
||||
|
||||
const unclaimed: UnclaimedEraReward[] = [];
|
||||
|
||||
// Process eras in parallel batches
|
||||
const eraPromises: Promise<UnclaimedEraReward[]>[] = [];
|
||||
|
||||
for (let era = startEra; era >= endEra; era--) {
|
||||
if (claimedSet.has(era)) continue;
|
||||
|
||||
eraPromises.push(checkEraRewards(staking, era, address, nominatedValidators));
|
||||
}
|
||||
|
||||
const results = await Promise.all(eraPromises);
|
||||
for (const eraResults of results) {
|
||||
unclaimed.push(...eraResults);
|
||||
}
|
||||
|
||||
// Sort by era descending
|
||||
unclaimed.sort((a, b) => b.era - a.era);
|
||||
|
||||
const totalRaw = unclaimed.reduce((sum, r) => sum + r.estimatedRewardRaw, 0n);
|
||||
|
||||
return {
|
||||
unclaimed,
|
||||
totalUnclaimedHez: formatHez(totalRaw),
|
||||
totalUnclaimedRaw: totalRaw,
|
||||
currentEra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single era for unclaimed rewards across all nominated validators
|
||||
*/
|
||||
|
||||
async function checkEraRewards(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
staking: any,
|
||||
era: number,
|
||||
address: string,
|
||||
validators: string[]
|
||||
): Promise<UnclaimedEraReward[]> {
|
||||
const results: UnclaimedEraReward[] = [];
|
||||
|
||||
for (const validator of validators) {
|
||||
try {
|
||||
// Check if validator was active in this era
|
||||
const overviewOpt = await staking.erasStakersOverview(era, validator);
|
||||
if (!overviewOpt || overviewOpt.isNone) continue;
|
||||
|
||||
const overview = overviewOpt.unwrap().toJSON();
|
||||
const pageCount: number = overview.pageCount || 1;
|
||||
const totalStake = BigInt(overview.total || '0');
|
||||
if (totalStake === 0n) continue;
|
||||
|
||||
// Check if user is in the validator's exposure pages
|
||||
let userStake = 0n;
|
||||
const pagesToCheck = Math.min(pageCount, MAX_PAGES_PER_VALIDATOR);
|
||||
|
||||
for (let page = 0; page < pagesToCheck; page++) {
|
||||
const pagedOpt = await staking.erasStakersPaged(era, validator, page);
|
||||
if (!pagedOpt || pagedOpt.isNone) continue;
|
||||
|
||||
const paged = pagedOpt.unwrap().toJSON();
|
||||
const others: { who: string; value: string | number }[] = paged.others || [];
|
||||
|
||||
for (const nominator of others) {
|
||||
if (nominator.who === address) {
|
||||
userStake = BigInt(nominator.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (userStake > 0n) break;
|
||||
}
|
||||
|
||||
if (userStake === 0n) continue;
|
||||
|
||||
// Calculate estimated reward
|
||||
const reward = await calculateEraReward(staking, era, validator, userStake, totalStake);
|
||||
|
||||
if (reward > 0n) {
|
||||
results.push({
|
||||
era,
|
||||
validator,
|
||||
estimatedReward: formatHez(reward),
|
||||
estimatedRewardRaw: reward,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[StakingRewards] Error checking era ${era} validator ${validator}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate estimated reward for a nominator in a specific era
|
||||
* Formula: eraReward × (valPoints/totalPoints) × (1 - commission/1e9) × (userStake/totalValStake)
|
||||
*/
|
||||
|
||||
async function calculateEraReward(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
staking: any,
|
||||
era: number,
|
||||
validator: string,
|
||||
userStake: bigint,
|
||||
totalValStake: bigint
|
||||
): Promise<bigint> {
|
||||
try {
|
||||
// Get era total reward
|
||||
const eraRewardOpt = await staking.erasValidatorReward(era);
|
||||
if (!eraRewardOpt || eraRewardOpt.isNone) return 0n;
|
||||
const eraReward = BigInt(eraRewardOpt.unwrap().toString());
|
||||
|
||||
// Get era reward points
|
||||
const rewardPoints = await staking.erasRewardPoints(era);
|
||||
const pointsJson = rewardPoints.toJSON();
|
||||
const totalPoints = BigInt(pointsJson.total || '0');
|
||||
if (totalPoints === 0n) return 0n;
|
||||
|
||||
// Find this validator's points
|
||||
const individualPoints = pointsJson.individual || {};
|
||||
const valPoints = BigInt(individualPoints[validator] || '0');
|
||||
if (valPoints === 0n) return 0n;
|
||||
|
||||
// Get validator commission (Perbill - parts per billion)
|
||||
const prefsOpt = await staking.erasValidatorPrefs(era, validator);
|
||||
const prefsJson = prefsOpt.toJSON();
|
||||
const commission = BigInt(prefsJson.commission || '0');
|
||||
const PERBILL = 1_000_000_000n;
|
||||
|
||||
// Calculate:
|
||||
// validatorReward = eraReward * valPoints / totalPoints
|
||||
// nominatorShare = validatorReward * (PERBILL - commission) / PERBILL
|
||||
// userReward = nominatorShare * userStake / totalValStake
|
||||
const validatorReward = (eraReward * valPoints) / totalPoints;
|
||||
const nominatorShare = (validatorReward * (PERBILL - commission)) / PERBILL;
|
||||
const userReward = (nominatorShare * userStake) / totalValStake;
|
||||
|
||||
return userReward;
|
||||
} catch (err) {
|
||||
console.error(`[StakingRewards] Error calculating reward for era ${era}:`, err);
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// PAYOUT FUNCTIONS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Submit payoutStakers for a single era+validator
|
||||
*/
|
||||
export async function payoutStakingReward(
|
||||
assetHubApi: ApiPromise,
|
||||
keypair: KeyringPair,
|
||||
validator: string,
|
||||
era: 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);
|
||||
|
||||
tx.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: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch((err: Error) => {
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit payoutStakers for all unclaimed rewards using utility.batchAll
|
||||
* Falls back to sequential calls if utility pallet is not available
|
||||
*/
|
||||
export async function payoutAllRewards(
|
||||
assetHubApi: ApiPromise,
|
||||
keypair: KeyringPair,
|
||||
unclaimed: UnclaimedEraReward[]
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (unclaimed.length === 0) return { success: true };
|
||||
|
||||
// Single reward - no need for batch
|
||||
if (unclaimed.length === 1) {
|
||||
return payoutStakingReward(assetHubApi, keypair, unclaimed[0].validator, unclaimed[0].era);
|
||||
}
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
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}` });
|
||||
} else {
|
||||
resolve({ success: false, error: dispatchError.toString() });
|
||||
}
|
||||
} 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) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
+174
-38
@@ -52,6 +52,12 @@ import {
|
||||
formatRewardDate,
|
||||
type StakingRewardsResult,
|
||||
} from '@/lib/subquery';
|
||||
import {
|
||||
getUnclaimedRewards,
|
||||
payoutStakingReward,
|
||||
payoutAllRewards,
|
||||
type UnclaimedRewardsResult,
|
||||
} from '@/lib/staking-rewards';
|
||||
import {
|
||||
getCitizenshipStatus,
|
||||
getCitizenCount,
|
||||
@@ -68,7 +74,7 @@ export function RewardsSection() {
|
||||
const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram();
|
||||
const { user: authUser } = useAuth();
|
||||
const { stats, myReferrals, loading, refreshStats } = useReferral();
|
||||
const { isConnected, address, peopleApi, keypair } = useWallet();
|
||||
const { isConnected, address, peopleApi, assetHubApi, keypair } = useWallet();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -86,6 +92,9 @@ export function RewardsSection() {
|
||||
const [showConfirmAnimation, setShowConfirmAnimation] = useState(false);
|
||||
const [showTrackingAnimation, setShowTrackingAnimation] = useState(false);
|
||||
const [trackingAnimationText, setTrackingAnimationText] = useState('');
|
||||
const [unclaimedRewards, setUnclaimedRewards] = useState<UnclaimedRewardsResult | null>(null);
|
||||
const [claimingStaking, setClaimingStaking] = useState(false);
|
||||
const [claimingStakingEra, setClaimingStakingEra] = useState<number | null>(null);
|
||||
|
||||
// Check activity status
|
||||
const checkActivityStatus = useCallback(() => {
|
||||
@@ -128,27 +137,30 @@ export function RewardsSection() {
|
||||
setStakingStatus(null);
|
||||
setStakingRewards(null);
|
||||
setPezRewards(null);
|
||||
setUnclaimedRewards(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setScoresLoading(true);
|
||||
try {
|
||||
const [scores, staking, rewards, pezRewardsData] = await Promise.all([
|
||||
const [scores, staking, rewards, pezRewardsData, unclaimed] = await Promise.all([
|
||||
getAllScores(peopleApi, address),
|
||||
peopleApi ? getStakingScoreStatus(peopleApi, address) : Promise.resolve(null),
|
||||
getStakingRewards(address),
|
||||
peopleApi ? getPezRewards(peopleApi, address) : Promise.resolve(null),
|
||||
assetHubApi ? getUnclaimedRewards(assetHubApi, address) : Promise.resolve(null),
|
||||
]);
|
||||
setUserScores(scores);
|
||||
setStakingStatus(staking);
|
||||
setStakingRewards(rewards);
|
||||
setPezRewards(pezRewardsData);
|
||||
setUnclaimedRewards(unclaimed);
|
||||
} catch (err) {
|
||||
console.error('Error fetching scores:', err);
|
||||
} finally {
|
||||
setScoresLoading(false);
|
||||
}
|
||||
}, [peopleApi, address]);
|
||||
}, [peopleApi, assetHubApi, address]);
|
||||
|
||||
// Fetch scores when tab changes to scores or on initial load
|
||||
useEffect(() => {
|
||||
@@ -288,6 +300,56 @@ export function RewardsSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClaimStakingReward = async (validator: string, era: number) => {
|
||||
if (!assetHubApi || !keypair) return;
|
||||
setClaimingStakingEra(era);
|
||||
setTrackingAnimationText(t('rewards.claimingStakingReward'));
|
||||
setShowTrackingAnimation(true);
|
||||
hapticImpact('medium');
|
||||
try {
|
||||
const result = await payoutStakingReward(assetHubApi, keypair, validator, era);
|
||||
if (result.success) {
|
||||
hapticNotification('success');
|
||||
showAlert(t('rewards.stakingClaimSuccess'));
|
||||
fetchUserScores();
|
||||
} else {
|
||||
hapticNotification('error');
|
||||
showAlert(result.error || t('rewards.stakingClaimFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
hapticNotification('error');
|
||||
showAlert(err instanceof Error ? err.message : t('rewards.stakingClaimFailed'));
|
||||
} finally {
|
||||
setShowTrackingAnimation(false);
|
||||
setClaimingStakingEra(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClaimAllStaking = async () => {
|
||||
if (!assetHubApi || !keypair || !unclaimedRewards?.unclaimed.length) return;
|
||||
setClaimingStaking(true);
|
||||
setTrackingAnimationText(t('rewards.claimingStakingReward'));
|
||||
setShowTrackingAnimation(true);
|
||||
hapticImpact('medium');
|
||||
try {
|
||||
const result = await payoutAllRewards(assetHubApi, keypair, unclaimedRewards.unclaimed);
|
||||
if (result.success) {
|
||||
hapticNotification('success');
|
||||
showAlert(t('rewards.stakingClaimSuccess'));
|
||||
fetchUserScores();
|
||||
} else {
|
||||
hapticNotification('error');
|
||||
showAlert(result.error || t('rewards.stakingClaimFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
hapticNotification('error');
|
||||
showAlert(err instanceof Error ? err.message : t('rewards.stakingClaimFailed'));
|
||||
} finally {
|
||||
setShowTrackingAnimation(false);
|
||||
setClaimingStaking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = () => {
|
||||
hapticNotification('success');
|
||||
localStorage.setItem(ACTIVITY_STORAGE_KEY, Date.now().toString());
|
||||
@@ -1046,62 +1108,136 @@ export function RewardsSection() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Staking Rewards from SubQuery */}
|
||||
{/* HEZ Staking Rewards */}
|
||||
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
|
||||
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<Coins className="w-4 h-4 text-amber-400" />
|
||||
{t('rewards.stakingRewards')}
|
||||
</h3>
|
||||
|
||||
{/* Total Accumulated */}
|
||||
{/* Unclaimed Rewards */}
|
||||
<div className="bg-amber-500/10 rounded-lg p-3 mb-3 border border-amber-500/20">
|
||||
<p className="text-xs text-amber-300 mb-1">{t('rewards.totalRewards')}</p>
|
||||
<p className="text-xs text-amber-300 mb-1">{t('rewards.unclaimedRewards')}</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{stakingRewards && stakingRewards.totalAccumulatedHez > 0
|
||||
? `${stakingRewards.totalAccumulatedHez.toFixed(4)} HEZ`
|
||||
{unclaimedRewards && unclaimedRewards.totalUnclaimedRaw > 0n
|
||||
? `${unclaimedRewards.totalUnclaimedHez} HEZ`
|
||||
: '0 HEZ'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recent Rewards List */}
|
||||
{stakingRewards && stakingRewards.rewards.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t('rewards.recentRewards')}
|
||||
</p>
|
||||
{stakingRewards.rewards.map((reward) => (
|
||||
{/* Unclaimed Era List */}
|
||||
{unclaimedRewards && unclaimedRewards.unclaimed.length > 0 ? (
|
||||
<div className="space-y-2 mb-3">
|
||||
{unclaimedRewards.unclaimed.map((reward) => (
|
||||
<div
|
||||
key={reward.id}
|
||||
className="flex items-center justify-between py-2 border-b border-border/30 last:border-0"
|
||||
key={`${reward.era}-${reward.validator}`}
|
||||
className="flex items-center justify-between py-2 px-3 bg-amber-500/5 rounded-lg border border-amber-500/10"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
reward.type === 'REWARD' ? 'bg-green-400' : 'bg-red-400'
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-foreground">
|
||||
{reward.type === 'REWARD' ? '+' : '-'}
|
||||
{formatRewardAmount(reward.amount)} HEZ
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Block #{reward.blockNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">
|
||||
{t('rewards.era')} #{reward.era}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{reward.validator.slice(0, 8)}...{reward.validator.slice(-6)}
|
||||
</p>
|
||||
<p className="text-xs text-amber-400 font-medium">
|
||||
{reward.estimatedReward} HEZ
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRewardDate(reward.timestamp)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleClaimStakingReward(reward.validator, reward.era)}
|
||||
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"
|
||||
>
|
||||
{claimingStakingEra === reward.era ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
t('rewards.claimStakingReward')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Claim All Button */}
|
||||
{unclaimedRewards.unclaimed.length > 1 && (
|
||||
<button
|
||||
onClick={handleClaimAllStaking}
|
||||
disabled={!keypair || claimingStaking || claimingStakingEra !== null}
|
||||
className="w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 bg-gradient-to-r from-purple-500 to-pink-600 text-white hover:opacity-90 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Coins className="w-5 h-5" />
|
||||
{t('rewards.claimAllStaking')} ({unclaimedRewards.totalUnclaimedHez} HEZ)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Single Claim All when only 1 reward */}
|
||||
{unclaimedRewards.unclaimed.length === 1 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleClaimStakingReward(
|
||||
unclaimedRewards.unclaimed[0].validator,
|
||||
unclaimedRewards.unclaimed[0].era
|
||||
)
|
||||
}
|
||||
disabled={!keypair || claimingStaking || claimingStakingEra !== null}
|
||||
className="w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 bg-gradient-to-r from-purple-500 to-pink-600 text-white hover:opacity-90 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Coins className="w-5 h-5" />
|
||||
{t('rewards.claimStakingReward')} ({unclaimedRewards.totalUnclaimedHez}{' '}
|
||||
HEZ)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-3">
|
||||
{t('rewards.noRewardsYet')}
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
{t('rewards.noUnclaimedRewards')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Reward History (SubQuery) */}
|
||||
{stakingRewards && stakingRewards.rewards.length > 0 && (
|
||||
<div className="border-t border-border/30 pt-3 mt-3">
|
||||
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{t('rewards.rewardHistory')}
|
||||
</p>
|
||||
<div className="bg-amber-500/5 rounded-lg p-2 mb-2 border border-amber-500/10">
|
||||
<p className="text-xs text-amber-300">{t('rewards.totalRewards')}</p>
|
||||
<p className="text-lg font-bold text-amber-400">
|
||||
{stakingRewards.totalAccumulatedHez.toFixed(4)} HEZ
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{stakingRewards.rewards.map((reward) => (
|
||||
<div
|
||||
key={reward.id}
|
||||
className="flex items-center justify-between py-1.5 border-b border-border/20 last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
reward.type === 'REWARD' ? 'bg-green-400' : 'bg-red-400'
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-foreground">
|
||||
{reward.type === 'REWARD' ? '+' : '-'}
|
||||
{formatRewardAmount(reward.amount)} HEZ
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Block #{reward.blockNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRewardDate(reward.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score Formula Info */}
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.0.217",
|
||||
"buildTime": "2026-02-21T14:53:30.534Z",
|
||||
"buildNumber": 1771685610534
|
||||
"version": "1.0.218",
|
||||
"buildTime": "2026-02-21T15:35:05.624Z",
|
||||
"buildNumber": 1771688105624
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user