diff --git a/package.json b/package.json index 2eb6b48..e041c74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pezkuwi-telegram-miniapp", - "version": "1.0.122", + "version": "1.0.123", "type": "module", "description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards", "author": "Pezkuwichain Team", diff --git a/src/components/wallet/WalletDashboard.tsx b/src/components/wallet/WalletDashboard.tsx index 74f3a38..aff417c 100644 --- a/src/components/wallet/WalletDashboard.tsx +++ b/src/components/wallet/WalletDashboard.tsx @@ -703,7 +703,7 @@ export function WalletDashboard({ onDisconnect }: Props) { } // Token types for send -type SendToken = 'HEZ' | 'PEZ' | 'USDT'; +type SendToken = 'HEZ' | 'PEZ' | 'USDT' | 'DOT'; interface TokenOption { symbol: SendToken; @@ -738,6 +738,14 @@ const SEND_TOKENS: TokenOption[] = [ assetId: 1000, decimals: 6, }, + { + symbol: 'DOT', + name: 'Polkadot', + chain: 'Asset Hub', + icon: '/tokens/DOT.png', + assetId: 1001, + decimals: 10, + }, ]; // Send Tab @@ -754,6 +762,7 @@ function SendTab({ onBack }: { onBack: () => void }) { const [txHash, setTxHash] = useState(''); const [pezBalance, setPezBalance] = useState('0.0000'); const [usdtBalance, setUsdtBalance] = useState('0.00'); + const [dotBalance, setDotBalance] = useState('0.0000'); // Fetch PEZ and USDT balances when Asset Hub is available useEffect(() => { @@ -778,6 +787,15 @@ function SendTab({ onBack }: { onBack: () => void }) { const usdtAmount = assetData.balance.toString(); setUsdtBalance((parseInt(usdtAmount) / 1e6).toFixed(2)); } + + // Fetch DOT balance (Asset ID: 1001, 10 decimals) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dotResult = await (assetHubApi.query.assets as any).account(1001, keypair.address); + if (dotResult.isSome) { + const assetData = dotResult.unwrap(); + const dotAmount = assetData.balance.toString(); + setDotBalance((parseInt(dotAmount) / 1e10).toFixed(4)); + } } catch (err) { console.error('Failed to fetch asset balances:', err); } @@ -790,6 +808,7 @@ function SendTab({ onBack }: { onBack: () => void }) { if (selectedToken === 'HEZ') return balance ?? '0.0000'; if (selectedToken === 'PEZ') return pezBalance; if (selectedToken === 'USDT') return usdtBalance; + if (selectedToken === 'DOT') return dotBalance; return '0.0000'; }; @@ -857,7 +876,10 @@ function SendTab({ onBack }: { onBack: () => void }) { setError('Mainnet API amade nîne'); return; } - if ((selectedToken === 'PEZ' || selectedToken === 'USDT') && !assetHubApi) { + if ( + (selectedToken === 'PEZ' || selectedToken === 'USDT' || selectedToken === 'DOT') && + !assetHubApi + ) { setError('Asset Hub API amade nîne'); return; } diff --git a/src/lib/scores.ts b/src/lib/scores.ts new file mode 100644 index 0000000..25ab293 --- /dev/null +++ b/src/lib/scores.ts @@ -0,0 +1,453 @@ +/** + * Score Systems Integration for Telegram Mini App + * Based on pwap/shared/lib/scores.ts + * + * All scores come from People Chain (people-rpc.pezkuwichain.io) + * - Trust Score: pezpallet-trust + * - Referral Score: pezpallet-referral + * - Staking Score: pezpallet-staking-score (with frontend fallback) + * - Tiki Score: pezpallet-tiki + */ + +import type { ApiPromise } from '@pezkuwi/api'; +import { calculateReferralScore, getReferralCount } from './referral'; + +// ======================================== +// TYPE DEFINITIONS +// ======================================== + +export interface UserScores { + trustScore: number; + referralScore: number; + stakingScore: number; + tikiScore: number; + totalScore: number; +} + +// ======================================== +// STAKING SCORE FRONTEND 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; + lastChecked: number; + lastStakeAmount: string; + }; +} + +function getStakingTrackingData(): StakingTrackingData { + if (typeof window === 'undefined') return {}; + try { + const stored = localStorage.getItem(STAKING_SCORE_STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +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 { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(relayApi?.query as any)?.staking) return null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ledger = await (relayApi.query.staking as any).ledger?.(address); + let stashAddress = address; + + // If no ledger, check if this is a stash account + if (!ledger || ledger.isEmpty || ledger.isNone) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bonded = await (relayApi.query.staking as any).bonded?.(address); + if (bonded && !bonded.isEmpty && !bonded.isNone) { + const controller = bonded.toString(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ledger = await (relayApi.query.staking as any).ledger?.(controller); + stashAddress = address; + } + } + + if (!ledger || ledger.isEmpty || ledger.isNone) { + return null; + } + + const ledgerJson = ledger.toJSON() as { active?: string | number }; + const active = BigInt(ledgerJson?.active || 0); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nominations = await (relayApi.query.staking as any).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 + */ +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 + */ +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; +} + +/** + * Get staking score using frontend fallback + */ +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, + }; + + if (!relayApi || !address) return emptyResult; + + const details = await fetchRelayStakingDetails(relayApi, address); + + if (!details || details.stakedAmount === 0n) { + return emptyResult; + } + + const trackingData = getStakingTrackingData(); + const now = Date.now(); + + if (!trackingData[address]) { + trackingData[address] = { + startTime: now, + lastChecked: now, + lastStakeAmount: details.stakedAmount.toString(), + }; + saveStakingTrackingData(trackingData); + } else { + 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); + + const stakedHez = Number(details.stakedAmount) / UNITS; + const baseScore = calculateBaseStakingScore(stakedHez); + const timeMultiplier = getStakingTimeMultiplier(monthsStaked); + const finalScore = Math.min(Math.floor(baseScore * timeMultiplier), 100); + + return { + score: finalScore, + stakedAmount: details.stakedAmount, + stakedHez, + trackingStarted, + monthsStaked, + timeMultiplier, + nominationsCount: details.nominationsCount, + }; +} + +// ======================================== +// TIKI SCORE +// ======================================== + +export interface TikiInfo { + roleId: number; + level: number; + name: string; +} + +const TIKI_ROLE_SCORES: Record = { + 1: 10, // Basic + 2: 20, // Bronze + 3: 30, // Silver + 4: 40, // Gold + 5: 50, // Platinum +}; + +/** + * Fetch user's tiki roles from People Chain + */ +export async function fetchUserTikis(peopleApi: ApiPromise, address: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(peopleApi?.query as any)?.tiki) return []; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (peopleApi.query.tiki as any).userRoles?.(address); + + if (!result || result.isEmpty) return []; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const roles = result.toJSON() as any[]; + return roles.map((role) => ({ + roleId: role.roleId || role.role_id || 0, + level: role.level || 0, + name: role.name || 'Unknown', + })); + } catch (err) { + console.error('Failed to fetch tiki roles:', err); + return []; + } +} + +/** + * Calculate tiki score from user's roles + */ +export function calculateTikiScore(tikis: TikiInfo[]): number { + if (!tikis.length) return 0; + + // Get highest role score + let maxScore = 0; + for (const tiki of tikis) { + const roleScore = TIKI_ROLE_SCORES[tiki.roleId] || 0; + maxScore = Math.max(maxScore, roleScore); + } + + return Math.min(maxScore, 50); // Capped at 50 +} + +/** + * Get tiki score for a user + */ +export async function getTikiScore(peopleApi: ApiPromise, address: string): Promise { + try { + const tikis = await fetchUserTikis(peopleApi, address); + return calculateTikiScore(tikis); + } catch (err) { + console.error('Error fetching tiki score:', err); + return 0; + } +} + +// ======================================== +// TRUST SCORE CALCULATION +// ======================================== +// 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; + 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 { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(peopleApi.query as any)?.identityKyc?.kycStatuses) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const status = await (peopleApi.query.identityKyc as any).kycStatuses(address); + const statusStr = status.toString(); + + return statusStr === 'Approved' || statusStr === '3'; + } catch (err) { + console.error('Error checking citizenship status:', err); + return false; + } +} + +/** + * Get Perwerde (education) score + */ +export async function getPerwerdeScore( + peopleApi: ApiPromise | null, + address: string +): Promise { + if (!peopleApi || !address) return 0; + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(peopleApi.query as any)?.perwerde) { + return 0; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((peopleApi.query.perwerde as any).userScores) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const score = await (peopleApi.query.perwerde as any).userScores(address); + if (!score.isEmpty) { + return Number(score.toString()); + } + } + + return 0; + } catch (err) { + console.error('Error fetching perwerde score:', err); + return 0; + } +} + +/** + * Calculate all scores with frontend fallback + */ +export async function getAllScoresWithFallback( + peopleApi: ApiPromise | null, + relayApi: ApiPromise | null, + address: string +): Promise { + const emptyResult: FrontendTrustScoreResult & { isFromFrontend: boolean } = { + trustScore: 0, + stakingScore: 0, + referralScore: 0, + perwerdeScore: 0, + tikiScore: 0, + weightedSum: 0, + isCitizen: false, + isFromFrontend: true, + }; + + if (!address) return emptyResult; + + // Check citizenship status + const isCitizen = await checkCitizenshipStatus(peopleApi, address); + + // Get component scores in parallel + const [stakingResult, referralCount, perwerdeScore, tikiScore] = await Promise.all([ + relayApi ? getFrontendStakingScore(relayApi, address) : Promise.resolve({ score: 0 }), + peopleApi ? getReferralCount(peopleApi, address) : Promise.resolve(0), + getPerwerdeScore(peopleApi, address), + peopleApi ? getTikiScore(peopleApi, address) : Promise.resolve(0), + ]); + + const stakingScore = stakingResult.score; + const referralScore = calculateReferralScore(referralCount); + + // 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, + isCitizen, + isFromFrontend: true, + }; +} + +// ======================================== +// SCORE DISPLAY HELPERS +// ======================================== + +export function getScoreColor(score: number): string { + if (score >= 200) return 'text-purple-400'; + if (score >= 150) return 'text-pink-400'; + if (score >= 100) return 'text-blue-400'; + if (score >= 70) return 'text-cyan-400'; + if (score >= 40) return 'text-teal-400'; + if (score >= 20) return 'text-green-400'; + return 'text-gray-400'; +} + +export function getScoreRating(score: number): string { + if (score >= 250) return 'Efsane'; + if (score >= 200) return 'Pir Baş'; + if (score >= 150) return 'Baş'; + if (score >= 100) return 'Navîn'; + if (score >= 70) return 'Têr'; + if (score >= 40) return 'Destpêk'; + if (score >= 20) return 'Nû'; + return 'Sifir'; +} + +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); +} diff --git a/src/sections/Rewards.tsx b/src/sections/Rewards.tsx index a09f98f..e0474d4 100644 --- a/src/sections/Rewards.tsx +++ b/src/sections/Rewards.tsx @@ -17,6 +17,10 @@ import { Award, Zap, Coins, + Shield, + Target, + Sparkles, + GraduationCap, } from 'lucide-react'; import { cn, formatAddress } from '@/lib/utils'; import { useTelegram } from '@/hooks/useTelegram'; @@ -24,6 +28,15 @@ import { useAuth } from '@/contexts/AuthContext'; import { useReferral } from '@/contexts/ReferralContext'; import { useWallet } from '@/contexts/WalletContext'; import { SocialLinks } from '@/components/SocialLinks'; +import { + getAllScoresWithFallback, + getFrontendStakingScore, + formatStakedAmount, + getScoreColor, + getScoreRating, + type FrontendTrustScoreResult, + type FrontendStakingScoreResult, +} from '@/lib/scores'; // Activity tracking constants const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active'; @@ -33,12 +46,17 @@ export function RewardsSection() { const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram(); const { user: authUser } = useAuth(); const { stats, myReferrals, loading, refreshStats } = useReferral(); - const { isConnected } = useWallet(); + const { isConnected, address, api, peopleApi } = useWallet(); const [copied, setCopied] = useState(false); - const [activeTab, setActiveTab] = useState<'overview' | 'referrals'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'referrals' | 'scores'>('overview'); const [isActive, setIsActive] = useState(false); const [timeRemaining, setTimeRemaining] = useState(null); + const [userScores, setUserScores] = useState< + (FrontendTrustScoreResult & { isFromFrontend: boolean }) | null + >(null); + const [stakingDetails, setStakingDetails] = useState(null); + const [scoresLoading, setScoresLoading] = useState(false); // Check activity status const checkActivityStatus = useCallback(() => { @@ -75,6 +93,36 @@ export function RewardsSection() { }; }, [checkActivityStatus]); + // Fetch user scores when on scores tab + const fetchUserScores = useCallback(async () => { + if (!address) { + setUserScores(null); + setStakingDetails(null); + return; + } + + setScoresLoading(true); + try { + const [scores, staking] = await Promise.all([ + getAllScoresWithFallback(peopleApi, api, address), + api ? getFrontendStakingScore(api, address) : Promise.resolve(null), + ]); + setUserScores(scores); + setStakingDetails(staking); + } catch (err) { + console.error('Error fetching scores:', err); + } finally { + setScoresLoading(false); + } + }, [api, peopleApi, address]); + + // Fetch scores when tab changes to scores or on initial load + useEffect(() => { + if (activeTab === 'scores' && address) { + fetchUserScores(); + } + }, [activeTab, address, fetchUserScores]); + const handleActivate = () => { hapticNotification('success'); localStorage.setItem(ACTIVITY_STORAGE_KEY, Date.now().toString()); @@ -159,6 +207,7 @@ export function RewardsSection() { {[ { id: 'overview' as const, label: 'Geşbîn' }, { id: 'referrals' as const, label: 'Referral' }, + { id: 'scores' as const, label: 'Puanlar' }, ].map(({ id, label }) => ( + + )} + + )} ); diff --git a/src/version.json b/src/version.json index 1c233fb..098545a 100644 --- a/src/version.json +++ b/src/version.json @@ -1,5 +1,5 @@ { - "version": "1.0.122", - "buildTime": "2026-02-06T17:04:46.943Z", - "buildNumber": 1770397486943 + "version": "1.0.123", + "buildTime": "2026-02-06T22:10:09.600Z", + "buildNumber": 1770415809601 }