// ======================================== // Score Systems Integration // ======================================== // All score pallets live on People Chain: // - Trust Score: pezpallet-trust (People Chain) - composite score // - Referral Score: pezpallet-referral (People Chain) // - Staking Score: pezpallet-staking-score (People Chain) - uses cached staking data from Asset Hub via XCM // - Tiki Score: pezpallet-tiki (People Chain) // - Perwerde Score: pezpallet-perwerde (People Chain) import type { ApiPromise } from '@pezkuwi/api'; import { formatBalance } from './wallet'; // ======================================== // TYPE DEFINITIONS // ======================================== export interface UserScores { trustScore: number; referralScore: number; stakingScore: number; tikiScore: number; totalScore: number; } export interface StakingScoreStatus { isTracking: boolean; hasCachedData: boolean; score: number; // 0-100 computed from CachedStakingDetails + duration startBlock: number | null; currentBlock: number; 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) // ======================================== /** * Fetch user's trust score from blockchain * Storage: trust.trustScores(address) */ export async function getTrustScore( peopleApi: ApiPromise, address: string ): Promise { try { if (!peopleApi?.query?.trust?.trustScores) { return 0; } const score = await peopleApi.query.trust.trustScores(address); if (score.isEmpty) { return 0; } return Number(score.toString()); } catch (error) { console.error('Error fetching trust score:', error); return 0; } } // ======================================== // REFERRAL SCORE (pezpallet-referral on People Chain) // ======================================== /** * Fetch user's referral score from on-chain stats * Storage: referral.referrerStatsStorage(address) → { totalReferrals, revokedReferrals, penaltyScore } * * On-chain tiered scoring (matches pezpallet-referral): * - 0 referrals: 0 points * - 1-10 referrals: count × 10 points (max 100) * - 11-50 referrals: 100 + (count - 10) × 5 points (max 300) * - 51-100 referrals: 300 + (count - 50) × 4 points (max 500) * - 101+ referrals: 500 points (maximum) * Then subtract penalty_score from revoked referrals */ export async function getReferralScore( peopleApi: ApiPromise, address: string ): Promise { try { // Try reading from referrerStatsStorage for full stats with penalties if (peopleApi?.query?.referral?.referrerStatsStorage) { const stats = await peopleApi.query.referral.referrerStatsStorage(address); if (!stats.isEmpty) { const statsJson = stats.toJSON() as { totalReferrals?: number; total_referrals?: number; revokedReferrals?: number; revoked_referrals?: number; penaltyScore?: number; penalty_score?: number; }; const totalReferrals = statsJson.totalReferrals ?? statsJson.total_referrals ?? 0; const revokedReferrals = statsJson.revokedReferrals ?? statsJson.revoked_referrals ?? 0; const penaltyScore = statsJson.penaltyScore ?? statsJson.penalty_score ?? 0; // Step 1: Remove revoked referrals from count const goodReferrals = Math.max(0, totalReferrals - revokedReferrals); // Step 2: Tiered scoring let baseScore: number; if (goodReferrals === 0) baseScore = 0; else if (goodReferrals <= 10) baseScore = goodReferrals * 10; else if (goodReferrals <= 50) baseScore = 100 + ((goodReferrals - 10) * 5); else if (goodReferrals <= 100) baseScore = 300 + ((goodReferrals - 50) * 4); else baseScore = 500; // Step 3: Subtract penalty return Math.max(0, baseScore - penaltyScore); } } // Fallback: simple count-based scoring if (peopleApi?.query?.referral?.referralCount) { const count = await peopleApi.query.referral.referralCount(address); const referralCount = Number(count.toString()); if (referralCount === 0) return 0; if (referralCount <= 10) return referralCount * 10; if (referralCount <= 50) return 100 + ((referralCount - 10) * 5); if (referralCount <= 100) return 300 + ((referralCount - 50) * 4); return 500; } return 0; } catch (error) { console.error('Error fetching referral score:', error); return 0; } } /** * Get raw referral count */ export async function getReferralCount( peopleApi: ApiPromise, address: string ): Promise { try { if (!peopleApi?.query?.referral?.referralCount) { return 0; } const count = await peopleApi.query.referral.referralCount(address); return Number(count.toString()); } catch (error) { console.error('Error fetching referral count:', error); return 0; } } // ======================================== // STAKING SCORE (pezpallet-staking-score on People Chain) // ======================================== /** * Check staking score tracking status and compute actual staking score. * Queries CachedStakingDetails from People Chain and calculates score * using the same formula as pallet_staking_score::get_staking_score(). * * Score Formula: * 1. Amount Score (20-50 points based on staked HEZ) * - 0-100 HEZ: 20, 101-250: 30, 251-750: 40, 751+: 50 * 2. Duration Multiplier (time since startScoreTracking) * - <1mo: x1.0, 1-2mo: x1.2, 3-5mo: x1.4, 6-11mo: x1.7, 12+mo: x2.0 * 3. Final = min(100, floor(amountScore * durationMultiplier)) */ export async function getStakingScoreStatus( peopleApi: ApiPromise, address: string ): Promise { const empty: StakingScoreStatus = { isTracking: false, hasCachedData: false, score: 0, startBlock: null, currentBlock: 0, durationBlocks: 0, }; try { if (!peopleApi?.query?.stakingScore?.stakingStartBlock) { return empty; } const startBlockResult = await peopleApi.query.stakingScore.stakingStartBlock(address); const currentBlock = Number((await peopleApi.query.system.number()).toString()); if (startBlockResult.isEmpty || startBlockResult.isNone) { return { ...empty, currentBlock }; } const startBlock = Number(startBlockResult.toString()); const durationBlocks = currentBlock - startBlock; // Query CachedStakingDetails for both sources let hasCachedData = false; let totalStakeWei = BigInt(0); if (peopleApi.query.stakingScore.cachedStakingDetails) { try { const [relayResult, assetHubResult] = await Promise.all([ peopleApi.query.stakingScore.cachedStakingDetails(address, 'RelayChain') .catch(() => null), peopleApi.query.stakingScore.cachedStakingDetails(address, 'AssetHub') .catch(() => null), ]); if (relayResult && !relayResult.isEmpty && relayResult.isSome) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const json = (relayResult.unwrap() as any).toJSON(); totalStakeWei += BigInt(json.stakedAmount ?? json.staked_amount ?? '0'); hasCachedData = true; } if (assetHubResult && !assetHubResult.isEmpty && assetHubResult.isSome) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const json = (assetHubResult.unwrap() as any).toJSON(); totalStakeWei += BigInt(json.stakedAmount ?? json.staked_amount ?? '0'); hasCachedData = true; } } catch { // keep defaults } } // Calculate staking score from cached data let score = 0; if (hasCachedData && totalStakeWei > BigInt(0)) { const stakedHEZ = Number(totalStakeWei / BigInt(10 ** 12)); // Amount tier let amountScore = 20; if (stakedHEZ > 750) amountScore = 50; else if (stakedHEZ > 250) amountScore = 40; else if (stakedHEZ > 100) amountScore = 30; // Duration multiplier const MONTH = 432000; // ~30 days in blocks (6s per block) let mult = 1.0; if (durationBlocks >= 12 * MONTH) mult = 2.0; else if (durationBlocks >= 6 * MONTH) mult = 1.7; else if (durationBlocks >= 3 * MONTH) mult = 1.4; else if (durationBlocks >= MONTH) mult = 1.2; score = Math.min(100, Math.floor(amountScore * mult)); } return { isTracking: true, hasCachedData, score, startBlock, currentBlock, durationBlocks }; } catch (error) { console.error('Error fetching staking score status:', error); return empty; } } /** * Start staking score tracking * Calls: stakingScore.startScoreTracking() * * Called on People Chain. No stake requirement - user opts in, then a * noter-authorized account submits staking data via receive_staking_details(). */ export async function startScoreTracking( peopleApi: ApiPromise, address: string, signer: any ): Promise<{ success: boolean; error?: string }> { try { if (!peopleApi?.tx?.stakingScore?.startScoreTracking) { return { success: false, error: 'stakingScore pallet not available on this chain' }; } const tx = peopleApi.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); resolve({ success: false, error: `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}` }); } else { resolve({ success: false, error: dispatchError.toString() }); } } else { resolve({ success: true }); } } }); }); } catch (error) { console.error('Error starting score tracking:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } // ======================================== // TIKI SCORE (pezpallet-tiki on People Chain) // ======================================== import { fetchUserTikis, calculateTikiScore } from './tiki'; /** * Get tiki score from user's roles */ export async function getTikiScore( peopleApi: ApiPromise, address: string ): Promise { try { const tikis = await fetchUserTikis(peopleApi, address); return calculateTikiScore(tikis); } catch (error) { console.error('Error fetching tiki score:', error); return 0; } } // ======================================== // COMPREHENSIVE SCORE FETCHING // ======================================== /** * Fetch all scores for a user from People Chain * Trust pallet computes composite score on-chain (includes staking, referral, tiki, perwerde) */ export async function getAllScores( peopleApi: ApiPromise | null, address: string ): Promise { if (!peopleApi || !address) { return { trustScore: 0, referralScore: 0, stakingScore: 0, tikiScore: 0, totalScore: 0 }; } try { const [trustScore, referralScore, tikiScore, stakingStatus] = await Promise.all([ getTrustScore(peopleApi, address), getReferralScore(peopleApi, address), getTikiScore(peopleApi, address), getStakingScoreStatus(peopleApi, address), ]); return { trustScore, referralScore, stakingScore: stakingStatus.score, tikiScore, totalScore: trustScore, }; } catch (error) { console.error('Error fetching scores:', error); return { trustScore: 0, referralScore: 0, stakingScore: 0, tikiScore: 0, totalScore: 0 }; } } // ======================================== // SCORE DISPLAY HELPERS // ======================================== export function getScoreColor(score: number): string { if (score >= 200) return 'text-purple-500'; if (score >= 150) return 'text-pink-500'; if (score >= 100) return 'text-blue-500'; if (score >= 70) return 'text-cyan-500'; if (score >= 40) return 'text-teal-500'; if (score >= 20) return 'text-green-500'; return 'text-gray-500'; } export function getScoreRating(score: number): string { if (score >= 250) return 'Legendary'; if (score >= 200) return 'Excellent'; if (score >= 150) return 'Very Good'; if (score >= 100) return 'Good'; if (score >= 70) return 'Average'; if (score >= 40) return 'Fair'; if (score >= 20) return 'Low'; return 'Very Low'; } export function formatScore(score: number): string { return score.toFixed(0); } export function formatDuration(blocks: number): string { const BLOCKS_PER_MINUTE = 10; const minutes = blocks / BLOCKS_PER_MINUTE; const hours = minutes / 60; const days = hours / 24; const months = days / 30; if (months >= 1) return `${Math.floor(months)} month${Math.floor(months) > 1 ? 's' : ''}`; if (days >= 1) return `${Math.floor(days)} day${Math.floor(days) > 1 ? 's' : ''}`; if (hours >= 1) return `${Math.floor(hours)} hour${Math.floor(hours) > 1 ? 's' : ''}`; return `${Math.floor(minutes)} minute${Math.floor(minutes) > 1 ? 's' : ''}`; } // ======================================== // CITIZENSHIP & PERWERDE // ======================================== /** * Check if user is a citizen (KYC approved) */ export async function checkCitizenshipStatus( peopleApi: ApiPromise | null, address: string ): Promise { if (!peopleApi || !address) return false; try { if (!peopleApi.query?.identityKyc?.kycStatuses) { return false; } const status = await peopleApi.query.identityKyc.kycStatuses(address); const statusStr = status.toString(); // KycLevel::Approved = "Approved" or numeric value 3 return statusStr === 'Approved' || statusStr === '3'; } catch (err) { console.error('Error checking citizenship status:', err); return false; } } /** * Get Perwerde (education) score * This is from pezpallet-perwerde on People Chain * * Queries studentCourses for enrolled course IDs, then checks enrollments * for completed courses and sums their points. */ export async function getPerwerdeScore( peopleApi: ApiPromise | null, address: string ): Promise { if (!peopleApi || !address) return 0; try { // Check if perwerde pallet exists if (!peopleApi.query?.perwerde) { return 0; } // Get user's enrolled course IDs if (!peopleApi.query.perwerde.studentCourses) { return 0; } const coursesResult = await peopleApi.query.perwerde.studentCourses(address); const courseIds = coursesResult.toJSON() as number[]; if (!Array.isArray(courseIds) || courseIds.length === 0) { return 0; } // Sum points from completed courses let totalPoints = 0; for (const courseId of courseIds) { try { if (!peopleApi.query.perwerde.enrollments) break; const enrollment = await peopleApi.query.perwerde.enrollments([address, courseId]); if (enrollment.isEmpty || enrollment.isNone) continue; const enrollmentJson = enrollment.toJSON() as { completedAt?: number | null; completed_at?: number | null; pointsEarned?: number; points_earned?: number; }; const completedAt = enrollmentJson.completedAt ?? enrollmentJson.completed_at; if (completedAt !== null && completedAt !== undefined) { totalPoints += enrollmentJson.pointsEarned ?? enrollmentJson.points_earned ?? 0; } } catch { // Skip individual enrollment errors } } return totalPoints; } catch (err) { console.error('Error fetching perwerde score:', err); return 0; } } // ======================================== // PEZ REWARDS (pezRewards pallet on People Chain) // ======================================== /** * Get PEZ rewards information for an account * Uses correct storage query names from live People Chain metadata: * - epochInfo() → epoch info (ValueQuery) * - epochStatus(epoch) → Open | ClaimPeriod | Closed * - userEpochScores(epoch, addr) → user's recorded score (OptionQuery) * - claimedRewards(epoch, addr) → claimed reward amount (OptionQuery) * - epochRewardPools(epoch) → reward pool info (OptionQuery) */ export async function getPezRewards( peopleApi: ApiPromise, address: string ): Promise { try { if (!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) { 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.userEpochScores(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.claimedRewards(i, address); if (claimedResult.isSome) continue; // Check if user has a score for this epoch const userScoreResult = await peopleApi.query.pezRewards.userEpochScores(i, address); if (!userScoreResult.isSome) continue; // Get epoch reward pool const epochPoolResult = await peopleApi.query.pezRewards.epochRewardPools(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' }; } }