diff --git a/shared/lib/error-handler.ts b/shared/lib/error-handler.ts index 9823c717..4ccd40fa 100644 --- a/shared/lib/error-handler.ts +++ b/shared/lib/error-handler.ts @@ -38,6 +38,28 @@ const ERROR_MESSAGES: Record = { kmr: 'Zêde chunk unbonding hene. Ji kerema xwe li çavkaniyên berê bisekine.', }, + // PEZ Rewards errors + 'pezRewards.ClaimPeriodExpired': { + en: 'The claim period for this epoch has expired. Rewards can no longer be claimed.', + kmr: 'Dema daxwazkirinê ji bo vê epoch-ê qediya. Xelat êdî nayên wergirtin.', + }, + 'pezRewards.RewardAlreadyClaimed': { + en: 'You have already claimed your reward for this epoch.', + kmr: 'We berê xelata xwe ji bo vê epoch-ê wergirtiye.', + }, + 'pezRewards.NoTrustScoreForEpoch': { + en: 'No trust score recorded for this epoch. You must record your score before claiming.', + kmr: 'Ji bo vê epoch-ê skora emîniyê tomar nebûye. Pêşî divê skora xwe tomar bikî.', + }, + 'pezRewards.NoRewardToClaim': { + en: 'No reward available to claim for this epoch.', + kmr: 'Ji bo vê epoch-ê xelateke ku were wergirtin tune ye.', + }, + 'pezRewards.EpochAlreadyClosed': { + en: 'This epoch is already closed. No further actions can be taken.', + kmr: 'Ev epoch berê girtî ye. Tu çalakî êdî nayê kirin.', + }, + // Identity KYC errors 'identityKyc.AlreadyApplied': { en: 'You already have a pending citizenship application. Please wait for approval.', @@ -430,6 +452,16 @@ export const SUCCESS_MESSAGES: Record = { kmr: 'Şopa staking dest pê kir! Xala we dê bi demê re kom bibe.', }, + // PEZ Rewards + 'pezRewards.recorded': { + en: 'Trust score recorded for this epoch. Your score will be used for reward calculation.', + kmr: 'Skora emîniyê ji bo vê epoch-ê tomar bû. Skora we dê ji bo hesabkirina xelatê were bikaranîn.', + }, + 'pezRewards.claimed': { + en: '{{amount}} PEZ reward claimed successfully!', + kmr: '{{amount}} PEZ xelat bi serkeftî hate wergirtin!', + }, + // Citizenship 'citizenship.applied': { en: 'Citizenship application submitted successfully! We will review your application.', diff --git a/shared/lib/scores.ts b/shared/lib/scores.ts index b122ab70..e0ae37b6 100644 --- a/shared/lib/scores.ts +++ b/shared/lib/scores.ts @@ -1,13 +1,14 @@ // ======================================== // Score Systems Integration // ======================================== -// All scores come from People Chain (people-rpc.pezkuwichain.io) -// - Trust Score: pezpallet-trust -// - Referral Score: pezpallet-referral -// - Staking Score: pezpallet-staking-score -// - Tiki Score: pezpallet-tiki +// Score pallets are distributed across chains: +// - Trust Score: pezpallet-trust (People Chain) +// - Referral Score: pezpallet-referral (People Chain) +// - Staking Score: pezpallet-staking-score (Relay Chain - needs staking.ledger access) +// - Tiki Score: pezpallet-tiki (People Chain) import type { ApiPromise } from '@pezkuwi/api'; +import { formatBalance } from './wallet'; // ======================================== // TYPE DEFINITIONS @@ -28,6 +29,26 @@ export interface StakingScoreStatus { durationBlocks: number; } +export type EpochStatus = 'Open' | 'ClaimPeriod' | 'Closed'; + +export interface EpochRewardPool { + totalRewardPool: string; + totalTrustScore: number; + participantsCount: number; + rewardPerTrustPoint: string; + claimDeadline: number; +} + +export interface PezRewardInfo { + currentEpoch: number; + epochStatus: EpochStatus; + hasRecordedThisEpoch: boolean; + userScoreCurrentEpoch: number; + claimableRewards: { epoch: number; amount: string }[]; + totalClaimable: string; + hasPendingClaim: boolean; +} + // ======================================== // TRUST SCORE (pezpallet-trust on People Chain) // ======================================== @@ -115,24 +136,27 @@ export async function getReferralCount( } // ======================================== -// STAKING SCORE (pezpallet-staking-score on People Chain) +// STAKING SCORE (pezpallet-staking-score on Relay Chain) // ======================================== /** * Check staking score tracking status * Storage: stakingScore.stakingStartBlock(address) + * + * IMPORTANT: stakingScore pallet is on the Relay Chain (not People Chain), + * because it needs access to staking.ledger for score calculation. */ export async function getStakingScoreStatus( - peopleApi: ApiPromise, + relayApi: ApiPromise, address: string ): Promise { try { - if (!peopleApi?.query?.stakingScore?.stakingStartBlock) { + if (!relayApi?.query?.stakingScore?.stakingStartBlock) { return { isTracking: false, startBlock: null, currentBlock: 0, durationBlocks: 0 }; } - const startBlockResult = await peopleApi.query.stakingScore.stakingStartBlock(address); - const currentBlock = Number((await peopleApi.query.system.number()).toString()); + const startBlockResult = await relayApi.query.stakingScore.stakingStartBlock(address); + const currentBlock = Number((await relayApi.query.system.number()).toString()); if (startBlockResult.isEmpty || startBlockResult.isNone) { return { isTracking: false, startBlock: null, currentBlock, durationBlocks: 0 }; @@ -156,25 +180,29 @@ export async function getStakingScoreStatus( /** * Start staking score tracking * Calls: stakingScore.startScoreTracking() + * + * IMPORTANT: This must be called on the Relay Chain API (not People Chain), + * because the stakingScore pallet needs access to staking.ledger to verify + * the user has an active stake. The staking pallet only exists on Relay Chain. */ export async function startScoreTracking( - peopleApi: ApiPromise, + relayApi: ApiPromise, address: string, signer: any ): Promise<{ success: boolean; error?: string }> { try { - if (!peopleApi?.tx?.stakingScore?.startScoreTracking) { - return { success: false, error: 'stakingScore pallet not available' }; + if (!relayApi?.tx?.stakingScore?.startScoreTracking) { + return { success: false, error: 'stakingScore pallet not available on this chain' }; } - const tx = peopleApi.tx.stakingScore.startScoreTracking(); + const tx = relayApi.tx.stakingScore.startScoreTracking(); return new Promise((resolve) => { tx.signAndSend(address, { signer }, ({ status, dispatchError }) => { if (status.isInBlock || status.isFinalized) { if (dispatchError) { if (dispatchError.isModule) { - const decoded = peopleApi.registry.findMetaError(dispatchError.asModule); + const decoded = relayApi.registry.findMetaError(dispatchError.asModule); resolve({ success: false, error: `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}` }); } else { resolve({ success: false, error: dispatchError.toString() }); @@ -360,3 +388,196 @@ export async function getPerwerdeScore( return 0; } } + +// ======================================== +// PEZ REWARDS (pezRewards pallet on People Chain) +// ======================================== + +/** + * Get PEZ rewards information for an account + * Uses correct storage query names from pezRewards pallet: + * - getCurrentEpochInfo() → epoch info + * - epochStatus(epoch) → Open | ClaimPeriod | Closed + * - getUserTrustScoreForEpoch(epoch, addr) → user's recorded score + * - getClaimedReward(epoch, addr) → claimed reward amount + * - getEpochRewardPool(epoch) → reward pool info + */ +export async function getPezRewards( + peopleApi: ApiPromise, + address: string +): Promise { + try { + if (!peopleApi?.query?.pezRewards?.getCurrentEpochInfo) { + console.warn('PezRewards pallet not available on People Chain'); + return null; + } + + // Get current epoch info + const epochInfoResult = await peopleApi.query.pezRewards.getCurrentEpochInfo(); + if (!epochInfoResult) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const epochInfo = epochInfoResult.toJSON() as any; + const currentEpoch: number = epochInfo.currentEpoch ?? epochInfo.current_epoch ?? 0; + + // Get current epoch status + let epochStatus: EpochStatus = 'Open'; + try { + const statusResult = await peopleApi.query.pezRewards.epochStatus(currentEpoch); + const statusStr = statusResult.toString(); + if (statusStr === 'ClaimPeriod') epochStatus = 'ClaimPeriod'; + else if (statusStr === 'Closed') epochStatus = 'Closed'; + else epochStatus = 'Open'; + } catch { + // Default to Open if query fails + } + + // Check if user has recorded their score this epoch + let hasRecordedThisEpoch = false; + let userScoreCurrentEpoch = 0; + try { + const userScoreResult = await peopleApi.query.pezRewards.getUserTrustScoreForEpoch(currentEpoch, address); + if (userScoreResult.isSome) { + hasRecordedThisEpoch = true; + const scoreCodec = userScoreResult.unwrap() as { toString: () => string }; + userScoreCurrentEpoch = Number(scoreCodec.toString()); + } + } catch { + // User hasn't recorded + } + + // Check for claimable rewards from completed epochs + const claimableRewards: { epoch: number; amount: string }[] = []; + let totalClaimable = BigInt(0); + + for (let i = Math.max(0, currentEpoch - 3); i < currentEpoch; i++) { + try { + // Check epoch status - only ClaimPeriod epochs are claimable + const pastStatusResult = await peopleApi.query.pezRewards.epochStatus(i); + const pastStatus = pastStatusResult.toString(); + if (pastStatus !== 'ClaimPeriod') continue; + + // Check if user already claimed + const claimedResult = await peopleApi.query.pezRewards.getClaimedReward(i, address); + if (claimedResult.isSome) continue; + + // Check if user has a score for this epoch + const userScoreResult = await peopleApi.query.pezRewards.getUserTrustScoreForEpoch(i, address); + if (!userScoreResult.isSome) continue; + + // Get epoch reward pool + const epochPoolResult = await peopleApi.query.pezRewards.getEpochRewardPool(i); + if (!epochPoolResult.isSome) continue; + + const epochPoolCodec = epochPoolResult.unwrap() as { toJSON: () => unknown }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const epochPool = epochPoolCodec.toJSON() as any; + const userScoreCodec = userScoreResult.unwrap() as { toString: () => string }; + const userScore = BigInt(userScoreCodec.toString()); + const rewardPerPoint = BigInt(epochPool.rewardPerTrustPoint || epochPool.reward_per_trust_point || '0'); + + const rewardAmount = userScore * rewardPerPoint; + const rewardFormatted = formatBalance(rewardAmount.toString()); + + if (parseFloat(rewardFormatted) > 0) { + claimableRewards.push({ epoch: i, amount: rewardFormatted }); + totalClaimable += rewardAmount; + } + } catch (err) { + console.warn(`Error checking epoch ${i} rewards:`, err); + } + } + + return { + currentEpoch, + epochStatus, + hasRecordedThisEpoch, + userScoreCurrentEpoch, + claimableRewards, + totalClaimable: formatBalance(totalClaimable.toString()), + hasPendingClaim: claimableRewards.length > 0, + }; + } catch (error) { + console.warn('PEZ rewards not available:', error); + return null; + } +} + +/** + * Record trust score for the current epoch + * Calls: pezRewards.recordTrustScore() + */ +export async function recordTrustScore( + peopleApi: ApiPromise, + address: string, + signer: any +): Promise<{ success: boolean; error?: string }> { + try { + if (!peopleApi?.tx?.pezRewards?.recordTrustScore) { + return { success: false, error: 'pezRewards pallet not available' }; + } + + const tx = peopleApi.tx.pezRewards.recordTrustScore(); + + return new Promise((resolve) => { + tx.signAndSend(address, { signer }, ({ status, dispatchError }: any) => { + if (status.isInBlock || status.isFinalized) { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = peopleApi.registry.findMetaError(dispatchError.asModule); + resolve({ success: false, error: `${decoded.section}.${decoded.name}` }); + } else { + resolve({ success: false, error: dispatchError.toString() }); + } + } else { + resolve({ success: true }); + } + } + }); + }); + } catch (error) { + console.error('Error recording trust score:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + +/** + * Claim PEZ reward for a specific epoch + * Calls: pezRewards.claimReward(epochIndex) + */ +export async function claimPezReward( + peopleApi: ApiPromise, + address: string, + epochIndex: number, + signer: any +): Promise<{ success: boolean; error?: string }> { + try { + if (!peopleApi?.tx?.pezRewards?.claimReward) { + return { success: false, error: 'pezRewards pallet not available' }; + } + + const tx = peopleApi.tx.pezRewards.claimReward(epochIndex); + + return new Promise((resolve) => { + tx.signAndSend(address, { signer }, ({ status, dispatchError }: any) => { + if (status.isInBlock || status.isFinalized) { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = peopleApi.registry.findMetaError(dispatchError.asModule); + resolve({ success: false, error: `${decoded.section}.${decoded.name}` }); + } else { + resolve({ success: false, error: dispatchError.toString() }); + } + } else { + resolve({ success: true }); + } + } + }); + }); + } catch (error) { + console.error('Error claiming PEZ reward:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} diff --git a/shared/lib/staking.ts b/shared/lib/staking.ts index 8808e41b..99781c63 100644 --- a/shared/lib/staking.ts +++ b/shared/lib/staking.ts @@ -5,6 +5,7 @@ import { ApiPromise } from '@pezkuwi/api'; import { formatBalance } from './wallet'; +import { getPezRewards, type PezRewardInfo } from './scores'; export interface StakingLedger { stash: string; @@ -30,14 +31,6 @@ export interface EraRewardPoints { individual: Record; } -export interface PezRewardInfo { - currentEpoch: number; - epochStartBlock: number; - claimableRewards: { epoch: number; amount: string }[]; // Unclaimed rewards from completed epochs - totalClaimable: string; - hasPendingClaim: boolean; -} - export interface StakingInfo { bonded: string; active: string; @@ -187,90 +180,6 @@ export async function getBlocksUntilEra( } } -/** - * Get PEZ rewards information for an account - * Note: pezRewards pallet is on People Chain, not Relay Chain - */ -export async function getPezRewards( - peopleApi: ApiPromise, - address: string -): Promise { - try { - // Check if pezRewards pallet exists on People Chain - if (!peopleApi?.query?.pezRewards || !peopleApi.query.pezRewards.epochInfo) { - console.warn('PezRewards pallet not available on People Chain'); - return null; - } - - // Get current epoch info - const epochInfoResult = await peopleApi.query.pezRewards.epochInfo(); - - if (!epochInfoResult) { - console.warn('No epoch info found'); - return null; - } - - const epochInfo = epochInfoResult.toJSON() as any; - const currentEpoch = epochInfo.currentEpoch || 0; - const epochStartBlock = epochInfo.epochStartBlock || 0; - - // Check for claimable rewards from completed epochs - const claimableRewards: { epoch: number; amount: string }[] = []; - let totalClaimable = BigInt(0); - - // Check last 3 completed epochs for unclaimed rewards - for (let i = Math.max(0, currentEpoch - 3); i < currentEpoch; i++) { - try { - // Check if user has claimed this epoch already - const claimedResult = await peopleApi.query.pezRewards.claimedRewards(i, address); - - if (claimedResult.isNone) { - // User hasn't claimed - check if they have rewards - const userScoreResult = await peopleApi.query.pezRewards.userEpochScores(i, address); - - if (userScoreResult.isSome) { - // User has a score for this epoch - calculate their reward - const epochPoolResult = await peopleApi.query.pezRewards.epochRewardPools(i); - - if (epochPoolResult.isSome) { - const epochPoolCodec = epochPoolResult.unwrap() as { toJSON: () => unknown }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const epochPool = epochPoolCodec.toJSON() as any; - const userScoreCodec = userScoreResult.unwrap() as { toString: () => string }; - const userScore = BigInt(userScoreCodec.toString()); - const rewardPerPoint = BigInt(epochPool.rewardPerTrustPoint || '0'); - - const rewardAmount = userScore * rewardPerPoint; - const rewardFormatted = formatBalance(rewardAmount.toString()); - - if (parseFloat(rewardFormatted) > 0) { - claimableRewards.push({ - epoch: i, - amount: rewardFormatted - }); - totalClaimable += rewardAmount; - } - } - } - } - } catch (err) { - console.warn(`Error checking epoch ${i} rewards:`, err); - } - } - - return { - currentEpoch, - epochStartBlock, - claimableRewards, - totalClaimable: formatBalance(totalClaimable.toString()), - hasPendingClaim: claimableRewards.length > 0 - }; - } catch (error) { - console.warn('PEZ rewards not available:', error); - return null; - } -} - /** * Get comprehensive staking info for an account * @param api - Relay Chain API (for staking pallet) @@ -329,17 +238,16 @@ export async function getStakingInfo( let hasStartedScoreTracking = false; try { - // stakingScore pallet is on People Chain - const scoreApi = peopleApi || api; - if (scoreApi.query.stakingScore && scoreApi.query.stakingScore.stakingStartBlock) { + // stakingScore pallet is on Relay Chain (same as staking pallet, needs staking.ledger access) + if (api.query.stakingScore && api.query.stakingScore.stakingStartBlock) { // Check if user has started score tracking - const scoreResult = await scoreApi.query.stakingScore.stakingStartBlock(address); + const scoreResult = await api.query.stakingScore.stakingStartBlock(address); if (scoreResult.isSome) { hasStartedScoreTracking = true; const startBlockCodec = scoreResult.unwrap() as { toString: () => string }; const startBlock = Number(startBlockCodec.toString()); - const currentBlock = Number((await scoreApi.query.system.number()).toString()); + const currentBlock = Number((await api.query.system.number()).toString()); const durationInBlocks = currentBlock - startBlock; stakingDuration = durationInBlocks; diff --git a/web/src/components/TransactionHistory.tsx b/web/src/components/TransactionHistory.tsx index 7c36ecce..8b5b8a6e 100644 --- a/web/src/components/TransactionHistory.tsx +++ b/web/src/components/TransactionHistory.tsx @@ -209,7 +209,7 @@ export const TransactionHistory: React.FC = ({ isOpen, } // Parse pezRewards operations - else if (method.section === 'pezRewards' && method.method === 'claimReward') { + else if (method.section === 'pezRewards' && (method.method === 'claimReward' || method.method === 'recordTrustScore')) { txList.push({ blockNumber, extrinsicIndex: index, diff --git a/web/src/components/staking/StakingDashboard.tsx b/web/src/components/staking/StakingDashboard.tsx index 87e6a4f1..bc0e7066 100644 --- a/web/src/components/staking/StakingDashboard.tsx +++ b/web/src/components/staking/StakingDashboard.tsx @@ -20,6 +20,12 @@ import { parseAmount, type StakingInfo } from '@pezkuwi/lib/staking'; +import { + recordTrustScore, + claimPezReward, + getPezRewards, + type PezRewardInfo +} from '@pezkuwi/lib/scores'; import { LoadingState } from '@pezkuwi/components/AsyncComponent'; import { ValidatorPoolDashboard } from './ValidatorPoolDashboard'; import { handleBlockchainError, handleBlockchainSuccess } from '@pezkuwi/lib/error-handler'; @@ -39,6 +45,9 @@ export const StakingDashboard: React.FC = () => { const [isLoading, setIsLoading] = useState(false); const [isLoadingData, setIsLoadingData] = useState(false); + const [pezRewards, setPezRewards] = useState(null); + const [isRecordingScore, setIsRecordingScore] = useState(false); + const [isClaimingReward, setIsClaimingReward] = useState(false); // Fetch staking data useEffect(() => { @@ -81,6 +90,82 @@ export const StakingDashboard: React.FC = () => { return () => clearInterval(interval); }, [api, peopleApi, isApiReady, isPeopleReady, selectedAccount]); + // Fetch PEZ rewards data separately from People Chain + useEffect(() => { + const fetchPezRewards = async () => { + if (!peopleApi || !isPeopleReady || !selectedAccount) return; + + try { + const rewards = await getPezRewards(peopleApi, selectedAccount.address); + setPezRewards(rewards); + } catch (error) { + if (import.meta.env.DEV) console.warn('Failed to fetch PEZ rewards:', error); + } + }; + + fetchPezRewards(); + const interval = setInterval(fetchPezRewards, 30000); + return () => clearInterval(interval); + }, [peopleApi, isPeopleReady, selectedAccount]); + + const handleRecordTrustScore = async () => { + if (!peopleApi || !selectedAccount) return; + + setIsRecordingScore(true); + try { + const injector = await web3FromAddress(selectedAccount.address); + const result = await recordTrustScore(peopleApi, selectedAccount.address, injector.signer); + + if (result.success) { + handleBlockchainSuccess('pezRewards.recorded', toast); + // Refresh PEZ rewards data + setTimeout(async () => { + if (peopleApi && selectedAccount) { + const rewards = await getPezRewards(peopleApi, selectedAccount.address); + setPezRewards(rewards); + } + }, 3000); + } else { + toast.error(result.error || 'Failed to record trust score'); + } + } catch (error) { + if (import.meta.env.DEV) console.error('Record trust score failed:', error); + toast.error(error instanceof Error ? error.message : 'Failed to record trust score'); + } finally { + setIsRecordingScore(false); + } + }; + + const handleClaimReward = async (epochIndex: number) => { + if (!peopleApi || !selectedAccount) return; + + setIsClaimingReward(true); + try { + const injector = await web3FromAddress(selectedAccount.address); + const result = await claimPezReward(peopleApi, selectedAccount.address, epochIndex, injector.signer); + + if (result.success) { + const rewardInfo = pezRewards?.claimableRewards.find(r => r.epoch === epochIndex); + handleBlockchainSuccess('pezRewards.claimed', toast, { amount: rewardInfo?.amount || '0' }); + refreshBalances(); + // Refresh PEZ rewards data + setTimeout(async () => { + if (peopleApi && selectedAccount) { + const rewards = await getPezRewards(peopleApi, selectedAccount.address); + setPezRewards(rewards); + } + }, 3000); + } else { + toast.error(result.error || 'Failed to claim reward'); + } + } catch (error) { + if (import.meta.env.DEV) console.error('Claim reward failed:', error); + toast.error(error instanceof Error ? error.message : 'Failed to claim reward'); + } finally { + setIsClaimingReward(false); + } + }; + const handleBond = async () => { if (!api || !selectedAccount || !bondAmount) return; @@ -425,36 +510,80 @@ export const StakingDashboard: React.FC = () => { - PEZ Rewards +
+ PEZ Rewards + {pezRewards && ( + + {pezRewards.epochStatus === 'Open' ? 'Open' : pezRewards.epochStatus === 'ClaimPeriod' ? 'Claim Period' : 'Closed'} + + )} +
- {stakingInfo?.pezRewards && stakingInfo.pezRewards.hasPendingClaim ? ( - <> -
- {parseFloat(stakingInfo.pezRewards.totalClaimable).toFixed(2)} PEZ -
-

- {stakingInfo.pezRewards.claimableRewards.length} epoch(s) to claim -

- - + {pezRewards ? ( +
+

Epoch {pezRewards.currentEpoch}

+ + {/* Open epoch: Record score or show recorded score */} + {pezRewards.epochStatus === 'Open' && ( + pezRewards.hasRecordedThisEpoch ? ( +
+
+ Score: {pezRewards.userScoreCurrentEpoch} +
+

Recorded for this epoch

+
+ ) : ( + + ) + )} + + {/* Claimable rewards */} + {pezRewards.hasPendingClaim ? ( + <> +
+ {parseFloat(pezRewards.totalClaimable).toFixed(2)} PEZ +
+
+ {pezRewards.claimableRewards.map((reward) => ( +
+ Epoch {reward.epoch}: {reward.amount} PEZ + +
+ ))} +
+ + ) : ( + !pezRewards.hasRecordedThisEpoch && pezRewards.epochStatus !== 'Open' && ( +
0 PEZ
+ ) + )} +
) : ( <>
0 PEZ
-

- {stakingInfo?.pezRewards - ? `Epoch ${stakingInfo.pezRewards.currentEpoch}` - : 'No rewards available'} -

+

No rewards available

)}
diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index a8dd7f3f..4fd03ad2 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -7,10 +7,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useAuth } from '@/contexts/AuthContext'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { supabase } from '@/lib/supabase'; -import { User, Mail, Phone, Globe, MapPin, Calendar, Shield, AlertCircle, ArrowLeft, Award, Users, TrendingUp, UserMinus, Play, Loader2 } from 'lucide-react'; +import { User, Mail, Phone, Globe, MapPin, Calendar, Shield, AlertCircle, ArrowLeft, Award, Users, TrendingUp, UserMinus, Play, Loader2, Coins } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { fetchUserTikis, getPrimaryRole, getTikiDisplayName, getTikiColor, getTikiEmoji, getUserRoleCategories, getAllTikiNFTDetails, generateCitizenNumber, type TikiNFTDetails } from '@pezkuwi/lib/tiki'; -import { getAllScores, getStakingScoreStatus, startScoreTracking, type UserScores, type StakingScoreStatus, formatDuration } from '@pezkuwi/lib/scores'; +import { getAllScores, getStakingScoreStatus, startScoreTracking, getPezRewards, recordTrustScore, claimPezReward, type UserScores, type StakingScoreStatus, type PezRewardInfo, formatDuration } from '@pezkuwi/lib/scores'; import { web3FromAddress } from '@pezkuwi/extension-dapp'; import { getKycStatus } from '@pezkuwi/lib/kyc'; import { ReferralDashboard } from '@/components/referral/ReferralDashboard'; @@ -37,6 +37,9 @@ export default function Dashboard() { const [startingScoreTracking, setStartingScoreTracking] = useState(false); const [kycStatus, setKycStatus] = useState('NotStarted'); const [renouncingCitizenship, setRenouncingCitizenship] = useState(false); + const [pezRewards, setPezRewards] = useState(null); + const [isRecordingScore, setIsRecordingScore] = useState(false); + const [isClaimingReward, setIsClaimingReward] = useState(false); const [nftDetails, setNftDetails] = useState<{ citizenNFT: TikiNFTDetails | null; roleNFTs: TikiNFTDetails[]; totalNFTs: number }>({ citizenNFT: null, roleNFTs: [], @@ -114,8 +117,8 @@ export default function Dashboard() { const allScores = await getAllScores(peopleApi, selectedAccount.address); setScores(allScores); - // Fetch staking score tracking status - const stakingStatusResult = await getStakingScoreStatus(peopleApi, selectedAccount.address); + // Fetch staking score tracking status (from Relay Chain where stakingScore pallet lives) + const stakingStatusResult = await getStakingScoreStatus(api, selectedAccount.address); setStakingStatus(stakingStatusResult); // Fetch tikis from People Chain (tiki pallet is on People Chain) @@ -129,6 +132,10 @@ export default function Dashboard() { // Fetch KYC status from People Chain (identityKyc pallet is on People Chain) const kycStatusResult = await getKycStatus(peopleApi, selectedAccount.address); setKycStatus(kycStatusResult); + + // Fetch PEZ rewards from People Chain + const rewards = await getPezRewards(peopleApi, selectedAccount.address); + setPezRewards(rewards); } catch (error) { if (import.meta.env.DEV) console.error('Error fetching scores and tikis:', error); } finally { @@ -137,7 +144,7 @@ export default function Dashboard() { }, [selectedAccount, api, peopleApi]); const handleStartScoreTracking = async () => { - if (!peopleApi || !selectedAccount) { + if (!api || !selectedAccount) { toast({ title: "Hata", description: "Lütfen önce cüzdanınızı bağlayın", @@ -149,7 +156,9 @@ export default function Dashboard() { setStartingScoreTracking(true); try { const injector = await web3FromAddress(selectedAccount.address); - const result = await startScoreTracking(peopleApi, selectedAccount.address, injector.signer); + // startScoreTracking must use Relay Chain API (api), not People Chain (peopleApi), + // because the stakingScore pallet needs access to staking.ledger on Relay Chain + const result = await startScoreTracking(api, selectedAccount.address, injector.signer); if (result.success) { toast({ @@ -177,6 +186,49 @@ export default function Dashboard() { } }; + const handleRecordTrustScore = async () => { + if (!peopleApi || !selectedAccount) return; + + setIsRecordingScore(true); + try { + const injector = await web3FromAddress(selectedAccount.address); + const result = await recordTrustScore(peopleApi, selectedAccount.address, injector.signer); + + if (result.success) { + toast({ title: "Success", description: "Trust score recorded for this epoch." }); + fetchScoresAndTikis(); + } else { + toast({ title: "Error", description: result.error || "Failed to record trust score", variant: "destructive" }); + } + } catch (error) { + toast({ title: "Error", description: error instanceof Error ? error.message : "Failed to record trust score", variant: "destructive" }); + } finally { + setIsRecordingScore(false); + } + }; + + const handleClaimReward = async (epochIndex: number) => { + if (!peopleApi || !selectedAccount) return; + + setIsClaimingReward(true); + try { + const injector = await web3FromAddress(selectedAccount.address); + const result = await claimPezReward(peopleApi, selectedAccount.address, epochIndex, injector.signer); + + if (result.success) { + const rewardInfo = pezRewards?.claimableRewards.find(r => r.epoch === epochIndex); + toast({ title: "Success", description: `${rewardInfo?.amount || '0'} PEZ reward claimed!` }); + fetchScoresAndTikis(); + } else { + toast({ title: "Error", description: result.error || "Failed to claim reward", variant: "destructive" }); + } + } catch (error) { + toast({ title: "Error", description: error instanceof Error ? error.message : "Failed to claim reward", variant: "destructive" }); + } finally { + setIsClaimingReward(false); + } + }; + useEffect(() => { fetchProfile(); if (selectedAccount && api && isApiReady && peopleApi && isPeopleReady) { @@ -529,6 +581,99 @@ export default function Dashboard() {
+ {/* PEZ Rewards Card */} + {selectedAccount && ( + + + PEZ Rewards +
+ {pezRewards && ( + + {pezRewards.epochStatus === 'Open' ? 'Open' : pezRewards.epochStatus === 'ClaimPeriod' ? 'Claim Period' : 'Closed'} + + )} + +
+
+ + {loadingScores ? ( +
...
+ ) : pezRewards ? ( +
+

Epoch {pezRewards.currentEpoch}

+ + {/* Open epoch: Record score or show recorded score */} + {pezRewards.epochStatus === 'Open' && ( + pezRewards.hasRecordedThisEpoch ? ( +
+
Score: {pezRewards.userScoreCurrentEpoch}
+ Recorded +
+ ) : ( + + ) + )} + + {/* Claimable rewards */} + {pezRewards.hasPendingClaim ? ( +
+
+ {parseFloat(pezRewards.totalClaimable).toFixed(2)} PEZ +
+ {pezRewards.claimableRewards.map((reward) => ( +
+ Epoch {reward.epoch}: {reward.amount} PEZ + +
+ ))} +
+ ) : ( + !pezRewards.hasRecordedThisEpoch && pezRewards.epochStatus !== 'Open' && ( +
0 PEZ
+ ) + )} +
+ ) : ( +
+
0 PEZ
+

No rewards available

+
+ )} +
+
+ )} + Profile diff --git a/web/src/pages/WalletDashboard.tsx b/web/src/pages/WalletDashboard.tsx index 7426a568..676d7c53 100644 --- a/web/src/pages/WalletDashboard.tsx +++ b/web/src/pages/WalletDashboard.tsx @@ -7,7 +7,10 @@ import { ReceiveModal } from '@/components/ReceiveModal'; import { TransactionHistory } from '@/components/TransactionHistory'; import { NftList } from '@/components/NftList'; import { Button } from '@/components/ui/button'; -import { ArrowUpRight, ArrowDownRight, History, ArrowLeft, RefreshCw } from 'lucide-react'; +import { ArrowUpRight, ArrowDownRight, History, ArrowLeft, RefreshCw, Coins, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { web3FromAddress } from '@pezkuwi/extension-dapp'; +import { getPezRewards, recordTrustScore, claimPezReward, type PezRewardInfo } from '@pezkuwi/lib/scores'; interface Transaction { blockNumber: number; @@ -24,12 +27,15 @@ interface Transaction { const WalletDashboard: React.FC = () => { const navigate = useNavigate(); - const { api, isApiReady, selectedAccount } = usePezkuwi(); + const { api, isApiReady, peopleApi, isPeopleReady, selectedAccount } = usePezkuwi(); const [isTransferModalOpen, setIsTransferModalOpen] = useState(false); const [isReceiveModalOpen, setIsReceiveModalOpen] = useState(false); const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false); const [recentTransactions, setRecentTransactions] = useState([]); const [isLoadingRecent, setIsLoadingRecent] = useState(false); + const [pezRewards, setPezRewards] = useState(null); + const [isRecordingScore, setIsRecordingScore] = useState(false); + const [isClaimingReward, setIsClaimingReward] = useState(false); // Fetch recent transactions (limited to last 10 blocks for performance) const fetchRecentTransactions = async () => { @@ -177,7 +183,7 @@ const WalletDashboard: React.FC = () => { // Parse stakingScore & pezRewards else if ((method.section === 'stakingScore' && method.method === 'startTracking') || - (method.section === 'pezRewards' && method.method === 'claimReward')) { + (method.section === 'pezRewards' && (method.method === 'claimReward' || method.method === 'recordTrustScore'))) { txList.push({ blockNumber, extrinsicIndex: index, @@ -210,6 +216,64 @@ const WalletDashboard: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedAccount, api, isApiReady]); + // Fetch PEZ rewards from People Chain + useEffect(() => { + const fetchPezRewards = async () => { + if (!peopleApi || !isPeopleReady || !selectedAccount) return; + try { + const rewards = await getPezRewards(peopleApi, selectedAccount.address); + setPezRewards(rewards); + } catch (error) { + if (import.meta.env.DEV) console.warn('Failed to fetch PEZ rewards:', error); + } + }; + + fetchPezRewards(); + const interval = setInterval(fetchPezRewards, 30000); + return () => clearInterval(interval); + }, [peopleApi, isPeopleReady, selectedAccount]); + + const handleRecordTrustScore = async () => { + if (!peopleApi || !selectedAccount) return; + setIsRecordingScore(true); + try { + const injector = await web3FromAddress(selectedAccount.address); + const result = await recordTrustScore(peopleApi, selectedAccount.address, injector.signer); + if (result.success) { + toast.success('Trust score recorded for this epoch'); + const rewards = await getPezRewards(peopleApi, selectedAccount.address); + setPezRewards(rewards); + } else { + toast.error(result.error || 'Failed to record trust score'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to record trust score'); + } finally { + setIsRecordingScore(false); + } + }; + + const handleClaimReward = async (epochIndex: number) => { + if (!peopleApi || !selectedAccount) return; + setIsClaimingReward(true); + try { + const injector = await web3FromAddress(selectedAccount.address); + const result = await claimPezReward(peopleApi, selectedAccount.address, epochIndex, injector.signer); + if (result.success) { + const rewardInfo = pezRewards?.claimableRewards.find(r => r.epoch === epochIndex); + toast.success(`${rewardInfo?.amount || '0'} PEZ reward claimed!`); + const rewards = await getPezRewards(peopleApi, selectedAccount.address); + setPezRewards(rewards); + } else { + toast.error(result.error || 'Failed to claim reward'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to claim reward'); + } finally { + setIsClaimingReward(false); + } + }; + const formatAmount = (amount: string, decimals: number = 12) => { const value = parseInt(amount) / Math.pow(10, decimals); return value.toFixed(4); @@ -355,6 +419,78 @@ const WalletDashboard: React.FC = () => { + {/* PEZ Rewards Card */} + {pezRewards && ( +
+
+
+ +

PEZ Rewards

+
+ + Epoch {pezRewards.currentEpoch} - {pezRewards.epochStatus === 'Open' ? 'Open' : pezRewards.epochStatus === 'ClaimPeriod' ? 'Claim Period' : 'Closed'} + +
+ + {/* Open epoch: Record score */} + {pezRewards.epochStatus === 'Open' && ( + pezRewards.hasRecordedThisEpoch ? ( +
+ Score: {pezRewards.userScoreCurrentEpoch} + Recorded for this epoch +
+ ) : ( + + ) + )} + + {/* Claimable rewards */} + {pezRewards.hasPendingClaim ? ( +
+
+ {parseFloat(pezRewards.totalClaimable).toFixed(2)} PEZ +
+

{pezRewards.claimableRewards.length} epoch(s) to claim

+ {pezRewards.claimableRewards.map((reward) => ( +
+ Epoch {reward.epoch}: {reward.amount} PEZ + +
+ ))} +
+ ) : ( + !pezRewards.hasRecordedThisEpoch && pezRewards.epochStatus !== 'Open' && ( +
No claimable rewards
+ ) + )} +
+ )} + {/* Token Balances */}