From 90b8204c25ad1fb83ccfbd56a0c782fd6e8deebf Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Sat, 7 Feb 2026 00:44:04 +0300 Subject: [PATCH] feat: add frontend fallback for staking and trust scores Until runtime upgrade is deployed, calculate scores on frontend: shared/lib/scores.ts: - getFrontendStakingScore: Read stake from Relay Chain, track in localStorage - getFrontendTrustScore: Calculate using pallet formula - getAllScoresWithFallback: Combined score fetching with fallback Formula (matching pezpallet-trust): - weighted_sum = staking*100 + referral*300 + perwerde*300 + tiki*300 - trust_score = (staking * weighted_sum) / 100 Components updated: - AccountBalance.tsx: Use getAllScoresWithFallback - HeroSection.tsx: Use getTrustScoreWithFallback --- shared/lib/scores.ts | 527 +++++++++++++++++++++++++- web/src/components/AccountBalance.tsx | 11 +- web/src/components/HeroSection.tsx | 17 +- 3 files changed, 537 insertions(+), 18 deletions(-) diff --git a/shared/lib/scores.ts b/shared/lib/scores.ts index d225f9f8..9e57faf7 100644 --- a/shared/lib/scores.ts +++ b/shared/lib/scores.ts @@ -287,10 +287,11 @@ export async function getTikiScore( /** * Fetch all scores for a user in one call * All scores come from People Chain except staking amount (Relay Chain) + * Staking score uses frontend fallback if on-chain pallet is not available * * @param peopleApi - People Chain API (trust, referral, stakingScore, tiki pallets) * @param address - User's blockchain address - * @param relayApi - Optional Relay Chain API (for staking.ledger amount) + * @param relayApi - Optional Relay Chain API (for staking.ledger amount and frontend fallback) */ export async function getAllScores( peopleApi: ApiPromise, @@ -298,7 +299,7 @@ export async function getAllScores( relayApi?: ApiPromise ): Promise { try { - if (!peopleApi || !address) { + if (!address) { return { trustScore: 0, referralScore: 0, @@ -308,12 +309,28 @@ export async function getAllScores( }; } - // Fetch all scores in parallel from People Chain - const [trustScore, referralScore, stakingScore, tikiScore] = await Promise.all([ - getTrustScore(peopleApi, address), - getReferralScore(peopleApi, address), - getStakingScore(peopleApi, address, relayApi), - getTikiScore(peopleApi, address) + // Fetch all scores in parallel + const scorePromises: Promise[] = []; + + // Trust and referral scores from People Chain + if (peopleApi) { + scorePromises.push(getTrustScore(peopleApi, address)); + scorePromises.push(getReferralScore(peopleApi, address)); + scorePromises.push(getTikiScore(peopleApi, address)); + } else { + scorePromises.push(Promise.resolve(0)); + scorePromises.push(Promise.resolve(0)); + scorePromises.push(Promise.resolve(0)); + } + + // Staking score with frontend fallback + const stakingScorePromise = relayApi + ? getStakingScoreWithFallback(peopleApi, relayApi, address).then(r => r.score) + : Promise.resolve(0); + + const [trustScore, referralScore, tikiScore, stakingScore] = await Promise.all([ + ...scorePromises, + stakingScorePromise ]); const totalScore = trustScore + referralScore + stakingScore + tikiScore; @@ -378,3 +395,497 @@ export function formatDuration(blocks: number): string { if (hours >= 1) return `${Math.floor(hours)} hour${Math.floor(hours) > 1 ? 's' : ''}`; return `${Math.floor(minutes)} minute${Math.floor(minutes) > 1 ? 's' : ''}`; } + +// ======================================== +// FRONTEND STAKING SCORE (Fallback) +// ======================================== +// Until runtime upgrade deploys, calculate staking score +// directly from Relay Chain data without People Chain pallet + +const STAKING_SCORE_STORAGE_KEY = 'pez_staking_score_tracking'; +const UNITS = 1_000_000_000_000; // 10^12 +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const MONTH_IN_MS = 30 * DAY_IN_MS; + +interface StakingTrackingData { + [address: string]: { + startTime: number; // Unix timestamp in ms + lastChecked: number; + lastStakeAmount: string; // Store as string to preserve precision + }; +} + +/** + * Get tracking data from localStorage + */ +function getStakingTrackingData(): StakingTrackingData { + if (typeof window === 'undefined') return {}; + try { + const stored = localStorage.getItem(STAKING_SCORE_STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +/** + * Save tracking data to localStorage + */ +function saveStakingTrackingData(data: StakingTrackingData): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(STAKING_SCORE_STORAGE_KEY, JSON.stringify(data)); + } catch (err) { + console.error('Failed to save staking tracking data:', err); + } +} + +/** + * Fetch staking details directly from Relay Chain + */ +export async function fetchRelayStakingDetails( + relayApi: ApiPromise, + address: string +): Promise<{ stakedAmount: bigint; nominationsCount: number } | null> { + try { + if (!relayApi?.query?.staking) return null; + + // Try to get ledger directly (if address is controller) + let ledger = await relayApi.query.staking.ledger?.(address); + let stashAddress = address; + + // If no ledger, check if this is a stash account + if (!ledger || ledger.isEmpty || ledger.isNone) { + const bonded = await relayApi.query.staking.bonded?.(address); + if (bonded && !bonded.isEmpty && !bonded.isNone) { + // This is a stash, get controller's ledger + const controller = bonded.toString(); + ledger = await relayApi.query.staking.ledger?.(controller); + stashAddress = address; + } + } + + if (!ledger || ledger.isEmpty || ledger.isNone) { + return null; + } + + // Parse ledger data + const ledgerJson = ledger.toJSON() as { active?: string | number }; + const active = BigInt(ledgerJson?.active || 0); + + // Get nominations + const nominations = await relayApi.query.staking.nominators?.(stashAddress); + const nominationsJson = nominations?.toJSON() as { targets?: unknown[] } | null; + const nominationsCount = nominationsJson?.targets?.length || 0; + + return { + stakedAmount: active, + nominationsCount + }; + } catch (err) { + console.error('Failed to fetch relay staking details:', err); + return null; + } +} + +/** + * Calculate base score from staked HEZ amount (matching pallet algorithm) + */ +function calculateBaseStakingScore(stakedHez: number): number { + if (stakedHez <= 0) return 0; + if (stakedHez <= 100) return 20; + if (stakedHez <= 250) return 30; + if (stakedHez <= 750) return 40; + return 50; // 751+ HEZ +} + +/** + * Get time multiplier based on months staked (matching pallet algorithm) + */ +function getStakingTimeMultiplier(monthsStaked: number): number { + if (monthsStaked >= 12) return 2.0; + if (monthsStaked >= 6) return 1.7; + if (monthsStaked >= 3) return 1.4; + if (monthsStaked >= 1) return 1.2; + return 1.0; +} + +export interface FrontendStakingScoreResult { + score: number; + stakedAmount: bigint; + stakedHez: number; + trackingStarted: Date | null; + monthsStaked: number; + timeMultiplier: number; + nominationsCount: number; + isFromFrontend: boolean; // true = frontend fallback, false = on-chain + needsRefresh: boolean; +} + +/** + * Get staking score using frontend fallback + * This bypasses the broken People Chain pallet and reads directly from Relay Chain + * + * @param relayApi - Relay Chain API (staking pallet) + * @param address - User's blockchain address + */ +export async function getFrontendStakingScore( + relayApi: ApiPromise, + address: string +): Promise { + const emptyResult: FrontendStakingScoreResult = { + score: 0, + stakedAmount: 0n, + stakedHez: 0, + trackingStarted: null, + monthsStaked: 0, + timeMultiplier: 1.0, + nominationsCount: 0, + isFromFrontend: true, + needsRefresh: false + }; + + if (!relayApi || !address) return emptyResult; + + // Fetch staking details from Relay Chain + const details = await fetchRelayStakingDetails(relayApi, address); + + if (!details || details.stakedAmount === 0n) { + return emptyResult; + } + + // Get or initialize tracking data + const trackingData = getStakingTrackingData(); + const now = Date.now(); + + if (!trackingData[address]) { + // First time seeing stake - start tracking + trackingData[address] = { + startTime: now, + lastChecked: now, + lastStakeAmount: details.stakedAmount.toString() + }; + saveStakingTrackingData(trackingData); + } else { + // Update last checked time and stake amount + trackingData[address].lastChecked = now; + trackingData[address].lastStakeAmount = details.stakedAmount.toString(); + saveStakingTrackingData(trackingData); + } + + const userTracking = trackingData[address]; + const trackingStarted = new Date(userTracking.startTime); + const msStaked = now - userTracking.startTime; + const monthsStaked = Math.floor(msStaked / MONTH_IN_MS); + + // Calculate score (matching pallet algorithm) + const stakedHez = Number(details.stakedAmount) / UNITS; + const baseScore = calculateBaseStakingScore(stakedHez); + const timeMultiplier = getStakingTimeMultiplier(monthsStaked); + const finalScore = Math.min(Math.floor(baseScore * timeMultiplier), 100); + + // Check if needs refresh (older than 24 hours) + const needsRefresh = now - userTracking.lastChecked > DAY_IN_MS; + + return { + score: finalScore, + stakedAmount: details.stakedAmount, + stakedHez, + trackingStarted, + monthsStaked, + timeMultiplier, + nominationsCount: details.nominationsCount, + isFromFrontend: true, + needsRefresh + }; +} + +/** + * Get staking score with frontend fallback + * First tries on-chain pallet, falls back to frontend calculation if it fails + * + * @param peopleApi - People Chain API (optional, for on-chain score) + * @param relayApi - Relay Chain API (required for stake data) + * @param address - User's blockchain address + */ +export async function getStakingScoreWithFallback( + peopleApi: ApiPromise | null, + relayApi: ApiPromise, + address: string +): Promise { + // First try on-chain score from People Chain + if (peopleApi) { + try { + const status = await getStakingScoreStatus(peopleApi, address); + if (status.isTracking && status.startBlock) { + // On-chain tracking is active, use it + const onChainScore = await getStakingScore(peopleApi, address, relayApi); + if (onChainScore > 0) { + const details = await fetchRelayStakingDetails(relayApi, address); + return { + score: onChainScore, + stakedAmount: details?.stakedAmount || 0n, + stakedHez: details ? Number(details.stakedAmount) / UNITS : 0, + trackingStarted: new Date(status.startBlock * 6000), // Approximate + monthsStaked: Math.floor(status.durationBlocks / (30 * 24 * 60 * 10)), + timeMultiplier: 1.0, // Already applied in on-chain score + nominationsCount: details?.nominationsCount || 0, + isFromFrontend: false, + needsRefresh: false + }; + } + } + } catch (err) { + console.log('On-chain staking score not available, using frontend fallback'); + } + } + + // Fall back to frontend calculation + return getFrontendStakingScore(relayApi, address); +} + +/** + * Format staked amount for display + */ +export function formatStakedAmount(amount: bigint): string { + const hez = Number(amount) / UNITS; + if (hez >= 1000000) return `${(hez / 1000000).toFixed(2)}M`; + if (hez >= 1000) return `${(hez / 1000).toFixed(2)}K`; + return hez.toFixed(2); +} + +// ======================================== +// FRONTEND TRUST SCORE (Fallback) +// ======================================== +// Until runtime upgrade, calculate trust score using frontend fallback +// Formula from pezpallet-trust: +// weighted_sum = staking*100 + referral*300 + perwerde*300 + tiki*300 +// trust_score = (staking * weighted_sum) / 100 + +const SCORE_MULTIPLIER_BASE = 100; + +export interface FrontendTrustScoreResult { + trustScore: number; + stakingScore: number; + referralScore: number; + perwerdeScore: number; + tikiScore: number; + weightedSum: number; + isFromFrontend: boolean; + isCitizen: boolean; +} + +/** + * 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 + */ +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; + } + + // Try to get user's completed courses/certifications + // The exact storage depends on pallet implementation + if (peopleApi.query.perwerde.userScores) { + const score = await peopleApi.query.perwerde.userScores(address); + if (!score.isEmpty) { + return Number(score.toString()); + } + } + + // Alternative: count completed courses + if (peopleApi.query.perwerde.completedCourses) { + const courses = await peopleApi.query.perwerde.completedCourses(address); + const coursesJson = courses.toJSON() as unknown[]; + if (Array.isArray(coursesJson)) { + // Each completed course = 10 points, max 50 + return Math.min(coursesJson.length * 10, 50); + } + } + + return 0; + } catch (err) { + console.error('Error fetching perwerde score:', err); + return 0; + } +} + +/** + * Calculate trust score using frontend fallback + * Uses the same formula as pezpallet-trust + * + * @param peopleApi - People Chain API (optional) + * @param relayApi - Relay Chain API (required for staking data) + * @param address - User's blockchain address + */ +export async function getFrontendTrustScore( + peopleApi: ApiPromise | null, + relayApi: ApiPromise, + address: string +): Promise { + const emptyResult: FrontendTrustScoreResult = { + trustScore: 0, + stakingScore: 0, + referralScore: 0, + perwerdeScore: 0, + tikiScore: 0, + weightedSum: 0, + isFromFrontend: true, + isCitizen: false + }; + + if (!address) return emptyResult; + + // Check citizenship status + const isCitizen = await checkCitizenshipStatus(peopleApi, address); + + // Get component scores in parallel + const [stakingResult, referralScore, perwerdeScore, tikiScore] = await Promise.all([ + getFrontendStakingScore(relayApi, address), + peopleApi ? getReferralScore(peopleApi, address) : Promise.resolve(0), + getPerwerdeScore(peopleApi, address), + peopleApi ? getTikiScore(peopleApi, address) : Promise.resolve(0) + ]); + + const stakingScore = stakingResult.score; + + // If staking score is 0, trust score is 0 (matches pallet logic) + if (stakingScore === 0) { + return { + ...emptyResult, + referralScore, + perwerdeScore, + tikiScore, + isCitizen + }; + } + + // Calculate weighted sum (matching pallet formula) + const weightedSum = + stakingScore * 100 + + referralScore * 300 + + perwerdeScore * 300 + + tikiScore * 300; + + // Calculate final trust score + // trust_score = (staking * weighted_sum) / 100 + const trustScore = Math.floor((stakingScore * weightedSum) / SCORE_MULTIPLIER_BASE); + + return { + trustScore, + stakingScore, + referralScore, + perwerdeScore, + tikiScore, + weightedSum, + isFromFrontend: true, + isCitizen + }; +} + +/** + * Get trust score with frontend fallback + * First tries on-chain, falls back to frontend calculation + */ +export async function getTrustScoreWithFallback( + peopleApi: ApiPromise | null, + relayApi: ApiPromise, + address: string +): Promise { + // First try on-chain trust score + if (peopleApi) { + try { + const onChainScore = await getTrustScore(peopleApi, address); + if (onChainScore > 0) { + // Get component scores for display + const [stakingResult, referralScore, perwerdeScore, tikiScore] = await Promise.all([ + getFrontendStakingScore(relayApi, address), + getReferralScore(peopleApi, address), + getPerwerdeScore(peopleApi, address), + getTikiScore(peopleApi, address) + ]); + + const isCitizen = await checkCitizenshipStatus(peopleApi, address); + + return { + trustScore: onChainScore, + stakingScore: stakingResult.score, + referralScore, + perwerdeScore, + tikiScore, + weightedSum: 0, // Not calculated for on-chain + isFromFrontend: false, + isCitizen + }; + } + } catch (err) { + console.log('On-chain trust score not available, using frontend fallback'); + } + } + + // Fall back to frontend calculation + return getFrontendTrustScore(peopleApi, relayApi, address); +} + +/** + * Get all scores with frontend fallback for staking and trust + */ +export async function getAllScoresWithFallback( + peopleApi: ApiPromise | null, + relayApi: ApiPromise, + address: string +): Promise { + if (!address) { + return { + trustScore: 0, + referralScore: 0, + stakingScore: 0, + tikiScore: 0, + totalScore: 0, + isFromFrontend: true + }; + } + + const trustResult = await getTrustScoreWithFallback(peopleApi, relayApi, address); + + return { + trustScore: trustResult.trustScore, + referralScore: trustResult.referralScore, + stakingScore: trustResult.stakingScore, + tikiScore: trustResult.tikiScore, + totalScore: trustResult.trustScore, // Trust score IS the total score + isFromFrontend: trustResult.isFromFrontend + }; +} diff --git a/web/src/components/AccountBalance.tsx b/web/src/components/AccountBalance.tsx index 21f404c7..be5feac7 100644 --- a/web/src/components/AccountBalance.tsx +++ b/web/src/components/AccountBalance.tsx @@ -8,7 +8,7 @@ import { AddTokenModal } from './AddTokenModal'; import { TransferModal } from './TransferModal'; import { XCMTeleportModal } from './XCMTeleportModal'; import { LPStakeModal } from './LPStakeModal'; -import { getAllScores, type UserScores } from '@pezkuwi/lib/scores'; +import { getAllScoresWithFallback, type UserScores } from '@pezkuwi/lib/scores'; interface TokenBalance { assetId: number; @@ -554,7 +554,7 @@ export const AccountBalance: React.FC = () => { fetchBalance(); fetchTokenPrices(); // Fetch token USD prices from pools - // Fetch All Scores from blockchain + // Fetch All Scores from blockchain with frontend fallback const fetchAllScores = async () => { if (!api || !isApiReady || !selectedAccount?.address) { setScores({ @@ -569,7 +569,12 @@ export const AccountBalance: React.FC = () => { setLoadingScores(true); try { - const userScores = await getAllScores(api, selectedAccount.address); + // Use fallback function: peopleApi for on-chain scores, api (Relay) for staking data + const userScores = await getAllScoresWithFallback( + peopleApi || null, // People Chain for referral, tiki, perwerde + api, // Relay Chain for staking data + selectedAccount.address + ); setScores(userScores); } catch (err) { if (import.meta.env.DEV) console.error('Failed to fetch scores:', err); diff --git a/web/src/components/HeroSection.tsx b/web/src/components/HeroSection.tsx index fb73cd81..da580b0b 100644 --- a/web/src/components/HeroSection.tsx +++ b/web/src/components/HeroSection.tsx @@ -4,10 +4,11 @@ import { ChevronRight, Shield } from 'lucide-react'; import { usePezkuwi } from '../contexts/PezkuwiContext'; import { useWallet } from '../contexts/WalletContext'; // Import useWallet import { formatBalance } from '@pezkuwi/lib/wallet'; +import { getTrustScoreWithFallback } from '@pezkuwi/lib/scores'; const HeroSection: React.FC = () => { const { t } = useTranslation(); - const { api, isApiReady } = usePezkuwi(); + const { api, isApiReady, peopleApi } = usePezkuwi(); const { selectedAccount } = useWallet(); // Use selectedAccount from WalletContext const [stats, setStats] = useState({ activeProposals: 0, @@ -23,11 +24,13 @@ const HeroSection: React.FC = () => { let currentTrustScore = 0; // Default if not fetched or no account if (selectedAccount?.address) { try { - // Assuming pallet-staking-score has a storage item for trust scores - // The exact query might need adjustment based on chain metadata - const rawTrustScore = await api.query.stakingScore.trustScore(selectedAccount.address); - // Assuming trustScore is a simple number or a wrapper around it - currentTrustScore = rawTrustScore.isSome ? rawTrustScore.unwrap().toNumber() : 0; + // Use frontend fallback for trust score + const trustResult = await getTrustScoreWithFallback( + peopleApi || null, + api, + selectedAccount.address + ); + currentTrustScore = trustResult.trustScore; } catch (err) { if (import.meta.env.DEV) console.warn('Failed to fetch trust score:', err); currentTrustScore = 0; @@ -91,7 +94,7 @@ const HeroSection: React.FC = () => { }; fetchStats(); - }, [api, isApiReady, selectedAccount]); // Add selectedAccount to dependencies + }, [api, isApiReady, peopleApi, selectedAccount]); // Add peopleApi to dependencies return (