mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-04-22 04:17:56 +00:00
refactor(scores): remove frontend fallback, read all scores from blockchain
- Remove localStorage staking tracking and manual score calculation - Read trust, referral, tiki, perwerde scores directly from People Chain - Add SubQuery integration for staking reward history display - Align score system with pwap/shared implementation
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pezkuwi-telegram-miniapp",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.184",
|
||||
"type": "module",
|
||||
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
|
||||
"author": "Pezkuwichain Team",
|
||||
|
||||
+136
-288
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Score Systems Integration for Telegram Mini App
|
||||
* Based on pwap/shared/lib/scores.ts
|
||||
* Aligned with pwap/shared/lib/scores.ts
|
||||
*
|
||||
* All scores come from People Chain (people-rpc.pezkuwichain.io)
|
||||
* - Trust Score: pezpallet-trust
|
||||
* - Trust Score: pezpallet-trust (composite, on-chain calculated)
|
||||
* - Referral Score: pezpallet-referral
|
||||
* - Staking Score: pezpallet-staking-score (with frontend fallback)
|
||||
* - Staking Score: pezpallet-staking-score
|
||||
* - Tiki Score: pezpallet-tiki
|
||||
* - Perwerde Score: pezpallet-perwerde
|
||||
*/
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import { calculateReferralScore, getReferralCount } from './referral';
|
||||
|
||||
// ========================================
|
||||
// TYPE DEFINITIONS
|
||||
@@ -21,221 +21,120 @@ export interface UserScores {
|
||||
referralScore: number;
|
||||
stakingScore: number;
|
||||
tikiScore: number;
|
||||
perwerdeScore: number;
|
||||
totalScore: number;
|
||||
isCitizen: boolean;
|
||||
}
|
||||
|
||||
export interface StakingScoreStatus {
|
||||
isTracking: boolean;
|
||||
startBlock: number | null;
|
||||
currentBlock: number;
|
||||
durationBlocks: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STAKING SCORE FRONTEND FALLBACK
|
||||
// TRUST SCORE (pezpallet-trust on People Chain)
|
||||
// ========================================
|
||||
// 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
|
||||
* In newer Substrate versions, ledger is keyed by stash address
|
||||
* Fetch user's trust score from blockchain
|
||||
* Storage: trust.trustScores(address)
|
||||
* This is the composite score calculated on-chain
|
||||
*/
|
||||
export async function fetchRelayStakingDetails(
|
||||
relayApi: ApiPromise,
|
||||
address: string
|
||||
): Promise<{ stakedAmount: bigint; nominationsCount: number } | null> {
|
||||
export async function getTrustScore(peopleApi: ApiPromise, address: string): Promise<number> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(relayApi?.query as any)?.staking) {
|
||||
console.warn('[Staking] staking pallet not found');
|
||||
return null;
|
||||
if (!(peopleApi?.query as any)?.trust?.trustScores) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let stashAddress = address;
|
||||
let active = 0n;
|
||||
|
||||
// In newer Substrate, ledger is keyed by stash address directly
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ledger = await (relayApi.query.staking as any).ledger?.(address);
|
||||
const score = await (peopleApi.query as any).trust.trustScores(address);
|
||||
|
||||
// Check if ledger exists and has data
|
||||
if (ledger && !ledger.isEmpty && !ledger.isNone) {
|
||||
// Ledger might be wrapped in Option
|
||||
const unwrapped = ledger.isSome ? ledger.unwrap() : ledger;
|
||||
const ledgerJson = unwrapped.toJSON() as { active?: string | number; stash?: string };
|
||||
console.warn('[Staking] Ledger found for', address, ':', ledgerJson);
|
||||
active = BigInt(ledgerJson?.active || 0);
|
||||
if (ledgerJson?.stash) {
|
||||
stashAddress = ledgerJson.stash;
|
||||
}
|
||||
} else {
|
||||
// Fallback: check if this is a stash account with a controller
|
||||
// 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();
|
||||
console.warn('[Staking] Address', address, 'is stash, controller:', controller);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ledger = await (relayApi.query.staking as any).ledger?.(controller);
|
||||
if (ledger && !ledger.isEmpty && !ledger.isNone) {
|
||||
const unwrapped = ledger.isSome ? ledger.unwrap() : ledger;
|
||||
const ledgerJson = unwrapped.toJSON() as { active?: string | number };
|
||||
console.warn('[Staking] Ledger from controller:', ledgerJson);
|
||||
active = BigInt(ledgerJson?.active || 0);
|
||||
}
|
||||
} else {
|
||||
console.warn('[Staking] No ledger or bonded found for', address);
|
||||
}
|
||||
if (score.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (active === 0n) {
|
||||
return null;
|
||||
}
|
||||
return Number(score.toString());
|
||||
} catch (error) {
|
||||
console.error('Error fetching trust score:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Get nominations
|
||||
// ========================================
|
||||
// REFERRAL SCORE (pezpallet-referral on People Chain)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Fetch user's referral score based on referral count
|
||||
* Storage: referral.referralCount(address)
|
||||
*/
|
||||
export async function getReferralScore(peopleApi: ApiPromise, address: string): Promise<number> {
|
||||
try {
|
||||
// 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;
|
||||
if (!(peopleApi?.query as any)?.referral?.referralCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const count = await (peopleApi.query as any).referral.referralCount(address);
|
||||
const referralCount = Number(count.toString());
|
||||
|
||||
if (referralCount === 0) return 0;
|
||||
if (referralCount <= 5) return referralCount * 4;
|
||||
if (referralCount <= 20) return 20 + (referralCount - 5) * 2;
|
||||
return 50; // Capped at 50 points
|
||||
} catch (error) {
|
||||
console.error('Error fetching referral score:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STAKING SCORE (pezpallet-staking-score on People Chain)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Check staking score tracking status
|
||||
* Storage: stakingScore.stakingStartBlock(address)
|
||||
*/
|
||||
export async function getStakingScoreStatus(
|
||||
peopleApi: ApiPromise,
|
||||
address: string
|
||||
): Promise<StakingScoreStatus> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(peopleApi?.query as any)?.stakingScore?.stakingStartBlock) {
|
||||
return { isTracking: false, startBlock: null, currentBlock: 0, durationBlocks: 0 };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const startBlockResult = await (peopleApi.query as any).stakingScore.stakingStartBlock(address);
|
||||
const currentBlock = Number((await peopleApi.query.system.number()).toString());
|
||||
|
||||
if (startBlockResult.isEmpty || startBlockResult.isNone) {
|
||||
return { isTracking: false, startBlock: null, currentBlock, durationBlocks: 0 };
|
||||
}
|
||||
|
||||
const startBlock = Number(startBlockResult.toString());
|
||||
const durationBlocks = currentBlock - startBlock;
|
||||
|
||||
console.log(
|
||||
'[Staking] Final result - active:',
|
||||
active.toString(),
|
||||
'nominations:',
|
||||
nominationsCount
|
||||
);
|
||||
return {
|
||||
stakedAmount: active,
|
||||
nominationsCount,
|
||||
isTracking: true,
|
||||
startBlock,
|
||||
currentBlock,
|
||||
durationBlocks,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch relay staking details:', err);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching staking score status:', error);
|
||||
return { isTracking: false, startBlock: null, currentBlock: 0, durationBlocks: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FrontendStakingScoreResult> {
|
||||
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
|
||||
// TIKI SCORE (pezpallet-tiki on People Chain)
|
||||
// ========================================
|
||||
|
||||
export interface TikiInfo {
|
||||
@@ -244,18 +143,6 @@ export interface TikiInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const TIKI_ROLE_SCORES: Record<number, number> = {
|
||||
1: 10, // Basic
|
||||
2: 20, // Bronze
|
||||
3: 30, // Silver
|
||||
4: 40, // Gold
|
||||
5: 50, // Platinum
|
||||
};
|
||||
|
||||
/**
|
||||
* Tiki role name to score mapping
|
||||
* Welati (citizen) is the basic role with score 10
|
||||
*/
|
||||
const TIKI_NAME_SCORES: Record<string, number> = {
|
||||
welati: 10,
|
||||
parlementer: 30,
|
||||
@@ -278,11 +165,9 @@ export async function fetchUserTikis(peopleApi: ApiPromise, address: string): Pr
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(peopleApi?.query as any)?.tiki) {
|
||||
console.warn('[Tiki] tiki pallet not found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try userTikis first (actual storage name)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let result = await (peopleApi.query.tiki as any).userTikis?.(address);
|
||||
|
||||
@@ -293,26 +178,21 @@ export async function fetchUserTikis(peopleApi: ApiPromise, address: string): Pr
|
||||
}
|
||||
|
||||
if (!result || result.isEmpty) {
|
||||
console.warn('[Tiki] No tikis found for', address);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Result is Vec<TikiRole> which are enum variants as strings
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tikis = result.toJSON() as any[];
|
||||
console.warn('[Tiki] Raw tikis for', address, ':', tikis);
|
||||
|
||||
return tikis.map((tiki, index) => {
|
||||
// Tiki can be a string (enum variant name) or object
|
||||
const name = typeof tiki === 'string' ? tiki : tiki.name || tiki.role || 'Unknown';
|
||||
const nameLower = name.toLowerCase();
|
||||
const score = TIKI_NAME_SCORES[nameLower] || 10; // Default to 10 if unknown
|
||||
|
||||
return {
|
||||
roleId: index + 1,
|
||||
level: 1,
|
||||
name: name,
|
||||
score: score,
|
||||
score: TIKI_NAME_SCORES[nameLower] || 10,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -323,25 +203,20 @@ export async function fetchUserTikis(peopleApi: ApiPromise, address: string): Pr
|
||||
|
||||
/**
|
||||
* Calculate tiki score from user's tikis
|
||||
* Uses the score property set during fetch, or looks up by name
|
||||
*/
|
||||
export function calculateTikiScore(tikis: TikiInfo[]): number {
|
||||
if (!tikis.length) return 0;
|
||||
|
||||
// Get highest role score
|
||||
let maxScore = 0;
|
||||
for (const tiki of tikis) {
|
||||
// Use score from tiki if available, otherwise lookup by roleId or name
|
||||
const tikiScore =
|
||||
(tiki as TikiInfo & { score?: number }).score ||
|
||||
TIKI_ROLE_SCORES[tiki.roleId] ||
|
||||
TIKI_NAME_SCORES[tiki.name.toLowerCase()] ||
|
||||
10; // Default welati score
|
||||
10;
|
||||
maxScore = Math.max(maxScore, tikiScore);
|
||||
}
|
||||
|
||||
console.warn('[Tiki] Calculated score:', maxScore, 'from tikis:', tikis);
|
||||
return Math.min(maxScore, 50); // Capped at 50
|
||||
return Math.min(maxScore, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,23 +233,8 @@ export async function getTikiScore(peopleApi: ApiPromise, address: string): Prom
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// TRUST SCORE CALCULATION
|
||||
// CITIZENSHIP & PERWERDE
|
||||
// ========================================
|
||||
// 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)
|
||||
@@ -433,70 +293,52 @@ export async function getPerwerdeScore(
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// COMPREHENSIVE SCORE FETCHING
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Calculate all scores with frontend fallback
|
||||
* Fetch all scores for a user from People Chain
|
||||
* Trust pallet computes composite score on-chain (includes staking, referral, tiki, perwerde)
|
||||
*/
|
||||
export async function getAllScoresWithFallback(
|
||||
export async function getAllScores(
|
||||
peopleApi: ApiPromise | null,
|
||||
relayApi: ApiPromise | null,
|
||||
address: string
|
||||
): Promise<FrontendTrustScoreResult & { isFromFrontend: boolean }> {
|
||||
const emptyResult: FrontendTrustScoreResult & { isFromFrontend: boolean } = {
|
||||
): Promise<UserScores> {
|
||||
const empty: UserScores = {
|
||||
trustScore: 0,
|
||||
stakingScore: 0,
|
||||
referralScore: 0,
|
||||
perwerdeScore: 0,
|
||||
stakingScore: 0,
|
||||
tikiScore: 0,
|
||||
weightedSum: 0,
|
||||
perwerdeScore: 0,
|
||||
totalScore: 0,
|
||||
isCitizen: false,
|
||||
isFromFrontend: true,
|
||||
};
|
||||
|
||||
if (!address) return emptyResult;
|
||||
if (!peopleApi || !address) return empty;
|
||||
|
||||
// Check citizenship status
|
||||
const isCitizen = await checkCitizenshipStatus(peopleApi, address);
|
||||
try {
|
||||
const [trustScore, referralScore, tikiScore, perwerdeScore, isCitizen] = await Promise.all([
|
||||
getTrustScore(peopleApi, address),
|
||||
getReferralScore(peopleApi, address),
|
||||
getTikiScore(peopleApi, address),
|
||||
getPerwerdeScore(peopleApi, address),
|
||||
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);
|
||||
|
||||
// Ger staking 0 be, trust jî 0 be (matches pallet logic)
|
||||
if (stakingScore === 0) {
|
||||
return {
|
||||
...emptyResult,
|
||||
trustScore,
|
||||
referralScore,
|
||||
perwerdeScore,
|
||||
stakingScore: 0, // Trust pallet already includes staking in composite
|
||||
tikiScore,
|
||||
perwerdeScore,
|
||||
totalScore: trustScore, // Trust score = composite score (on-chain calculated)
|
||||
isCitizen,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching scores:', error);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -524,9 +366,15 @@ export function getScoreRating(score: number): string {
|
||||
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);
|
||||
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)} meh`;
|
||||
if (days >= 1) return `${Math.floor(days)} roj`;
|
||||
if (hours >= 1) return `${Math.floor(hours)} saet`;
|
||||
return `${Math.floor(minutes)} deqîqe`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* SubQuery GraphQL Client
|
||||
* Queries the pezkuwi-subquery indexer for historical blockchain data
|
||||
*/
|
||||
|
||||
const SUBQUERY_ENDPOINT = 'https://subquery.pezkuwichain.io';
|
||||
|
||||
const UNITS = 1_000_000_000_000; // 10^12
|
||||
|
||||
// ========================================
|
||||
// TYPES
|
||||
// ========================================
|
||||
|
||||
export interface StakingRewardEntry {
|
||||
id: string;
|
||||
blockNumber: number;
|
||||
timestamp: string;
|
||||
amount: string;
|
||||
accumulatedAmount: string;
|
||||
type: 'REWARD' | 'SLASH';
|
||||
}
|
||||
|
||||
export interface StakingRewardsResult {
|
||||
rewards: StakingRewardEntry[];
|
||||
totalAccumulated: string;
|
||||
totalAccumulatedHez: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// GRAPHQL CLIENT
|
||||
// ========================================
|
||||
|
||||
async function querySubGraph<T>(query: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(SUBQUERY_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('[SubQuery] HTTP error:', res.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.errors) {
|
||||
console.error('[SubQuery] GraphQL errors:', json.errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
return json.data as T;
|
||||
} catch (err) {
|
||||
console.error('[SubQuery] Fetch error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// REWARD QUERIES
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Fetch staking rewards for a specific address
|
||||
*/
|
||||
export async function getStakingRewards(
|
||||
address: string,
|
||||
limit = 20
|
||||
): Promise<StakingRewardsResult> {
|
||||
const empty: StakingRewardsResult = {
|
||||
rewards: [],
|
||||
totalAccumulated: '0',
|
||||
totalAccumulatedHez: 0,
|
||||
};
|
||||
|
||||
if (!address) return empty;
|
||||
|
||||
const data = await querySubGraph<{
|
||||
accountRewards: { nodes: StakingRewardEntry[] };
|
||||
accumulatedReward: { amount: string } | null;
|
||||
}>(`{
|
||||
accountRewards(
|
||||
filter: { address: { equalTo: "${address}" } }
|
||||
orderBy: BLOCK_NUMBER_DESC
|
||||
first: ${limit}
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
blockNumber
|
||||
timestamp
|
||||
amount
|
||||
accumulatedAmount
|
||||
type
|
||||
}
|
||||
}
|
||||
accumulatedReward(id: "${address}") {
|
||||
amount
|
||||
}
|
||||
}`);
|
||||
|
||||
if (!data) return empty;
|
||||
|
||||
const totalAccumulated = data.accumulatedReward?.amount || '0';
|
||||
|
||||
return {
|
||||
rewards: data.accountRewards.nodes,
|
||||
totalAccumulated,
|
||||
totalAccumulatedHez: Number(BigInt(totalAccumulated)) / UNITS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a raw amount (planck) to HEZ display
|
||||
*/
|
||||
export function formatRewardAmount(amount: string): string {
|
||||
const hez = Number(BigInt(amount)) / UNITS;
|
||||
if (hez >= 1000) return `${(hez / 1000).toFixed(2)}K`;
|
||||
if (hez >= 1) return hez.toFixed(4);
|
||||
if (hez >= 0.001) return hez.toFixed(6);
|
||||
return hez.toFixed(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to locale string
|
||||
*/
|
||||
export function formatRewardDate(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('ku', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
+88
-61
@@ -21,6 +21,7 @@ import {
|
||||
Target,
|
||||
Sparkles,
|
||||
GraduationCap,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { cn, formatAddress } from '@/lib/utils';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
@@ -29,14 +30,20 @@ import { useReferral } from '@/contexts/ReferralContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { SocialLinks } from '@/components/SocialLinks';
|
||||
import {
|
||||
getAllScoresWithFallback,
|
||||
getFrontendStakingScore,
|
||||
formatStakedAmount,
|
||||
getAllScores,
|
||||
getStakingScoreStatus,
|
||||
formatDuration,
|
||||
getScoreColor,
|
||||
getScoreRating,
|
||||
type FrontendTrustScoreResult,
|
||||
type FrontendStakingScoreResult,
|
||||
type UserScores,
|
||||
type StakingScoreStatus,
|
||||
} from '@/lib/scores';
|
||||
import {
|
||||
getStakingRewards,
|
||||
formatRewardAmount,
|
||||
formatRewardDate,
|
||||
type StakingRewardsResult,
|
||||
} from '@/lib/subquery';
|
||||
|
||||
// Activity tracking constants
|
||||
const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active';
|
||||
@@ -46,16 +53,15 @@ export function RewardsSection() {
|
||||
const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram();
|
||||
const { user: authUser } = useAuth();
|
||||
const { stats, myReferrals, loading, refreshStats } = useReferral();
|
||||
const { isConnected, address, api, peopleApi } = useWallet();
|
||||
const { isConnected, address, peopleApi } = useWallet();
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'referrals' | 'scores'>('overview');
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
|
||||
const [userScores, setUserScores] = useState<
|
||||
(FrontendTrustScoreResult & { isFromFrontend: boolean }) | null
|
||||
>(null);
|
||||
const [stakingDetails, setStakingDetails] = useState<FrontendStakingScoreResult | null>(null);
|
||||
const [userScores, setUserScores] = useState<UserScores | null>(null);
|
||||
const [stakingStatus, setStakingStatus] = useState<StakingScoreStatus | null>(null);
|
||||
const [stakingRewards, setStakingRewards] = useState<StakingRewardsResult | null>(null);
|
||||
const [scoresLoading, setScoresLoading] = useState(false);
|
||||
|
||||
// Check activity status
|
||||
@@ -84,7 +90,6 @@ export function RewardsSection() {
|
||||
|
||||
// Check activity status on mount and every minute
|
||||
useEffect(() => {
|
||||
// Run check after a microtask to avoid synchronous setState in effect
|
||||
const timeoutId = setTimeout(checkActivityStatus, 0);
|
||||
const interval = setInterval(checkActivityStatus, 60000);
|
||||
return () => {
|
||||
@@ -97,28 +102,27 @@ export function RewardsSection() {
|
||||
const fetchUserScores = useCallback(async () => {
|
||||
if (!address) {
|
||||
setUserScores(null);
|
||||
setStakingDetails(null);
|
||||
setStakingStatus(null);
|
||||
setStakingRewards(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[Scores] Fetching scores for', address);
|
||||
console.warn('[Scores] API connected:', !!api, 'People API:', !!peopleApi);
|
||||
|
||||
setScoresLoading(true);
|
||||
try {
|
||||
const [scores, staking] = await Promise.all([
|
||||
getAllScoresWithFallback(peopleApi, api, address),
|
||||
api ? getFrontendStakingScore(api, address) : Promise.resolve(null),
|
||||
const [scores, staking, rewards] = await Promise.all([
|
||||
getAllScores(peopleApi, address),
|
||||
peopleApi ? getStakingScoreStatus(peopleApi, address) : Promise.resolve(null),
|
||||
getStakingRewards(address),
|
||||
]);
|
||||
console.warn('[Scores] Results:', { scores, staking });
|
||||
setUserScores(scores);
|
||||
setStakingDetails(staking);
|
||||
setStakingStatus(staking);
|
||||
setStakingRewards(rewards);
|
||||
} catch (err) {
|
||||
console.error('Error fetching scores:', err);
|
||||
} finally {
|
||||
setScoresLoading(false);
|
||||
}
|
||||
}, [api, peopleApi, address]);
|
||||
}, [peopleApi, address]);
|
||||
|
||||
// Fetch scores when tab changes to scores or on initial load
|
||||
useEffect(() => {
|
||||
@@ -603,13 +607,16 @@ export function RewardsSection() {
|
||||
<span className="text-xs text-muted-foreground">Staking</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{userScores?.stakingScore ?? 0}
|
||||
{stakingStatus?.isTracking ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDuration(stakingStatus.durationBlocks)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Nehatiye destpêkirin</span>
|
||||
)}
|
||||
</p>
|
||||
{stakingDetails && stakingDetails.stakedAmount > 0n && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatStakedAmount(stakingDetails.stakedAmount)} HEZ
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">Di Trust de tê hesibandin</p>
|
||||
</div>
|
||||
|
||||
{/* Referral Score */}
|
||||
@@ -651,41 +658,61 @@ export function RewardsSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Staking Details Card */}
|
||||
{stakingDetails && stakingDetails.stakedAmount > 0n && (
|
||||
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
|
||||
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-blue-400" />
|
||||
Staking Hûrgelan
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between py-2 border-b border-border/30">
|
||||
<span className="text-muted-foreground">HEZ Staked</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{formatStakedAmount(stakingDetails.stakedAmount)} HEZ
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-border/30">
|
||||
<span className="text-muted-foreground">Demjimêr</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{stakingDetails.monthsStaked} meh
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-border/30">
|
||||
<span className="text-muted-foreground">Pir Zêdeker</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{stakingDetails.timeMultiplier}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-muted-foreground">Nominasyon</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{stakingDetails.nominationsCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Staking Rewards from SubQuery */}
|
||||
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
|
||||
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<Coins className="w-4 h-4 text-amber-400" />
|
||||
Xelatên Staking
|
||||
</h3>
|
||||
|
||||
{/* Total Accumulated */}
|
||||
<div className="bg-amber-500/10 rounded-lg p-3 mb-3 border border-amber-500/20">
|
||||
<p className="text-xs text-amber-300 mb-1">Tevahiya xelatên wergirtî</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{stakingRewards && stakingRewards.totalAccumulatedHez > 0
|
||||
? `${stakingRewards.totalAccumulatedHez.toFixed(4)} HEZ`
|
||||
: '0 HEZ'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Rewards List */}
|
||||
{stakingRewards && stakingRewards.rewards.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground mb-2">Xelatên dawî</p>
|
||||
{stakingRewards.rewards.map((reward) => (
|
||||
<div
|
||||
key={reward.id}
|
||||
className="flex items-center justify-between py-2 border-b border-border/30 last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
reward.type === 'REWARD' ? 'bg-green-400' : 'bg-red-400'
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-foreground">
|
||||
{reward.type === 'REWARD' ? '+' : '-'}
|
||||
{formatRewardAmount(reward.amount)} HEZ
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Block #{reward.blockNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRewardDate(reward.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-3">
|
||||
Hêj xelatek nehatiye tomarkirin
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score Formula Info */}
|
||||
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.0.183",
|
||||
"buildTime": "2026-02-08T02:58:45.175Z",
|
||||
"buildNumber": 1770519525176
|
||||
"version": "1.0.184",
|
||||
"buildTime": "2026-02-13T03:26:27.567Z",
|
||||
"buildNumber": 1770953187568
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user