mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-11-12 05:37:07 +00:00
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.
781 lines
24 KiB
TypeScript
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`;
|
|
}
|