Files
pezkuwi-telegram-miniapp/src/lib/scores.ts
T
Satoshi Qazi Muhammed df9c96b238 rewards: claim what the chain pays, without recording a score first
The People chain has no pezRewards.recordTrustScore and no userEpochScores since the payroll
started holding the roll still through each claim window: a claim pays the trust score at
claim time times the epoch's rate, plus the seat share for a Parlementer seat taken by the
time of the roll. The app still asked for the recorded score, so the claimable list was
always empty, and its Record Trust Score button signed a call that does not exist.

The button, its handler and its texts are removed, and the claimable amount is worked out
as the chain's entitlement does.
2026-10-25 19:32:06 +04:00

781 lines
24 KiB
TypeScript

/**
* Score Systems Integration for Telegram Mini App
* Aligned with pwap/shared/lib/scores.ts
*
* All scores come from People Chain (people-rpc.pezkuwichain.io)
* - Trust Score: pezpallet-trust (composite, on-chain calculated)
* - Referral Score: pezpallet-referral
* - Staking Score: pezpallet-staking-score
* - Tiki Score: pezpallet-tiki
* - Perwerde Score: pezpallet-perwerde
*/
import type { ApiPromise } from '@pezkuwi/api';
import type { KeyringPair } from '@pezkuwi/keyring/types';
// ========================================
// TYPE DEFINITIONS
// ========================================
export interface UserScores {
trustScore: number;
referralScore: number;
stakingScore: number;
tikiScore: number;
perwerdeScore: number;
totalScore: number;
isCitizen: boolean;
}
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 PezRewardInfo {
currentEpoch: number;
epochStatus: EpochStatus;
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)
* This is the composite score calculated on-chain
*/
export async function getTrustScore(peopleApi: ApiPromise, address: string): Promise<number> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(peopleApi?.query as any)?.trust?.trustScores) {
return 0;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const score = await (peopleApi.query as any).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)
// ========================================
function computeReferralBaseScore(goodReferrals: number): number {
if (goodReferrals <= 0) return 0;
if (goodReferrals <= 10) return goodReferrals * 10;
if (goodReferrals <= 50) return 100 + (goodReferrals - 10) * 5;
if (goodReferrals <= 100) return 300 + (goodReferrals - 50) * 4;
return 500;
}
/**
* Fetch user's referral score using the same formula as pezpallet-referral.
* Storage: referral.referrerStatsStorage(address)
*
* Tiered scoring (max 500):
* 0 referrals = 0
* 1-10 = count * 10 (10..100)
* 11-50 = 100 + (count-10) * 5 (105..300)
* 51-100 = 300 + (count-50) * 4 (304..500)
* 101+ = 500
* Minus penalty_score from revocations.
*/
export async function getReferralScore(peopleApi: ApiPromise, address: string): Promise<number> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const queryRef = peopleApi?.query as any;
if (!queryRef?.referral?.referrerStatsStorage) {
// Fallback: use referralCount if stats storage unavailable
if (!queryRef?.referral?.referralCount) return 0;
const count = Number((await queryRef.referral.referralCount(address)).toString());
return computeReferralBaseScore(count);
}
const stats = await queryRef.referral.referrerStatsStorage(address);
if (stats.isEmpty) return 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const json = stats.toJSON() as any;
const total = json.totalReferrals ?? json.total_referrals ?? 0;
const revoked = json.revokedReferrals ?? json.revoked_referrals ?? 0;
const penalty = json.penaltyScore ?? json.penalty_score ?? 0;
const goodReferrals = Math.max(0, total - revoked);
return Math.max(0, computeReferralBaseScore(goodReferrals) - penalty);
} catch (error) {
console.error('Error fetching referral score:', 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<StakingScoreStatus> {
const empty: StakingScoreStatus = {
isTracking: false,
hasCachedData: false,
score: 0,
startBlock: null,
currentBlock: 0,
durationBlocks: 0,
};
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(peopleApi?.query as any)?.stakingScore?.stakingStartBlock) {
return empty;
}
// 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 { ...empty, currentBlock };
}
const startBlock = Number(startBlockResult.toString());
const durationBlocks = currentBlock - startBlock;
// Query CachedStakingDetails for both sources
let hasCachedData = false;
let totalStakeWei = BigInt(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((peopleApi.query as any).stakingScore?.cachedStakingDetails) {
try {
const [relayResult, assetHubResult] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(peopleApi.query as any).stakingScore
.cachedStakingDetails(address, 'RelayChain')
.catch(() => null),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(peopleApi.query as any).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 = 30 * 24 * 60 * 10; // ~432,000 blocks
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;
}
}
// ========================================
// TIKI SCORE (pezpallet-tiki on People Chain)
// ========================================
export interface TikiInfo {
roleId: number;
level: number;
name: string;
}
// Must match pezpallet-tiki get_bonus_for_tiki() exactly
const TIKI_NAME_SCORES: Record<string, number> = {
welati: 10,
parlementer: 100,
serokimeclise: 150,
serok: 200,
wezir: 100,
serokweziran: 125,
weziredarayiye: 100,
wezireparez: 100,
weziredad: 100,
wezirebelaw: 100,
weziretend: 100,
wezireava: 100,
wezirecand: 100,
endamediwane: 175,
dadger: 150,
dozger: 120,
hiquqnas: 75,
xezinedar: 100,
pisporêewlehiyasîber: 100,
mufetîs: 90,
balyoz: 80,
berdevk: 70,
mamoste: 70,
operatorêtorê: 60,
noter: 50,
bacgir: 50,
mela: 50,
feqî: 50,
perwerdekar: 40,
rewsenbîr: 40,
gerinendeyecavkaniye: 40,
gerinendeyedaneye: 40,
kalîtekontrolker: 30,
navbeynkar: 30,
hekem: 30,
qeydkar: 25,
parêzvaneçandî: 25,
sêwirmend: 20,
bazargan: 60,
pêseng: 80,
axa: 250,
rêveberêprojeyê: 250,
moderatorêcivakê: 200,
serokêkomele: 100,
};
/**
* Fetch user's tikis from People Chain
* Storage: tiki.userTikis(address) -> Vec<TikiRole>
*/
export async function fetchUserTikis(peopleApi: ApiPromise, address: string): Promise<TikiInfo[]> {
// The `tiki` pallet is not part of the base @pezkuwi/api typings, so we
// describe just the storage entries we read here.
type TikiStorageResult = { isEmpty: boolean; toJSON(): unknown };
type TikiQuery = {
userTikis?: (address: string) => Promise<TikiStorageResult>;
userRoles?: (address: string) => Promise<TikiStorageResult>;
};
type TikiEntry = string | { name?: string; role?: string };
try {
const tikiQuery = (peopleApi?.query as { tiki?: TikiQuery } | undefined)?.tiki;
if (!tikiQuery) {
return [];
}
let result = await tikiQuery.userTikis?.(address);
// Fallback to userRoles if userTikis doesn't exist
if (!result && tikiQuery.userRoles) {
result = await tikiQuery.userRoles?.(address);
}
if (!result || result.isEmpty) {
return [];
}
const tikis = result.toJSON() as TikiEntry[];
return tikis.map((tiki, index) => {
const name = typeof tiki === 'string' ? tiki : tiki.name || tiki.role || 'Unknown';
const nameLower = name.toLowerCase();
return {
roleId: index + 1,
level: 1,
name: name,
score: TIKI_NAME_SCORES[nameLower] || 10,
};
});
} catch (err) {
console.error('Failed to fetch tiki roles:', err);
return [];
}
}
/**
* Calculate tiki score from user's tikis.
* Must match pallet: sum of all tiki bonuses (NOT max).
*/
export function calculateTikiScore(tikis: TikiInfo[]): number {
if (!tikis.length) return 0;
let totalScore = 0;
for (const tiki of tikis) {
const tikiScore =
(tiki as TikiInfo & { score?: number }).score ||
TIKI_NAME_SCORES[tiki.name.toLowerCase()] ||
10;
totalScore += tikiScore;
}
return totalScore;
}
/**
* Get tiki score for a user
*/
export async function getTikiScore(peopleApi: ApiPromise, address: string): Promise<number> {
try {
const tikis = await fetchUserTikis(peopleApi, address);
return calculateTikiScore(tikis);
} catch (err) {
console.error('Error fetching tiki score:', err);
return 0;
}
}
// ========================================
// CITIZENSHIP & PERWERDE
// ========================================
/**
* Check if user is a citizen (KYC approved)
*/
export async function checkCitizenshipStatus(
peopleApi: ApiPromise | null,
address: string
): Promise<boolean> {
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<number> {
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;
}
}
// ========================================
// SCORE TRACKING & RECORDING (People Chain Extrinsics)
// ========================================
/**
* Start staking score tracking on People Chain.
* Calls stakingScore.startScoreTracking() - no arguments.
* This enables the noter to cache staking details.
*/
export async function startScoreTracking(
peopleApi: ApiPromise,
keypair: KeyringPair
): Promise<{ success: boolean; error?: string }> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = peopleApi.tx as any;
if (!tx?.stakingScore?.startScoreTracking) {
return { success: false, error: 'stakingScore pallet not available' };
}
const result = await new Promise<{ success: boolean; error?: string }>((resolve) => {
tx.stakingScore
.startScoreTracking()
.signAndSend(
keypair,
{ nonce: -1 },
({
status,
dispatchError,
}: {
status: {
isInBlock: boolean;
isFinalized: boolean;
};
dispatchError?: { isModule: boolean; asModule: unknown; toString: () => string };
}) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'startScoreTracking failed';
if (dispatchError.isModule) {
const decoded = peopleApi.registry.findMetaError(
dispatchError.asModule as Parameters<typeof peopleApi.registry.findMetaError>[0]
);
errorMessage = `${decoded.section}.${decoded.name}`;
}
resolve({ success: false, error: errorMessage });
return;
}
resolve({ success: true });
}
}
)
.catch((error: Error) => resolve({ success: false, error: error.message }));
});
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ========================================
// PEZ REWARDS (pezRewards pallet on People Chain)
// ========================================
function formatBalancePlanck(planck: string): string {
const num = BigInt(planck);
const whole = num / BigInt(10 ** 12);
const frac = num % BigInt(10 ** 12);
const fracStr = frac.toString().padStart(12, '0').slice(0, 4);
return `${whole}.${fracStr}`;
}
/**
* Get PEZ rewards information for an account
* Queries pezRewards pallet on People Chain
*/
export async function getPezRewards(
peopleApi: ApiPromise,
address: string
): Promise<PezRewardInfo | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(peopleApi?.query as any)?.pezRewards?.epochInfo) {
console.warn('PezRewards pallet not available on People Chain');
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const epochInfoResult = await (peopleApi.query as any).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 {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const statusResult = await (peopleApi.query as any).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
}
// What a claim pays, worked out the way the chain does (`pezRewards::entitlement`). There
// is no score to record beforehand: the roll is held still from an epoch's finalisation
// to the end of its claim window, so the trust score now is the one the rate was set
// against, and a seat is paid if it is held now and was taken by the time of the roll.
const claimableRewards: { epoch: number; amount: string }[] = [];
let totalClaimable = BigInt(0);
const who = peopleApi.createType('AccountId', address).toString();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const query = peopleApi.query as any;
const trustScore = BigInt((await query.trust.trustScores(address)).toString());
// Seat: the Parlementer tiki, not expired, and the date the seat was taken.
let seatTakenAt: number | null = null;
try {
const tikis = ((await query.tiki.userTikis(address)).toJSON() ?? []) as string[];
if (tikis.includes('Parlementer')) {
const expiry = (await query.tiki.tikiExpiry(address, 'Parlementer')).toJSON() as
| number
| null;
const now = (await peopleApi.rpc.chain.getHeader()).number.toNumber();
if (expiry === null || now <= expiry) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const members = (await query.welati.parliamentMembers()).toJSON() as any[];
const member = members.find(
(m) => peopleApi.createType('AccountId', m.account).toString() === who
);
if (member) seatTakenAt = Number(member.electedAt ?? member.elected_at);
}
}
} catch {
// No seat
}
for (let i = Math.max(0, currentEpoch - 3); i < currentEpoch; i++) {
try {
const pastStatus = (await query.pezRewards.epochStatus(i)).toString();
if (pastStatus !== 'ClaimPeriod') continue;
const claimedResult = await query.pezRewards.claimedRewards(i, address);
if (claimedResult.isSome) continue;
const epochPoolResult = await query.pezRewards.epochRewardPools(i);
if (!epochPoolResult.isSome) continue;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pool = (epochPoolResult.unwrap() as any).toJSON();
const rewardPerPoint = BigInt(
pool.rewardPerTrustPoint ?? pool.reward_per_trust_point ?? '0'
);
const seatShare = BigInt(pool.seatShare ?? pool.seat_share ?? '0');
const finalizedAt = Number(pool.finalizedAt ?? pool.finalized_at ?? 0);
let rewardAmount = trustScore * rewardPerPoint;
if (seatTakenAt !== null && seatTakenAt <= finalizedAt) rewardAmount += seatShare;
if (rewardAmount === BigInt(0)) continue;
claimableRewards.push({ epoch: i, amount: formatBalancePlanck(rewardAmount.toString()) });
totalClaimable += rewardAmount;
} catch (err) {
console.warn(`Error checking epoch ${i} rewards:`, err);
}
}
return {
currentEpoch,
epochStatus,
claimableRewards,
totalClaimable: formatBalancePlanck(totalClaimable.toString()),
hasPendingClaim: claimableRewards.length > 0,
};
} catch (error) {
console.warn('PEZ rewards not available:', error);
return null;
}
}
/**
* Claim PEZ reward for a specific epoch
* Calls pezRewards.claimReward(epochIndex)
*/
export async function claimPezReward(
peopleApi: ApiPromise,
keypair: KeyringPair,
epochIndex: number
): Promise<{ success: boolean; error?: string }> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = peopleApi.tx as any;
if (!tx?.pezRewards?.claimReward) {
return { success: false, error: 'pezRewards pallet not available' };
}
const result = await new Promise<{ success: boolean; error?: string }>((resolve) => {
tx.pezRewards
.claimReward(epochIndex)
.signAndSend(
keypair,
{ nonce: -1 },
({
status,
dispatchError,
}: {
status: {
isInBlock: boolean;
isFinalized: boolean;
};
dispatchError?: { isModule: boolean; asModule: unknown; toString: () => string };
}) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'claimReward failed';
if (dispatchError.isModule) {
const decoded = peopleApi.registry.findMetaError(
dispatchError.asModule as Parameters<typeof peopleApi.registry.findMetaError>[0]
);
errorMessage = `${decoded.section}.${decoded.name}`;
}
resolve({ success: false, error: errorMessage });
return;
}
resolve({ success: true });
}
}
)
.catch((error: Error) => resolve({ success: false, error: error.message }));
});
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ========================================
// 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<UserScores> {
const empty: UserScores = {
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
perwerdeScore: 0,
totalScore: 0,
isCitizen: false,
};
if (!peopleApi || !address) return empty;
try {
const [trustScore, referralScore, tikiScore, perwerdeScore, isCitizen, stakingStatus] =
await Promise.all([
getTrustScore(peopleApi, address),
getReferralScore(peopleApi, address),
getTikiScore(peopleApi, address),
getPerwerdeScore(peopleApi, address),
checkCitizenshipStatus(peopleApi, address),
getStakingScoreStatus(peopleApi, address),
]);
return {
trustScore,
referralScore,
stakingScore: stakingStatus.score,
tikiScore,
perwerdeScore,
totalScore: trustScore,
isCitizen,
};
} catch (error) {
console.error('Error fetching scores:', error);
return empty;
}
}
// ========================================
// 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 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`;
}