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:
2026-02-21 18:35:05 +03:00
parent a14bd57bbe
commit f2b809cb7c
11 changed files with 584 additions and 42 deletions
+1 -1
View File
@@ -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",
+9
View File
@@ -161,6 +161,15 @@ const ar: Translations = {
claimSuccess: 'تم المطالبة بالمكافأة بنجاح!',
claimFailed: 'فشلت المطالبة بالمكافأة',
noPezRewards: 'لا توجد مكافآت PEZ قابلة للمطالبة',
unclaimedRewards: 'المكافآت غير المطالب بها',
claimStakingReward: 'مطالبة',
claimAllStaking: 'مطالبة الكل',
claimingStakingReward: 'جارٍ المطالبة بمكافآت الستيكينغ...',
stakingClaimSuccess: 'تم المطالبة بمكافآت الستيكينغ!',
stakingClaimFailed: 'فشل المطالبة بمكافآت الستيكينغ',
noUnclaimedRewards: 'لا توجد مكافآت غير مطالب بها',
rewardHistory: 'سجل المكافآت',
era: 'حقبة',
},
wallet: {
+9
View File
@@ -162,6 +162,15 @@ const ckb: Translations = {
claimSuccess: 'خەڵات بە سەرکەوتوویی داوا کرا!',
claimFailed: 'داواکردنی خەڵات سەرنەکەوت',
noPezRewards: 'خەڵاتی PEZ ی داواکراو نییە',
unclaimedRewards: 'خەڵاتە داوانەکراوەکان',
claimStakingReward: 'داواکردن',
claimAllStaking: 'هەموو داوابکە',
claimingStakingReward: 'خەڵاتەکانی ستەیکینگ داوادەکرێن...',
stakingClaimSuccess: 'خەڵاتەکانی ستەیکینگ داواکران!',
stakingClaimFailed: 'داواکردنی خەڵاتەکانی ستەیکینگ سەرکەوتوو نەبوو',
noUnclaimedRewards: 'خەڵاتی داوانەکراو نییە',
rewardHistory: 'مێژووی خەڵاتەکان',
era: 'سەردەم',
},
wallet: {
+9
View File
@@ -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: {
+9
View File
@@ -161,6 +161,15 @@ const fa: Translations = {
claimSuccess: 'پاداش با موفقیت مطالبه شد!',
claimFailed: 'مطالبه پاداش ناموفق بود',
noPezRewards: 'پاداش PEZ قابل مطالبه‌ای وجود ندارد',
unclaimedRewards: 'پاداش‌های مطالبه نشده',
claimStakingReward: 'مطالبه',
claimAllStaking: 'مطالبه همه',
claimingStakingReward: 'در حال مطالبه پاداش‌های استیکینگ...',
stakingClaimSuccess: 'پاداش‌های استیکینگ مطالبه شد!',
stakingClaimFailed: 'مطالبه پاداش‌های استیکینگ ناموفق بود',
noUnclaimedRewards: 'پاداش مطالبه نشده‌ای وجود ندارد',
rewardHistory: 'تاریخچه پاداش‌ها',
era: 'دوره',
},
wallet: {
+9
View File
@@ -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: {
+9
View File
@@ -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: {
+9
View File
@@ -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
+343
View File
@@ -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
View File
@@ -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
View File
@@ -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
}