diff --git a/package.json b/package.json index 29d47bc..5c6713a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/i18n/translations/ar.ts b/src/i18n/translations/ar.ts index d669f55..2e48993 100644 --- a/src/i18n/translations/ar.ts +++ b/src/i18n/translations/ar.ts @@ -161,6 +161,15 @@ const ar: Translations = { claimSuccess: 'تم المطالبة بالمكافأة بنجاح!', claimFailed: 'فشلت المطالبة بالمكافأة', noPezRewards: 'لا توجد مكافآت PEZ قابلة للمطالبة', + unclaimedRewards: 'المكافآت غير المطالب بها', + claimStakingReward: 'مطالبة', + claimAllStaking: 'مطالبة الكل', + claimingStakingReward: 'جارٍ المطالبة بمكافآت الستيكينغ...', + stakingClaimSuccess: 'تم المطالبة بمكافآت الستيكينغ!', + stakingClaimFailed: 'فشل المطالبة بمكافآت الستيكينغ', + noUnclaimedRewards: 'لا توجد مكافآت غير مطالب بها', + rewardHistory: 'سجل المكافآت', + era: 'حقبة', }, wallet: { diff --git a/src/i18n/translations/ckb.ts b/src/i18n/translations/ckb.ts index 050265e..b9d7602 100644 --- a/src/i18n/translations/ckb.ts +++ b/src/i18n/translations/ckb.ts @@ -162,6 +162,15 @@ const ckb: Translations = { claimSuccess: 'خەڵات بە سەرکەوتوویی داوا کرا!', claimFailed: 'داواکردنی خەڵات سەرنەکەوت', noPezRewards: 'خەڵاتی PEZ ی داواکراو نییە', + unclaimedRewards: 'خەڵاتە داوانەکراوەکان', + claimStakingReward: 'داواکردن', + claimAllStaking: 'هەموو داوابکە', + claimingStakingReward: 'خەڵاتەکانی ستەیکینگ داوادەکرێن...', + stakingClaimSuccess: 'خەڵاتەکانی ستەیکینگ داواکران!', + stakingClaimFailed: 'داواکردنی خەڵاتەکانی ستەیکینگ سەرکەوتوو نەبوو', + noUnclaimedRewards: 'خەڵاتی داوانەکراو نییە', + rewardHistory: 'مێژووی خەڵاتەکان', + era: 'سەردەم', }, wallet: { diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index 359000b..d2ab22b 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -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: { diff --git a/src/i18n/translations/fa.ts b/src/i18n/translations/fa.ts index 10891b3..f785d8d 100644 --- a/src/i18n/translations/fa.ts +++ b/src/i18n/translations/fa.ts @@ -161,6 +161,15 @@ const fa: Translations = { claimSuccess: 'پاداش با موفقیت مطالبه شد!', claimFailed: 'مطالبه پاداش ناموفق بود', noPezRewards: 'پاداش PEZ قابل مطالبه‌ای وجود ندارد', + unclaimedRewards: 'پاداش‌های مطالبه نشده', + claimStakingReward: 'مطالبه', + claimAllStaking: 'مطالبه همه', + claimingStakingReward: 'در حال مطالبه پاداش‌های استیکینگ...', + stakingClaimSuccess: 'پاداش‌های استیکینگ مطالبه شد!', + stakingClaimFailed: 'مطالبه پاداش‌های استیکینگ ناموفق بود', + noUnclaimedRewards: 'پاداش مطالبه نشده‌ای وجود ندارد', + rewardHistory: 'تاریخچه پاداش‌ها', + era: 'دوره', }, wallet: { diff --git a/src/i18n/translations/krd.ts b/src/i18n/translations/krd.ts index fb96547..4f19450 100644 --- a/src/i18n/translations/krd.ts +++ b/src/i18n/translations/krd.ts @@ -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: { diff --git a/src/i18n/translations/tr.ts b/src/i18n/translations/tr.ts index 6d8cb12..3283129 100644 --- a/src/i18n/translations/tr.ts +++ b/src/i18n/translations/tr.ts @@ -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: { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index f16f20b..8834649 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -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 diff --git a/src/lib/staking-rewards.ts b/src/lib/staking-rewards.ts new file mode 100644 index 0000000..aec6795 --- /dev/null +++ b/src/lib/staking-rewards.ts @@ -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 { + 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[] = []; + + 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 { + 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 { + 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 }; +} diff --git a/src/sections/Rewards.tsx b/src/sections/Rewards.tsx index 5d09889..fcdb724 100644 --- a/src/sections/Rewards.tsx +++ b/src/sections/Rewards.tsx @@ -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(null); + const [claimingStaking, setClaimingStaking] = useState(false); + const [claimingStakingEra, setClaimingStakingEra] = useState(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() { )} - {/* Staking Rewards from SubQuery */} + {/* HEZ Staking Rewards */}

{t('rewards.stakingRewards')}

- {/* Total Accumulated */} + {/* Unclaimed Rewards */}
-

{t('rewards.totalRewards')}

+

{t('rewards.unclaimedRewards')}

- {stakingRewards && stakingRewards.totalAccumulatedHez > 0 - ? `${stakingRewards.totalAccumulatedHez.toFixed(4)} HEZ` + {unclaimedRewards && unclaimedRewards.totalUnclaimedRaw > 0n + ? `${unclaimedRewards.totalUnclaimedHez} HEZ` : '0 HEZ'}

- {/* Recent Rewards List */} - {stakingRewards && stakingRewards.rewards.length > 0 ? ( -
-

- {t('rewards.recentRewards')} -

- {stakingRewards.rewards.map((reward) => ( + {/* Unclaimed Era List */} + {unclaimedRewards && unclaimedRewards.unclaimed.length > 0 ? ( +
+ {unclaimedRewards.unclaimed.map((reward) => (
-
-
-
-

- {reward.type === 'REWARD' ? '+' : '-'} - {formatRewardAmount(reward.amount)} HEZ -

-

- Block #{reward.blockNumber} -

-
+
+

+ {t('rewards.era')} #{reward.era} +

+

+ {reward.validator.slice(0, 8)}...{reward.validator.slice(-6)} +

+

+ {reward.estimatedReward} HEZ +

- - {formatRewardDate(reward.timestamp)} - +
))} + + {/* Claim All Button */} + {unclaimedRewards.unclaimed.length > 1 && ( + + )} + + {/* Single Claim All when only 1 reward */} + {unclaimedRewards.unclaimed.length === 1 && ( + + )}
) : ( -

- {t('rewards.noRewardsYet')} +

+ {t('rewards.noUnclaimedRewards')}

)} + + {/* Reward History (SubQuery) */} + {stakingRewards && stakingRewards.rewards.length > 0 && ( +
+

+ + {t('rewards.rewardHistory')} +

+
+

{t('rewards.totalRewards')}

+

+ {stakingRewards.totalAccumulatedHez.toFixed(4)} HEZ +

+
+
+ {stakingRewards.rewards.map((reward) => ( +
+
+
+
+

+ {reward.type === 'REWARD' ? '+' : '-'} + {formatRewardAmount(reward.amount)} HEZ +

+

+ Block #{reward.blockNumber} +

+
+
+ + {formatRewardDate(reward.timestamp)} + +
+ ))} +
+
+ )}
{/* Score Formula Info */} diff --git a/src/version.json b/src/version.json index 37925e9..b68f9b7 100644 --- a/src/version.json +++ b/src/version.json @@ -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 }