Files
pwap/shared/lib/scores.ts
T
SatoshiQaziMuhammed 03e6db8461 rewards: claim what the chain pays, without recording a score first (#61)
* 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 three screens offered a Record Trust Score button that signed a call that
does not exist.

The buttons, their handlers and texts, and the messages for an error and a success that can
no longer happen are removed; the claimable amount is worked out as the chain's entitlement
does, and the empty state shows in every epoch status. Past recordTrustScore extrinsics in
the transaction history keep their label.

* backend: body-parser 2.4.0 and ip-address 10.7.5

npm audit on the production dependencies: body-parser 2.0.0-2.3.0 (GHSA-27r3-jxmh-p828,
high, denial of service by trailing bytes after a compressed body) and ip-address <=10.7.3
(GHSA-rxp7-6hxx-x8q5, moderate). Lockfile only; npm audit --omit=dev finds nothing after.

* ops: lower the typecheck baseline to 335

Four TS2339 errors in shared/lib/scores.ts went with the userEpochScores code.

* web: dompurify 3.4.17 and zod 4.6.7

npm audit on the production dependencies: dompurify <=3.4.16 (critical: GHSA-cg2g-xh64-v4q7,
GHSA-mrv2-3pqr-j5jr, GHSA-c6gr-qvv4-5v3f) and zod <4.6.6 (high, GHSA-mrhx-c7xc-m3w5, fixed
only in 4.x). No code imports zod; it is here for abitype, whose peer range is
^3.22.0 || ^4.0.0. Typecheck ratchet, lint and the build are unchanged.
2026-10-26 02:37:20 +04:00

659 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========================================
// Score Systems Integration
// ========================================
// All score pallets live on People Chain:
// - Trust Score: pezpallet-trust (People Chain) - composite score
// - Referral Score: pezpallet-referral (People Chain)
// - Staking Score: pezpallet-staking-score (People Chain) - uses cached staking data from Asset Hub via XCM
// - Tiki Score: pezpallet-tiki (People Chain)
// - Perwerde Score: pezpallet-perwerde (People Chain)
import type { ApiPromise } from '@pezkuwi/api';
import { formatBalance } from './wallet';
// ========================================
// TYPE DEFINITIONS
// ========================================
export interface UserScores {
trustScore: number;
referralScore: number;
stakingScore: number;
tikiScore: number;
totalScore: number;
}
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 EpochRewardPool {
totalRewardPool: string;
totalTrustScore: number;
participantsCount: number;
rewardPerTrustPoint: string;
claimDeadline: number;
}
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)
*/
export async function getTrustScore(
peopleApi: ApiPromise,
address: string
): Promise<number> {
try {
if (!peopleApi?.query?.trust?.trustScores) {
return 0;
}
const score = await peopleApi.query.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)
// ========================================
/**
* Fetch user's referral score from on-chain stats
* Storage: referral.referrerStatsStorage(address) → { totalReferrals, revokedReferrals, penaltyScore }
*
* On-chain tiered scoring (matches pezpallet-referral):
* - 0 referrals: 0 points
* - 1-10 referrals: count × 10 points (max 100)
* - 11-50 referrals: 100 + (count - 10) × 5 points (max 300)
* - 51-100 referrals: 300 + (count - 50) × 4 points (max 500)
* - 101+ referrals: 500 points (maximum)
* Then subtract penalty_score from revoked referrals
*/
export async function getReferralScore(
peopleApi: ApiPromise,
address: string
): Promise<number> {
try {
// Try reading from referrerStatsStorage for full stats with penalties
if (peopleApi?.query?.referral?.referrerStatsStorage) {
const stats = await peopleApi.query.referral.referrerStatsStorage(address);
if (!stats.isEmpty) {
const statsJson = stats.toJSON() as {
totalReferrals?: number;
total_referrals?: number;
revokedReferrals?: number;
revoked_referrals?: number;
penaltyScore?: number;
penalty_score?: number;
};
const totalReferrals = statsJson.totalReferrals ?? statsJson.total_referrals ?? 0;
const revokedReferrals = statsJson.revokedReferrals ?? statsJson.revoked_referrals ?? 0;
const penaltyScore = statsJson.penaltyScore ?? statsJson.penalty_score ?? 0;
// Step 1: Remove revoked referrals from count
const goodReferrals = Math.max(0, totalReferrals - revokedReferrals);
// Step 2: Tiered scoring
let baseScore: number;
if (goodReferrals === 0) baseScore = 0;
else if (goodReferrals <= 10) baseScore = goodReferrals * 10;
else if (goodReferrals <= 50) baseScore = 100 + ((goodReferrals - 10) * 5);
else if (goodReferrals <= 100) baseScore = 300 + ((goodReferrals - 50) * 4);
else baseScore = 500;
// Step 3: Subtract penalty
return Math.max(0, baseScore - penaltyScore);
}
}
// Fallback: simple count-based scoring
if (peopleApi?.query?.referral?.referralCount) {
const count = await peopleApi.query.referral.referralCount(address);
const referralCount = Number(count.toString());
if (referralCount === 0) return 0;
if (referralCount <= 10) return referralCount * 10;
if (referralCount <= 50) return 100 + ((referralCount - 10) * 5);
if (referralCount <= 100) return 300 + ((referralCount - 50) * 4);
return 500;
}
return 0;
} catch (error) {
console.error('Error fetching referral score:', error);
return 0;
}
}
/**
* Get raw referral count
*/
export async function getReferralCount(
peopleApi: ApiPromise,
address: string
): Promise<number> {
try {
if (!peopleApi?.query?.referral?.referralCount) {
return 0;
}
const count = await peopleApi.query.referral.referralCount(address);
return Number(count.toString());
} catch (error) {
console.error('Error fetching referral count:', 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 {
if (!peopleApi?.query?.stakingScore?.stakingStartBlock) {
return empty;
}
const startBlockResult = await peopleApi.query.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);
if (peopleApi.query.stakingScore.cachedStakingDetails) {
try {
const [relayResult, assetHubResult] = await Promise.all([
peopleApi.query.stakingScore.cachedStakingDetails(address, 'RelayChain')
.catch(() => null),
peopleApi.query.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 = 432000; // ~30 days in blocks (6s per block)
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;
}
}
/**
* Start staking score tracking
* Calls: stakingScore.startScoreTracking()
*
* Called on People Chain. No stake requirement - user opts in, then a
* noter-authorized account submits staking data via receive_staking_details().
*/
export async function startScoreTracking(
peopleApi: ApiPromise,
address: string,
signer: any
): Promise<{ success: boolean; error?: string }> {
try {
if (!peopleApi?.tx?.stakingScore?.startScoreTracking) {
return { success: false, error: 'stakingScore pallet not available on this chain' };
}
const tx = peopleApi.tx.stakingScore.startScoreTracking();
return new Promise((resolve) => {
tx.signAndSend(address, { signer }, ({ status, dispatchError }) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
resolve({ success: false, error: `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}` });
} else {
resolve({ success: false, error: dispatchError.toString() });
}
} else {
resolve({ success: true });
}
}
});
});
} catch (error) {
console.error('Error starting score tracking:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
// ========================================
// TIKI SCORE (pezpallet-tiki on People Chain)
// ========================================
import { fetchUserTikis, calculateTikiScore } from './tiki';
/**
* Get tiki score from user's roles
*/
export async function getTikiScore(
peopleApi: ApiPromise,
address: string
): Promise<number> {
try {
const tikis = await fetchUserTikis(peopleApi, address);
return calculateTikiScore(tikis);
} catch (error) {
console.error('Error fetching tiki score:', error);
return 0;
}
}
// ========================================
// 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> {
if (!peopleApi || !address) {
return { trustScore: 0, referralScore: 0, stakingScore: 0, tikiScore: 0, totalScore: 0 };
}
try {
const [trustScore, referralScore, tikiScore, stakingStatus] = await Promise.all([
getTrustScore(peopleApi, address),
getReferralScore(peopleApi, address),
getTikiScore(peopleApi, address),
getStakingScoreStatus(peopleApi, address),
]);
return {
trustScore,
referralScore,
stakingScore: stakingStatus.score,
tikiScore,
totalScore: trustScore,
};
} catch (error) {
console.error('Error fetching scores:', error);
return { trustScore: 0, referralScore: 0, stakingScore: 0, tikiScore: 0, totalScore: 0 };
}
}
// ========================================
// SCORE DISPLAY HELPERS
// ========================================
export function getScoreColor(score: number): string {
if (score >= 200) return 'text-purple-500';
if (score >= 150) return 'text-pink-500';
if (score >= 100) return 'text-blue-500';
if (score >= 70) return 'text-cyan-500';
if (score >= 40) return 'text-teal-500';
if (score >= 20) return 'text-green-500';
return 'text-gray-500';
}
export function getScoreRating(score: number): string {
if (score >= 250) return 'Legendary';
if (score >= 200) return 'Excellent';
if (score >= 150) return 'Very Good';
if (score >= 100) return 'Good';
if (score >= 70) return 'Average';
if (score >= 40) return 'Fair';
if (score >= 20) return 'Low';
return 'Very Low';
}
export function formatScore(score: number): string {
return score.toFixed(0);
}
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)} month${Math.floor(months) > 1 ? 's' : ''}`;
if (days >= 1) return `${Math.floor(days)} day${Math.floor(days) > 1 ? 's' : ''}`;
if (hours >= 1) return `${Math.floor(hours)} hour${Math.floor(hours) > 1 ? 's' : ''}`;
return `${Math.floor(minutes)} minute${Math.floor(minutes) > 1 ? 's' : ''}`;
}
// ========================================
// 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 {
if (!peopleApi.query?.identityKyc?.kycStatuses) {
return false;
}
const status = await peopleApi.query.identityKyc.kycStatuses(address);
const statusStr = status.toString();
// KycLevel::Approved = "Approved" or numeric value 3
return statusStr === 'Approved' || statusStr === '3';
} catch (err) {
console.error('Error checking citizenship status:', err);
return false;
}
}
/**
* Get Perwerde (education) score
* This is from pezpallet-perwerde on People Chain
*
* Queries studentCourses for enrolled course IDs, then checks enrollments
* for completed courses and sums their points.
*/
export async function getPerwerdeScore(
peopleApi: ApiPromise | null,
address: string
): Promise<number> {
if (!peopleApi || !address) return 0;
try {
// Check if perwerde pallet exists
if (!peopleApi.query?.perwerde) {
return 0;
}
// Get user's enrolled course IDs
if (!peopleApi.query.perwerde.studentCourses) {
return 0;
}
const coursesResult = await peopleApi.query.perwerde.studentCourses(address);
const courseIds = coursesResult.toJSON() as number[];
if (!Array.isArray(courseIds) || courseIds.length === 0) {
return 0;
}
// Sum points from completed courses
let totalPoints = 0;
for (const courseId of courseIds) {
try {
if (!peopleApi.query.perwerde.enrollments) break;
const enrollment = await peopleApi.query.perwerde.enrollments([address, courseId]);
if (enrollment.isEmpty || enrollment.isNone) continue;
const enrollmentJson = enrollment.toJSON() as {
completedAt?: number | null;
completed_at?: number | null;
pointsEarned?: number;
points_earned?: number;
};
const completedAt = enrollmentJson.completedAt ?? enrollmentJson.completed_at;
if (completedAt !== null && completedAt !== undefined) {
totalPoints += enrollmentJson.pointsEarned ?? enrollmentJson.points_earned ?? 0;
}
} catch {
// Skip individual enrollment errors
}
}
return totalPoints;
} catch (err) {
console.error('Error fetching perwerde score:', err);
return 0;
}
}
// ========================================
// PEZ REWARDS (pezRewards pallet on People Chain)
// ========================================
/**
* Get PEZ rewards information for an account
* Uses correct storage query names from live People Chain metadata:
* - epochInfo() → epoch info (ValueQuery)
* - epochStatus(epoch) → Open | ClaimPeriod | Closed
* - claimedRewards(epoch, addr) → claimed reward amount (OptionQuery)
* - epochRewardPools(epoch) → reward pool info (OptionQuery)
*/
export async function getPezRewards(
peopleApi: ApiPromise,
address: string
): Promise<PezRewardInfo | null> {
try {
if (!peopleApi?.query?.pezRewards?.epochInfo) {
console.warn('PezRewards pallet not available on People Chain');
return null;
}
// Get current epoch info
const epochInfoResult = await peopleApi.query.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 {
const statusResult = await peopleApi.query.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 if query fails
}
// 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 who = peopleApi.createType('AccountId', address).toString();
const trustScore = BigInt((await peopleApi.query.trust.trustScores(address)).toString());
let seatTakenAt: number | null = null;
try {
const tikis = ((await peopleApi.query.tiki.userTikis(address)).toJSON() ?? []) as string[];
if (tikis.includes('Parlementer')) {
const expiry = (await peopleApi.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 peopleApi.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
}
// Check for claimable rewards from completed epochs
const claimableRewards: { epoch: number; amount: string }[] = [];
let totalClaimable = BigInt(0);
for (let i = Math.max(0, currentEpoch - 3); i < currentEpoch; i++) {
try {
// Check epoch status - only ClaimPeriod epochs are claimable
const pastStatusResult = await peopleApi.query.pezRewards.epochStatus(i);
const pastStatus = pastStatusResult.toString();
if (pastStatus !== 'ClaimPeriod') continue;
// Check if user already claimed
const claimedResult = await peopleApi.query.pezRewards.claimedRewards(i, address);
if (claimedResult.isSome) continue;
// Get epoch reward pool
const epochPoolResult = await peopleApi.query.pezRewards.epochRewardPools(i);
if (!epochPoolResult.isSome) continue;
const epochPoolCodec = epochPoolResult.unwrap() as { toJSON: () => unknown };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const epochPool = epochPoolCodec.toJSON() as any;
const rewardPerPoint = BigInt(epochPool.rewardPerTrustPoint ?? epochPool.reward_per_trust_point ?? '0');
const seatShare = BigInt(epochPool.seatShare ?? epochPool.seat_share ?? '0');
const finalizedAt = Number(epochPool.finalizedAt ?? epochPool.finalized_at ?? 0);
let rewardAmount = trustScore * rewardPerPoint;
if (seatTakenAt !== null && seatTakenAt <= finalizedAt) rewardAmount += seatShare;
if (rewardAmount === BigInt(0)) continue;
claimableRewards.push({ epoch: i, amount: formatBalance(rewardAmount.toString()) });
totalClaimable += rewardAmount;
} catch (err) {
console.warn(`Error checking epoch ${i} rewards:`, err);
}
}
return {
currentEpoch,
epochStatus,
claimableRewards,
totalClaimable: formatBalance(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,
address: string,
epochIndex: number,
signer: any
): Promise<{ success: boolean; error?: string }> {
try {
if (!peopleApi?.tx?.pezRewards?.claimReward) {
return { success: false, error: 'pezRewards pallet not available' };
}
const tx = peopleApi.tx.pezRewards.claimReward(epochIndex);
return new Promise((resolve) => {
tx.signAndSend(address, { signer }, ({ status, dispatchError }: any) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
resolve({ success: false, error: `${decoded.section}.${decoded.name}` });
} else {
resolve({ success: false, error: dispatchError.toString() });
}
} else {
resolve({ success: true });
}
}
});
});
} catch (error) {
console.error('Error claiming PEZ reward:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}