Reorganize repository into monorepo structure

Restructured the project to support multiple frontend applications:
- Move web app to web/ directory
- Create pezkuwi-sdk-ui/ for Polkadot SDK clone (planned)
- Create mobile/ directory for mobile app development
- Add shared/ directory with common utilities, types, and blockchain code
- Update README.md with comprehensive documentation
- Remove obsolete DKSweb/ directory

This monorepo structure enables better code sharing and organized
development across web, mobile, and SDK UI projects.
This commit is contained in:
Claude
2025-11-14 00:46:35 +00:00
parent d66e46034a
commit 24be8d4411
206 changed files with 502 additions and 4 deletions
+404
View File
@@ -0,0 +1,404 @@
// ========================================
// Citizenship Crypto Utilities
// ========================================
// Handles encryption, hashing, signatures for citizenship data
import { web3FromAddress } from '@polkadot/extension-dapp';
import { stringToHex, hexToU8a, u8aToHex, stringToU8a } from '@polkadot/util';
import { decodeAddress, signatureVerify, cryptoWaitReady } from '@polkadot/util-crypto';
import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types';
import type { CitizenshipData } from './citizenship-workflow';
// ========================================
// HASHING FUNCTIONS
// ========================================
/**
* Generate SHA-256 hash from data
*/
export async function generateHash(data: string): Promise<string> {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return `0x${hashHex}`;
}
/**
* Generate commitment hash from citizenship data
*/
export async function generateCommitmentHash(
data: CitizenshipData
): Promise<string> {
const dataString = JSON.stringify({
fullName: data.fullName,
fatherName: data.fatherName,
grandfatherName: data.grandfatherName,
motherName: data.motherName,
tribe: data.tribe,
maritalStatus: data.maritalStatus,
childrenCount: data.childrenCount,
children: data.children,
region: data.region,
email: data.email,
profession: data.profession,
referralCode: data.referralCode,
walletAddress: data.walletAddress,
timestamp: data.timestamp
});
return generateHash(dataString);
}
/**
* Generate nullifier hash (prevents double-registration)
*/
export async function generateNullifierHash(
walletAddress: string,
timestamp: number
): Promise<string> {
const nullifierData = `${walletAddress}-${timestamp}-nullifier`;
return generateHash(nullifierData);
}
// ========================================
// ENCRYPTION / DECRYPTION (AES-GCM)
// ========================================
/**
* Derive encryption key from wallet address
* NOTE: For MVP, we use a deterministic key. For production, use proper key derivation
*/
async function deriveEncryptionKey(walletAddress: string): Promise<CryptoKey> {
// Create a deterministic seed from wallet address
const seed = await generateHash(walletAddress);
// Convert hex to ArrayBuffer
const keyMaterial = hexToU8a(seed).slice(0, 32); // 256-bit key
// Import as AES-GCM key
return crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypt citizenship data
*/
export async function encryptData(
data: CitizenshipData,
walletAddress: string
): Promise<string> {
try {
const key = await deriveEncryptionKey(walletAddress);
// Generate random IV (Initialization Vector)
const iv = crypto.getRandomValues(new Uint8Array(12));
// Encrypt data
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(JSON.stringify(data));
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
dataBuffer
);
// Combine IV + encrypted data
const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encryptedBuffer), iv.length);
// Convert to hex
return u8aToHex(combined);
} catch (error) {
console.error('Encryption error:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Decrypt citizenship data
*/
export async function decryptData(
encryptedHex: string,
walletAddress: string
): Promise<CitizenshipData> {
try {
const key = await deriveEncryptionKey(walletAddress);
// Convert hex to Uint8Array
const combined = hexToU8a(encryptedHex);
// Extract IV and encrypted data
const iv = combined.slice(0, 12);
const encryptedData = combined.slice(12);
// Decrypt
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
encryptedData
);
// Convert to string and parse JSON
const decoder = new TextDecoder();
const decryptedString = decoder.decode(decryptedBuffer);
return JSON.parse(decryptedString) as CitizenshipData;
} catch (error) {
console.error('Decryption error:', error);
throw new Error('Failed to decrypt data');
}
}
// ========================================
// SIGNATURE GENERATION & VERIFICATION
// ========================================
export interface AuthChallenge {
nonce: string; // Random UUID
timestamp: number; // Current timestamp
tikiNumber: string; // NFT number to prove
expiresAt: number; // Expiry timestamp (5 min)
}
/**
* Generate authentication challenge
*/
export function generateAuthChallenge(tikiNumber: string): AuthChallenge {
const now = Date.now();
const nonce = crypto.randomUUID();
return {
nonce,
timestamp: now,
tikiNumber,
expiresAt: now + (5 * 60 * 1000) // 5 minutes
};
}
/**
* Format challenge message for signing
*/
export function formatChallengeMessage(challenge: AuthChallenge): string {
return `Prove ownership of Welati Tiki #${challenge.tikiNumber}
Nonce: ${challenge.nonce}
Timestamp: ${challenge.timestamp}
Expires: ${new Date(challenge.expiresAt).toISOString()}
By signing this message, you prove you control the wallet that owns this Tiki NFT.`;
}
/**
* Sign authentication challenge with wallet
*/
export async function signChallenge(
account: InjectedAccountWithMeta,
challenge: AuthChallenge
): Promise<string> {
try {
await cryptoWaitReady();
const injector = await web3FromAddress(account.address);
const signRaw = injector?.signer?.signRaw;
if (!signRaw) {
throw new Error('Signer not available');
}
const message = formatChallengeMessage(challenge);
const { signature } = await signRaw({
address: account.address,
data: stringToHex(message),
type: 'bytes'
});
return signature;
} catch (error) {
console.error('Signature error:', error);
throw new Error('Failed to sign challenge');
}
}
/**
* Verify signature
*/
export async function verifySignature(
signature: string,
challenge: AuthChallenge,
expectedAddress: string
): Promise<boolean> {
try {
await cryptoWaitReady();
// Check if challenge has expired
if (Date.now() > challenge.expiresAt) {
console.warn('Challenge has expired');
return false;
}
const message = formatChallengeMessage(challenge);
const messageU8a = stringToU8a(message);
const signatureU8a = hexToU8a(signature);
const publicKey = decodeAddress(expectedAddress);
const result = signatureVerify(messageU8a, signatureU8a, publicKey);
return result.isValid;
} catch (error) {
console.error('Verification error:', error);
return false;
}
}
// ========================================
// LOCAL STORAGE UTILITIES
// ========================================
const STORAGE_KEY_PREFIX = 'pezkuwi_citizen_';
export interface CitizenSession {
tikiNumber: string;
walletAddress: string;
sessionToken: string; // JWT-like token
encryptedDataCID?: string; // IPFS CID
lastAuthenticated: number; // Timestamp
expiresAt: number; // Session expiry (24h)
}
/**
* Save encrypted citizen session to localStorage
*/
export async function saveCitizenSession(session: CitizenSession): Promise<void> {
try {
const sessionJson = JSON.stringify(session);
const sessionKey = `${STORAGE_KEY_PREFIX}session`;
// For MVP, store plainly. For production, encrypt with device key
localStorage.setItem(sessionKey, sessionJson);
} catch (error) {
console.error('Error saving session:', error);
throw new Error('Failed to save session');
}
}
/**
* Load citizen session from localStorage
*/
export function loadCitizenSession(): CitizenSession | null {
try {
const sessionKey = `${STORAGE_KEY_PREFIX}session`;
const sessionJson = localStorage.getItem(sessionKey);
if (!sessionJson) {
return null;
}
const session = JSON.parse(sessionJson) as CitizenSession;
// Check if session has expired
if (Date.now() > session.expiresAt) {
clearCitizenSession();
return null;
}
return session;
} catch (error) {
console.error('Error loading session:', error);
return null;
}
}
/**
* Clear citizen session from localStorage
*/
export function clearCitizenSession(): void {
try {
const sessionKey = `${STORAGE_KEY_PREFIX}session`;
localStorage.removeItem(sessionKey);
} catch (error) {
console.error('Error clearing session:', error);
}
}
/**
* Save encrypted citizenship data to localStorage (backup)
*/
export async function saveLocalCitizenshipData(
data: CitizenshipData,
walletAddress: string
): Promise<void> {
try {
const encrypted = await encryptData(data, walletAddress);
const dataKey = `${STORAGE_KEY_PREFIX}data_${walletAddress}`;
localStorage.setItem(dataKey, encrypted);
} catch (error) {
console.error('Error saving citizenship data:', error);
throw new Error('Failed to save citizenship data');
}
}
/**
* Load encrypted citizenship data from localStorage
*/
export async function loadLocalCitizenshipData(
walletAddress: string
): Promise<CitizenshipData | null> {
try {
const dataKey = `${STORAGE_KEY_PREFIX}data_${walletAddress}`;
const encrypted = localStorage.getItem(dataKey);
if (!encrypted) {
return null;
}
return await decryptData(encrypted, walletAddress);
} catch (error) {
console.error('Error loading citizenship data:', error);
return null;
}
}
// ========================================
// IPFS UTILITIES (Placeholder)
// ========================================
/**
* Upload encrypted data to IPFS
* NOTE: This is a placeholder. Implement with actual IPFS client (Pinata, Web3.Storage, etc.)
*/
export async function uploadToIPFS(encryptedData: string): Promise<string> {
// TODO: Implement actual IPFS upload
// For MVP, we can use Pinata API or Web3.Storage
console.warn('IPFS upload not yet implemented. Using mock CID.');
// Mock CID for development
const mockCid = `Qm${Math.random().toString(36).substring(2, 15)}`;
return mockCid;
}
/**
* Fetch encrypted data from IPFS
* NOTE: This is a placeholder. Implement with actual IPFS client
*/
export async function fetchFromIPFS(cid: string): Promise<string> {
// TODO: Implement actual IPFS fetch
// For MVP, use public IPFS gateways or dedicated service
console.warn('IPFS fetch not yet implemented. Returning mock data.');
// Mock encrypted data
return '0x000000000000000000000000';
}
+624
View File
@@ -0,0 +1,624 @@
// ========================================
// Citizenship Workflow Library
// ========================================
// Handles citizenship verification, status checks, and workflow logic
import type { ApiPromise } from '@polkadot/api';
import { web3FromAddress } from '@polkadot/extension-dapp';
import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types';
// ========================================
// TYPE DEFINITIONS
// ========================================
export type KycStatus = 'NotStarted' | 'Pending' | 'Approved' | 'Rejected';
export type Region =
| 'bakur' // North (Turkey)
| 'basur' // South (Iraq)
| 'rojava' // West (Syria)
| 'rojhelat' // East (Iran)
| 'diaspora' // Diaspora
| 'kurdistan_a_sor'; // Red Kurdistan (Armenia/Azerbaijan)
export type MaritalStatus = 'zewici' | 'nezewici'; // Married / Unmarried
export interface ChildInfo {
name: string;
birthYear: number;
}
export interface CitizenshipData {
// Personal Identity
fullName: string;
fatherName: string;
grandfatherName: string;
motherName: string;
// Tribal Affiliation
tribe: string;
// Family Status
maritalStatus: MaritalStatus;
childrenCount?: number;
children?: ChildInfo[];
// Geographic Origin
region: Region;
// Contact & Profession
email: string;
profession: string;
// Referral
referralCode?: string;
// Metadata
walletAddress: string;
timestamp: number;
}
export interface CitizenshipCommitment {
commitmentHash: string; // SHA256 hash of all data
nullifierHash: string; // Prevents double-registration
ipfsCid: string; // IPFS CID of encrypted data
publicKey: string; // User's encryption public key
timestamp: number;
}
export interface TikiInfo {
id: string;
role: string;
metadata?: any;
}
export interface CitizenshipStatus {
kycStatus: KycStatus;
hasCitizenTiki: boolean;
tikiNumber?: string;
stakingScoreTracking: boolean;
ipfsCid?: string;
nextAction: 'APPLY_KYC' | 'CLAIM_TIKI' | 'START_TRACKING' | 'COMPLETE';
}
// ========================================
// KYC STATUS CHECKS
// ========================================
/**
* Get KYC status for a wallet address
*/
export async function getKycStatus(
api: ApiPromise,
address: string
): Promise<KycStatus> {
try {
if (!api?.query?.identityKyc) {
console.warn('Identity KYC pallet not available');
return 'NotStarted';
}
const status = await api.query.identityKyc.kycStatuses(address);
if (status.isEmpty) {
return 'NotStarted';
}
const statusStr = status.toString();
// Map on-chain status to our type
if (statusStr === 'Approved') return 'Approved';
if (statusStr === 'Pending') return 'Pending';
if (statusStr === 'Rejected') return 'Rejected';
return 'NotStarted';
} catch (error) {
console.error('Error fetching KYC status:', error);
return 'NotStarted';
}
}
/**
* Check if user has pending KYC application
*/
export async function hasPendingApplication(
api: ApiPromise,
address: string
): Promise<boolean> {
try {
if (!api?.query?.identityKyc?.pendingKycApplications) {
return false;
}
const application = await api.query.identityKyc.pendingKycApplications(address);
return !application.isEmpty;
} catch (error) {
console.error('Error checking pending application:', error);
return false;
}
}
// ========================================
// TIKI / CITIZENSHIP CHECKS
// ========================================
/**
* Get all Tiki roles for a user
*/
// Tiki enum mapping from pallet-tiki
const TIKI_ROLES = [
'Hemwelatî', 'Parlementer', 'SerokiMeclise', 'Serok', 'Wezir', 'EndameDiwane', 'Dadger',
'Dozger', 'Hiquqnas', 'Noter', 'Xezinedar', 'Bacgir', 'GerinendeyeCavkaniye', 'OperatorêTorê',
'PisporêEwlehiyaSîber', 'GerinendeyeDaneye', 'Berdevk', 'Qeydkar', 'Balyoz', 'Navbeynkar',
'ParêzvaneÇandî', 'Mufetîs', 'KalîteKontrolker', 'Mela', 'Feqî', 'Perwerdekar', 'Rewsenbîr',
'RêveberêProjeyê', 'SerokêKomele', 'ModeratorêCivakê', 'Axa', 'Pêseng', 'Sêwirmend', 'Hekem', 'Mamoste',
'Bazargan',
'SerokWeziran', 'WezireDarayiye', 'WezireParez', 'WezireDad', 'WezireBelaw', 'WezireTend', 'WezireAva', 'WezireCand'
];
export async function getUserTikis(
api: ApiPromise,
address: string
): Promise<TikiInfo[]> {
try {
if (!api?.query?.tiki?.userTikis) {
console.warn('Tiki pallet not available');
return [];
}
const tikis = await api.query.tiki.userTikis(address);
if (tikis.isEmpty) {
return [];
}
// userTikis returns a BoundedVec of Tiki enum values (as indices)
const tikiIndices = tikis.toJSON() as number[];
return tikiIndices.map((index, i) => ({
id: `${index}`,
role: TIKI_ROLES[index] || `Unknown Role (${index})`,
metadata: {}
}));
} catch (error) {
console.error('Error fetching user tikis:', error);
return [];
}
}
/**
* Check if user has Welati (Citizen) Tiki
* Backend checks for "Hemwelatî" (actual blockchain role name)
*/
export async function hasCitizenTiki(
api: ApiPromise,
address: string
): Promise<{ hasTiki: boolean; tikiNumber?: string }> {
try {
const tikis = await getUserTikis(api, address);
const citizenTiki = tikis.find(t =>
t.role.toLowerCase() === 'hemwelatî' ||
t.role.toLowerCase() === 'welati' ||
t.role.toLowerCase() === 'citizen'
);
return {
hasTiki: !!citizenTiki,
tikiNumber: citizenTiki?.id
};
} catch (error) {
console.error('Error checking citizen tiki:', error);
return { hasTiki: false };
}
}
/**
* Verify NFT ownership by NFT number
*/
export async function verifyNftOwnership(
api: ApiPromise,
nftNumber: string,
walletAddress: string
): Promise<boolean> {
try {
const tikis = await getUserTikis(api, walletAddress);
return tikis.some(tiki =>
tiki.id === nftNumber &&
(
tiki.role.toLowerCase() === 'hemwelatî' ||
tiki.role.toLowerCase() === 'welati' ||
tiki.role.toLowerCase() === 'citizen'
)
);
} catch (error) {
console.error('Error verifying NFT ownership:', error);
return false;
}
}
// ========================================
// STAKING SCORE TRACKING
// ========================================
/**
* Check if staking score tracking has been started
*/
export async function isStakingScoreTracking(
api: ApiPromise,
address: string
): Promise<boolean> {
try {
if (!api?.query?.stakingScore?.stakingStartBlock) {
console.warn('Staking score pallet not available');
return false;
}
const startBlock = await api.query.stakingScore.stakingStartBlock(address);
return !startBlock.isNone;
} catch (error) {
console.error('Error checking staking score tracking:', error);
return false;
}
}
/**
* Check if user is staking
*/
export async function isStaking(
api: ApiPromise,
address: string
): Promise<boolean> {
try {
if (!api?.query?.staking?.ledger) {
return false;
}
const ledger = await api.query.staking.ledger(address);
return !ledger.isNone;
} catch (error) {
console.error('Error checking staking status:', error);
return false;
}
}
// ========================================
// COMPREHENSIVE CITIZENSHIP STATUS
// ========================================
/**
* Get complete citizenship status and next action needed
*/
export async function getCitizenshipStatus(
api: ApiPromise,
address: string
): Promise<CitizenshipStatus> {
try {
if (!api || !address) {
return {
kycStatus: 'NotStarted',
hasCitizenTiki: false,
stakingScoreTracking: false,
nextAction: 'APPLY_KYC'
};
}
// Fetch all status in parallel
const [kycStatus, citizenCheck, stakingTracking] = await Promise.all([
getKycStatus(api, address),
hasCitizenTiki(api, address),
isStakingScoreTracking(api, address)
]);
const kycApproved = kycStatus === 'Approved';
const hasTiki = citizenCheck.hasTiki;
// Determine next action
let nextAction: CitizenshipStatus['nextAction'];
if (!kycApproved) {
nextAction = 'APPLY_KYC';
} else if (!hasTiki) {
nextAction = 'CLAIM_TIKI';
} else if (!stakingTracking) {
nextAction = 'START_TRACKING';
} else {
nextAction = 'COMPLETE';
}
return {
kycStatus,
hasCitizenTiki: hasTiki,
tikiNumber: citizenCheck.tikiNumber,
stakingScoreTracking: stakingTracking,
nextAction
};
} catch (error) {
console.error('Error fetching citizenship status:', error);
return {
kycStatus: 'NotStarted',
hasCitizenTiki: false,
stakingScoreTracking: false,
nextAction: 'APPLY_KYC'
};
}
}
// ========================================
// IPFS COMMITMENT RETRIEVAL
// ========================================
/**
* Get IPFS CID for citizen data
*/
export async function getCitizenDataCid(
api: ApiPromise,
address: string
): Promise<string | null> {
try {
if (!api?.query?.identityKyc?.identities) {
return null;
}
// Try to get from identity storage
// This assumes the pallet stores IPFS CID somewhere
// Adjust based on actual pallet storage structure
const identity = await api.query.identityKyc.identities(address);
if (identity.isNone) {
return null;
}
const identityData = identity.unwrap().toJSON() as any;
// Try different possible field names
return identityData.ipfsCid ||
identityData.cid ||
identityData.dataCid ||
null;
} catch (error) {
console.error('Error fetching citizen data CID:', error);
return null;
}
}
// ========================================
// REFERRAL VALIDATION
// ========================================
/**
* Validate referral code
*/
export async function validateReferralCode(
api: ApiPromise,
referralCode: string
): Promise<boolean> {
try {
if (!referralCode || referralCode.trim() === '') {
return true; // Empty is valid (will use founder)
}
// Check if referral code exists in trust pallet
if (!api?.query?.trust?.referrals) {
return false;
}
// Referral code could be an address or custom code
// For now, check if it's a valid address format
// TODO: Implement proper referral code lookup
return referralCode.length > 0;
} catch (error) {
console.error('Error validating referral code:', error);
return false;
}
}
// ========================================
// BLOCKCHAIN TRANSACTIONS
// ========================================
/**
* Submit KYC application to blockchain
* This is a two-step process:
* 1. Set identity (name, email)
* 2. Apply for KYC (IPFS CID, notes)
*/
export async function submitKycApplication(
api: ApiPromise,
account: InjectedAccountWithMeta,
name: string,
email: string,
ipfsCid: string,
notes: string = 'Citizenship application'
): Promise<{ success: boolean; error?: string; blockHash?: string }> {
try {
if (!api?.tx?.identityKyc?.setIdentity || !api?.tx?.identityKyc?.applyForKyc) {
return { success: false, error: 'Identity KYC pallet not available' };
}
// Check if user already has a pending KYC application
const pendingApp = await api.query.identityKyc.pendingKycApplications(account.address);
if (!pendingApp.isEmpty) {
console.log('⚠️ User already has a pending KYC application');
return {
success: false,
error: 'You already have a pending citizenship application. Please wait for approval.'
};
}
// Check if user is already approved
const kycStatus = await api.query.identityKyc.kycStatuses(account.address);
if (kycStatus.toString() === 'Approved') {
console.log('✅ User KYC is already approved');
return {
success: false,
error: 'Your citizenship application is already approved!'
};
}
// Get the injector for signing
const injector = await web3FromAddress(account.address);
// Debug logging
console.log('=== submitKycApplication Debug ===');
console.log('account.address:', account.address);
console.log('name:', name);
console.log('email:', email);
console.log('ipfsCid:', ipfsCid);
console.log('notes:', notes);
console.log('===================================');
// Ensure ipfsCid is a string
const cidString = String(ipfsCid);
if (!cidString || cidString === 'undefined' || cidString === '[object Object]') {
return { success: false, error: `Invalid IPFS CID received: ${cidString}` };
}
// Step 1: Set identity first
console.log('Step 1: Setting identity...');
const identityResult = await new Promise<{ success: boolean; error?: string }>((resolve, reject) => {
api.tx.identityKyc
.setIdentity(name, email)
.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError, events }) => {
console.log('Identity transaction status:', status.type);
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Identity transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else {
errorMessage = dispatchError.toString();
}
console.error('Identity transaction error:', errorMessage);
resolve({ success: false, error: errorMessage });
return;
}
// Check for IdentitySet event
const identitySetEvent = events.find(({ event }) =>
event.section === 'identityKyc' && event.method === 'IdentitySet'
);
if (identitySetEvent) {
console.log('✅ Identity set successfully');
resolve({ success: true });
} else {
resolve({ success: true }); // Still consider it success if in block
}
}
})
.catch((error) => {
console.error('Failed to sign and send identity transaction:', error);
reject(error);
});
});
if (!identityResult.success) {
return identityResult;
}
// Step 2: Apply for KYC
console.log('Step 2: Applying for KYC...');
const result = await new Promise<{ success: boolean; error?: string; blockHash?: string }>((resolve, reject) => {
api.tx.identityKyc
.applyForKyc([cidString], notes)
.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError, events }) => {
console.log('Transaction status:', status.type);
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else {
errorMessage = dispatchError.toString();
}
console.error('Transaction error:', errorMessage);
resolve({ success: false, error: errorMessage });
return;
}
// Check for KycApplied event
const kycAppliedEvent = events.find(({ event }) =>
event.section === 'identityKyc' && event.method === 'KycApplied'
);
if (kycAppliedEvent) {
console.log('✅ KYC Application submitted successfully');
resolve({
success: true,
blockHash: status.asInBlock.toString()
});
} else {
console.warn('Transaction included but KycApplied event not found');
resolve({ success: true });
}
}
})
.catch((error) => {
console.error('Failed to sign and send transaction:', error);
reject(error);
});
});
return result;
} catch (error: any) {
console.error('Error submitting KYC application:', error);
return {
success: false,
error: error.message || 'Failed to submit KYC application'
};
}
}
/**
* Subscribe to KYC approval events for an address
*/
export function subscribeToKycApproval(
api: ApiPromise,
address: string,
onApproved: () => void,
onError?: (error: string) => void
): () => void {
try {
if (!api?.query?.system?.events) {
console.error('Cannot subscribe to events: system.events not available');
if (onError) onError('Event subscription not available');
return () => {};
}
const unsubscribe = api.query.system.events((events) => {
events.forEach((record) => {
const { event } = record;
if (event.section === 'identityKyc' && event.method === 'KycApproved') {
const [approvedAddress] = event.data;
if (approvedAddress.toString() === address) {
console.log('✅ KYC Approved for:', address);
onApproved();
}
}
});
});
return unsubscribe as unknown as () => void;
} catch (error: any) {
console.error('Error subscribing to KYC approval:', error);
if (onError) onError(error.message || 'Failed to subscribe to approval events');
return () => {};
}
}
// ========================================
// FOUNDER ADDRESS
// ========================================
export const FOUNDER_ADDRESS = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; // Satoshi Qazi Muhammed
+130
View File
@@ -0,0 +1,130 @@
// Identity verification types and utilities
export interface IdentityProfile {
address: string;
verificationLevel: 'none' | 'basic' | 'advanced' | 'verified';
kycStatus: 'pending' | 'approved' | 'rejected' | 'none';
reputationScore: number;
badges: Badge[];
roles: Role[];
verificationDate?: Date;
privacySettings: PrivacySettings;
}
export interface Badge {
id: string;
name: string;
description: string;
icon: string;
color: string;
earnedDate: Date;
category: 'governance' | 'contribution' | 'verification' | 'achievement';
}
export interface Role {
id: string;
name: string;
permissions: string[];
assignedDate: Date;
expiryDate?: Date;
}
export interface PrivacySettings {
showRealName: boolean;
showEmail: boolean;
showCountry: boolean;
useZKProof: boolean;
}
export interface KYCData {
firstName?: string;
lastName?: string;
email?: string;
country?: string;
documentType?: 'passport' | 'driver_license' | 'national_id';
documentHash?: string;
zkProof?: string;
}
export const VERIFICATION_LEVELS = {
none: { label: 'Unverified', color: 'gray', minScore: 0 },
basic: { label: 'Basic', color: 'blue', minScore: 100 },
advanced: { label: 'Advanced', color: 'purple', minScore: 500 },
verified: { label: 'Verified', color: 'green', minScore: 1000 }
};
export const DEFAULT_BADGES: Badge[] = [
{
id: 'early_adopter',
name: 'Early Adopter',
description: 'Joined during genesis phase',
icon: '🚀',
color: 'purple',
earnedDate: new Date(),
category: 'achievement'
},
{
id: 'governance_participant',
name: 'Active Voter',
description: 'Participated in 10+ proposals',
icon: '🗳️',
color: 'blue',
earnedDate: new Date(),
category: 'governance'
}
];
export const ROLES = {
validator: {
id: 'validator',
name: 'Validator',
permissions: ['validate_blocks', 'propose_blocks', 'vote_proposals']
},
council_member: {
id: 'council_member',
name: 'Council Member',
permissions: ['create_proposals', 'fast_track_proposals', 'emergency_actions']
},
verified_user: {
id: 'verified_user',
name: 'Verified User',
permissions: ['vote_proposals', 'create_basic_proposals']
}
};
export function calculateReputationScore(
activities: any[],
verificationLevel: string,
badges: Badge[]
): number {
let score = 0;
// Base score from verification
switch (verificationLevel) {
case 'basic': score += 100; break;
case 'advanced': score += 500; break;
case 'verified': score += 1000; break;
}
// Score from badges
score += badges.length * 50;
// Score from activities (placeholder)
score += activities.length * 10;
return Math.min(score, 2000); // Cap at 2000
}
export function generateZKProof(data: KYCData): string {
// Simplified ZK proof generation (in production, use actual ZK library)
const hash = btoa(JSON.stringify({
...data,
timestamp: Date.now(),
nonce: Math.random()
}));
return `zk_${hash.substring(0, 32)}`;
}
export function verifyZKProof(proof: string, expectedData?: any): boolean {
// Simplified verification (in production, use actual ZK verification)
return proof.startsWith('zk_') && proof.length > 32;
}
+325
View File
@@ -0,0 +1,325 @@
// ========================================
// Multisig Utilities for USDT Treasury
// ========================================
// Full on-chain multisig using Substrate pallet-multisig
import type { ApiPromise } from '@polkadot/api';
import type { SubmittableExtrinsic } from '@polkadot/api/types';
import { Tiki } from './tiki';
import { encodeAddress, sortAddresses } from '@polkadot/util-crypto';
// ========================================
// MULTISIG CONFIGURATION
// ========================================
export interface MultisigMember {
role: string;
tiki: Tiki;
isUnique: boolean;
address?: string; // For non-unique roles, hardcoded address
}
export const USDT_MULTISIG_CONFIG = {
threshold: 3,
members: [
{ role: 'Founder/President', tiki: Tiki.Serok, isUnique: true },
{ role: 'Parliament Speaker', tiki: Tiki.SerokiMeclise, isUnique: true },
{ role: 'Treasurer', tiki: Tiki.Xezinedar, isUnique: true },
{ role: 'Notary', tiki: Tiki.Noter, isUnique: false, address: '' }, // Will be set at runtime
{ role: 'Spokesperson', tiki: Tiki.Berdevk, isUnique: false, address: '' },
] as MultisigMember[],
};
// ========================================
// MULTISIG MEMBER QUERIES
// ========================================
/**
* Get all multisig members from on-chain tiki holders
* @param api - Polkadot API instance
* @param specificAddresses - Addresses for non-unique roles {tiki: address}
* @returns Sorted array of member addresses
*/
export async function getMultisigMembers(
api: ApiPromise,
specificAddresses: Record<string, string> = {}
): Promise<string[]> {
const members: string[] = [];
for (const memberConfig of USDT_MULTISIG_CONFIG.members) {
if (memberConfig.isUnique) {
// Query from chain for unique roles
try {
const holder = await api.query.tiki.tikiHolder(memberConfig.tiki);
if (holder.isSome) {
const address = holder.unwrap().toString();
members.push(address);
} else {
console.warn(`No holder found for unique role: ${memberConfig.tiki}`);
}
} catch (error) {
console.error(`Error querying ${memberConfig.tiki}:`, error);
}
} else {
// Use hardcoded address for non-unique roles
const address = specificAddresses[memberConfig.tiki] || memberConfig.address;
if (address) {
members.push(address);
} else {
console.warn(`No address specified for non-unique role: ${memberConfig.tiki}`);
}
}
}
// Multisig requires sorted addresses
return sortAddresses(members);
}
/**
* Calculate deterministic multisig account address
* @param members - Sorted array of member addresses
* @param threshold - Signature threshold (default: 3)
* @param ss58Format - SS58 format for address encoding (default: 42)
* @returns Multisig account address
*/
export function calculateMultisigAddress(
members: string[],
threshold: number = USDT_MULTISIG_CONFIG.threshold,
ss58Format: number = 42
): string {
// Sort members (multisig requires sorted order)
const sortedMembers = sortAddresses(members);
// Create multisig address
// Formula: blake2(b"modlpy/utilisuba" + concat(sorted_members) + threshold)
const multisigId = encodeAddress(
new Uint8Array([
...Buffer.from('modlpy/utilisuba'),
...sortedMembers.flatMap((addr) => Array.from(Buffer.from(addr, 'hex'))),
threshold,
]),
ss58Format
);
return multisigId;
}
/**
* Check if an address is a multisig member
* @param api - Polkadot API instance
* @param address - Address to check
* @param specificAddresses - Addresses for non-unique roles
* @returns boolean
*/
export async function isMultisigMember(
api: ApiPromise,
address: string,
specificAddresses: Record<string, string> = {}
): Promise<boolean> {
const members = await getMultisigMembers(api, specificAddresses);
return members.includes(address);
}
/**
* Get multisig member info for display
* @param api - Polkadot API instance
* @param specificAddresses - Addresses for non-unique roles
* @returns Array of member info objects
*/
export async function getMultisigMemberInfo(
api: ApiPromise,
specificAddresses: Record<string, string> = {}
): Promise<Array<{ role: string; tiki: Tiki; address: string; isUnique: boolean }>> {
const memberInfo = [];
for (const memberConfig of USDT_MULTISIG_CONFIG.members) {
let address = '';
if (memberConfig.isUnique) {
try {
const holder = await api.query.tiki.tikiHolder(memberConfig.tiki);
if (holder.isSome) {
address = holder.unwrap().toString();
}
} catch (error) {
console.error(`Error querying ${memberConfig.tiki}:`, error);
}
} else {
address = specificAddresses[memberConfig.tiki] || memberConfig.address || '';
}
if (address) {
memberInfo.push({
role: memberConfig.role,
tiki: memberConfig.tiki,
address,
isUnique: memberConfig.isUnique,
});
}
}
return memberInfo;
}
// ========================================
// MULTISIG TRANSACTION HELPERS
// ========================================
export interface MultisigTimepoint {
height: number;
index: number;
}
/**
* Create a new multisig transaction (first signature)
* @param api - Polkadot API instance
* @param call - The extrinsic to execute via multisig
* @param otherSignatories - Other multisig members (excluding current signer)
* @param threshold - Signature threshold
* @returns Multisig transaction
*/
export function createMultisigTx(
api: ApiPromise,
call: SubmittableExtrinsic<'promise'>,
otherSignatories: string[],
threshold: number = USDT_MULTISIG_CONFIG.threshold
) {
const maxWeight = {
refTime: 1000000000,
proofSize: 64 * 1024,
};
return api.tx.multisig.asMulti(
threshold,
sortAddresses(otherSignatories),
null, // No timepoint for first call
call,
maxWeight
);
}
/**
* Approve an existing multisig transaction
* @param api - Polkadot API instance
* @param call - The original extrinsic
* @param otherSignatories - Other multisig members
* @param timepoint - Block height and index of the first approval
* @param threshold - Signature threshold
* @returns Approval transaction
*/
export function approveMultisigTx(
api: ApiPromise,
call: SubmittableExtrinsic<'promise'>,
otherSignatories: string[],
timepoint: MultisigTimepoint,
threshold: number = USDT_MULTISIG_CONFIG.threshold
) {
const maxWeight = {
refTime: 1000000000,
proofSize: 64 * 1024,
};
return api.tx.multisig.asMulti(
threshold,
sortAddresses(otherSignatories),
timepoint,
call,
maxWeight
);
}
/**
* Cancel a multisig transaction
* @param api - Polkadot API instance
* @param callHash - Hash of the call to cancel
* @param otherSignatories - Other multisig members
* @param timepoint - Block height and index of the call
* @param threshold - Signature threshold
* @returns Cancel transaction
*/
export function cancelMultisigTx(
api: ApiPromise,
callHash: string,
otherSignatories: string[],
timepoint: MultisigTimepoint,
threshold: number = USDT_MULTISIG_CONFIG.threshold
) {
return api.tx.multisig.cancelAsMulti(
threshold,
sortAddresses(otherSignatories),
timepoint,
callHash
);
}
// ========================================
// MULTISIG STORAGE QUERIES
// ========================================
/**
* Get pending multisig calls
* @param api - Polkadot API instance
* @param multisigAddress - The multisig account address
* @returns Array of pending calls
*/
export async function getPendingMultisigCalls(
api: ApiPromise,
multisigAddress: string
): Promise<any[]> {
try {
const multisigs = await api.query.multisig.multisigs.entries(multisigAddress);
return multisigs.map(([key, value]) => {
const callHash = key.args[1].toHex();
const multisigData = value.toJSON() as any;
return {
callHash,
when: multisigData.when,
deposit: multisigData.deposit,
depositor: multisigData.depositor,
approvals: multisigData.approvals,
};
});
} catch (error) {
console.error('Error fetching pending multisig calls:', error);
return [];
}
}
// ========================================
// DISPLAY HELPERS
// ========================================
/**
* Format multisig address for display
* @param address - Full multisig address
* @returns Shortened address
*/
export function formatMultisigAddress(address: string): string {
if (!address) return '';
return `${address.slice(0, 8)}...${address.slice(-8)}`;
}
/**
* Get approval status text
* @param approvals - Number of approvals
* @param threshold - Required threshold
* @returns Status text
*/
export function getApprovalStatus(approvals: number, threshold: number): string {
if (approvals >= threshold) return 'Ready to Execute';
return `${approvals}/${threshold} Approvals`;
}
/**
* Get approval status color
* @param approvals - Number of approvals
* @param threshold - Required threshold
* @returns Tailwind color class
*/
export function getApprovalStatusColor(approvals: number, threshold: number): string {
if (approvals >= threshold) return 'text-green-500';
if (approvals >= threshold - 1) return 'text-yellow-500';
return 'text-gray-500';
}
+355
View File
@@ -0,0 +1,355 @@
// ========================================
// Score Systems Integration
// ========================================
// Centralized score fetching from blockchain pallets
import type { ApiPromise } from '@polkadot/api';
// ========================================
// TYPE DEFINITIONS
// ========================================
export interface UserScores {
trustScore: number;
referralScore: number;
stakingScore: number;
tikiScore: number;
totalScore: number;
}
export interface TrustScoreDetails {
totalScore: number;
stakingPoints: number;
referralPoints: number;
tikiPoints: number;
activityPoints: number;
historyLength: number;
}
// ========================================
// TRUST SCORE (pallet_trust)
// ========================================
/**
* Fetch user's trust score from blockchain
* pallet_trust::TrustScores storage
*/
export async function getTrustScore(
api: ApiPromise,
address: string
): Promise<number> {
try {
if (!api?.query?.trust) {
console.warn('Trust pallet not available');
return 0;
}
const score = await api.query.trust.trustScores(address);
if (score.isEmpty) {
return 0;
}
return Number(score.toString());
} catch (error) {
console.error('Error fetching trust score:', error);
return 0;
}
}
/**
* Fetch detailed trust score breakdown
* pallet_trust::ScoreHistory storage
*/
export async function getTrustScoreDetails(
api: ApiPromise,
address: string
): Promise<TrustScoreDetails | null> {
try {
if (!api?.query?.trust) {
return null;
}
const totalScore = await getTrustScore(api, address);
// Get score history to show detailed breakdown
const historyResult = await api.query.trust.scoreHistory(address);
if (historyResult.isEmpty) {
return {
totalScore,
stakingPoints: 0,
referralPoints: 0,
tikiPoints: 0,
activityPoints: 0,
historyLength: 0
};
}
const history = historyResult.toJSON() as any[];
// Calculate points from history
// History format: [{blockNumber, score, reason}]
let stakingPoints = 0;
let referralPoints = 0;
let tikiPoints = 0;
let activityPoints = 0;
for (const entry of history) {
const reason = entry.reason || '';
const score = entry.score || 0;
if (reason.includes('Staking')) stakingPoints += score;
else if (reason.includes('Referral')) referralPoints += score;
else if (reason.includes('Tiki') || reason.includes('Role')) tikiPoints += score;
else activityPoints += score;
}
return {
totalScore,
stakingPoints,
referralPoints,
tikiPoints,
activityPoints,
historyLength: history.length
};
} catch (error) {
console.error('Error fetching trust score details:', error);
return null;
}
}
// ========================================
// REFERRAL SCORE (pallet_trust)
// ========================================
/**
* Fetch user's referral score
* pallet_trust::ReferralScores storage
*/
export async function getReferralScore(
api: ApiPromise,
address: string
): Promise<number> {
try {
if (!api?.query?.trust?.referralScores) {
console.warn('Referral scores not available in trust pallet');
return 0;
}
const score = await api.query.trust.referralScores(address);
if (score.isEmpty) {
return 0;
}
return Number(score.toString());
} catch (error) {
console.error('Error fetching referral score:', error);
return 0;
}
}
/**
* Get referral count for user
* pallet_trust::Referrals storage
*/
export async function getReferralCount(
api: ApiPromise,
address: string
): Promise<number> {
try {
if (!api?.query?.trust?.referrals) {
return 0;
}
const referrals = await api.query.trust.referrals(address);
if (referrals.isEmpty) {
return 0;
}
const referralList = referrals.toJSON() as any[];
return Array.isArray(referralList) ? referralList.length : 0;
} catch (error) {
console.error('Error fetching referral count:', error);
return 0;
}
}
// ========================================
// STAKING SCORE (pallet_staking_score)
// ========================================
/**
* Get staking score from pallet_staking_score
* This is already implemented in lib/staking.ts
* Re-exported here for consistency
*/
export async function getStakingScoreFromPallet(
api: ApiPromise,
address: string
): Promise<number> {
try {
if (!api?.query?.stakingScore) {
console.warn('Staking score pallet not available');
return 0;
}
// Check if user has started score tracking
const scoreResult = await api.query.stakingScore.stakingStartBlock(address);
if (scoreResult.isNone) {
return 0;
}
// Get staking info from staking pallet
const ledger = await api.query.staking.ledger(address);
if (ledger.isNone) {
return 0;
}
const ledgerData = ledger.unwrap().toJSON() as any;
const stakedAmount = Number(ledgerData.total || 0) / 1e12; // Convert to HEZ
// Get duration
const startBlock = Number(scoreResult.unwrap().toString());
const currentBlock = Number((await api.query.system.number()).toString());
const durationInBlocks = currentBlock - startBlock;
// Calculate score based on amount and duration
// Amount-based score (20-50 points)
let amountScore = 20;
if (stakedAmount <= 100) amountScore = 20;
else if (stakedAmount <= 250) amountScore = 30;
else if (stakedAmount <= 750) amountScore = 40;
else amountScore = 50;
// Duration multiplier
const MONTH_IN_BLOCKS = 30 * 24 * 60 * 10; // ~30 days
let durationMultiplier = 1.0;
if (durationInBlocks >= 12 * MONTH_IN_BLOCKS) durationMultiplier = 2.0;
else if (durationInBlocks >= 6 * MONTH_IN_BLOCKS) durationMultiplier = 1.7;
else if (durationInBlocks >= 3 * MONTH_IN_BLOCKS) durationMultiplier = 1.4;
else if (durationInBlocks >= MONTH_IN_BLOCKS) durationMultiplier = 1.2;
return Math.min(100, Math.floor(amountScore * durationMultiplier));
} catch (error) {
console.error('Error fetching staking score:', error);
return 0;
}
}
// ========================================
// TIKI SCORE (from lib/tiki.ts)
// ========================================
/**
* Calculate Tiki score from user's roles
* Import from lib/tiki.ts
*/
import { fetchUserTikis, calculateTikiScore } from './tiki';
export async function getTikiScore(
api: ApiPromise,
address: string
): Promise<number> {
try {
const tikis = await fetchUserTikis(api, address);
return calculateTikiScore(tikis);
} catch (error) {
console.error('Error fetching tiki score:', error);
return 0;
}
}
// ========================================
// COMPREHENSIVE SCORE FETCHING
// ========================================
/**
* Fetch all scores for a user in one call
*/
export async function getAllScores(
api: ApiPromise,
address: string
): Promise<UserScores> {
try {
if (!api || !address) {
return {
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
};
}
// Fetch all scores in parallel
const [trustScore, referralScore, stakingScore, tikiScore] = await Promise.all([
getTrustScore(api, address),
getReferralScore(api, address),
getStakingScoreFromPallet(api, address),
getTikiScore(api, address)
]);
const totalScore = trustScore + referralScore + stakingScore + tikiScore;
return {
trustScore,
referralScore,
stakingScore,
tikiScore,
totalScore
};
} catch (error) {
console.error('Error fetching all scores:', error);
return {
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
};
}
}
// ========================================
// SCORE DISPLAY HELPERS
// ========================================
/**
* Get color class based on score
*/
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';
}
/**
* Get score rating label
*/
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';
}
/**
* Format score for display
*/
export function formatScore(score: number): string {
return score.toFixed(0);
}
+487
View File
@@ -0,0 +1,487 @@
// ========================================
// Staking Helper Functions
// ========================================
// Helper functions for pallet_staking and pallet_staking_score integration
import { ApiPromise } from '@polkadot/api';
import { formatBalance } from './wallet';
export interface StakingLedger {
stash: string;
total: string;
active: string;
unlocking: { value: string; era: number }[];
claimedRewards: number[];
}
export interface NominatorInfo {
targets: string[];
submittedIn: number;
suppressed: boolean;
}
export interface ValidatorPrefs {
commission: number;
blocked: boolean;
}
export interface EraRewardPoints {
total: number;
individual: Record<string, number>;
}
export interface PezRewardInfo {
currentEpoch: number;
epochStartBlock: number;
claimableRewards: { epoch: number; amount: string }[]; // Unclaimed rewards from completed epochs
totalClaimable: string;
hasPendingClaim: boolean;
}
export interface StakingInfo {
bonded: string;
active: string;
unlocking: { amount: string; era: number; blocksRemaining: number }[];
redeemable: string;
nominations: string[];
stakingScore: number | null;
stakingDuration: number | null; // Duration in blocks
hasStartedScoreTracking: boolean;
isValidator: boolean;
pezRewards: PezRewardInfo | null; // PEZ rewards information
}
/**
* Get staking ledger for an account
* In Substrate staking, we need to query using the controller account.
* If stash == controller (modern setup), we can query directly.
*/
export async function getStakingLedger(
api: ApiPromise,
address: string
): Promise<StakingLedger | null> {
try {
// Method 1: Try direct ledger query (modern Substrate where stash == controller)
let ledgerResult = await api.query.staking.ledger(address);
// Method 2: If not found, check if address is a stash and get controller
if (ledgerResult.isNone) {
const bondedController = await api.query.staking.bonded(address);
if (bondedController.isSome) {
const controllerAddress = bondedController.unwrap().toString();
console.log(`Found controller ${controllerAddress} for stash ${address}`);
ledgerResult = await api.query.staking.ledger(controllerAddress);
}
}
if (ledgerResult.isNone) {
console.warn(`No staking ledger found for ${address}`);
return null;
}
const ledger = ledgerResult.unwrap();
const ledgerJson = ledger.toJSON() as any;
console.log('Staking ledger:', ledgerJson);
return {
stash: ledgerJson.stash?.toString() || address,
total: ledgerJson.total?.toString() || '0',
active: ledgerJson.active?.toString() || '0',
unlocking: (ledgerJson.unlocking || []).map((u: any) => ({
value: u.value?.toString() || '0',
era: u.era || 0
})),
claimedRewards: ledgerJson.claimedRewards || []
};
} catch (error) {
console.error('Error fetching staking ledger:', error);
return null;
}
}
/**
* Get nominations for an account
*/
export async function getNominations(
api: ApiPromise,
address: string
): Promise<NominatorInfo | null> {
try {
const nominatorsOption = await api.query.staking.nominators(address);
if (nominatorsOption.isNone) {
return null;
}
const nominator = nominatorsOption.unwrap();
const nominatorJson = nominator.toJSON() as any;
return {
targets: nominatorJson.targets || [],
submittedIn: nominatorJson.submittedIn || 0,
suppressed: nominatorJson.suppressed || false
};
} catch (error) {
console.error('Error fetching nominations:', error);
return null;
}
}
/**
* Get current active era
*/
export async function getCurrentEra(api: ApiPromise): Promise<number> {
try {
const activeEraOption = await api.query.staking.activeEra();
if (activeEraOption.isNone) {
return 0;
}
const activeEra = activeEraOption.unwrap();
return Number(activeEra.index.toString());
} catch (error) {
console.error('Error fetching current era:', error);
return 0;
}
}
/**
* Get blocks remaining until an era
*/
export async function getBlocksUntilEra(
api: ApiPromise,
targetEra: number
): Promise<number> {
try {
const currentEra = await getCurrentEra(api);
if (targetEra <= currentEra) {
return 0;
}
const activeEraOption = await api.query.staking.activeEra();
if (activeEraOption.isNone) {
return 0;
}
const activeEra = activeEraOption.unwrap();
const eraStartBlock = Number(activeEra.start.unwrapOr(0).toString());
// Get session length and sessions per era
const sessionLength = api.consts.babe?.epochDuration || api.consts.timestamp?.minimumPeriod || 600;
const sessionsPerEra = api.consts.staking.sessionsPerEra;
const blocksPerEra = Number(sessionLength.toString()) * Number(sessionsPerEra.toString());
const currentBlock = Number((await api.query.system.number()).toString());
const erasRemaining = targetEra - currentEra;
const blocksIntoCurrentEra = currentBlock - eraStartBlock;
const blocksRemainingInCurrentEra = blocksPerEra - blocksIntoCurrentEra;
return blocksRemainingInCurrentEra + (blocksPerEra * (erasRemaining - 1));
} catch (error) {
console.error('Error calculating blocks until era:', error);
return 0;
}
}
/**
* Get PEZ rewards information for an account
*/
export async function getPezRewards(
api: ApiPromise,
address: string
): Promise<PezRewardInfo | null> {
try {
// Check if pezRewards pallet exists
if (!api.query.pezRewards || !api.query.pezRewards.epochInfo) {
console.warn('PezRewards pallet not available');
return null;
}
// Get current epoch info
const epochInfoResult = await api.query.pezRewards.epochInfo();
if (!epochInfoResult) {
console.warn('No epoch info found');
return null;
}
const epochInfo = epochInfoResult.toJSON() as any;
const currentEpoch = epochInfo.currentEpoch || 0;
const epochStartBlock = epochInfo.epochStartBlock || 0;
// Check for claimable rewards from completed epochs
const claimableRewards: { epoch: number; amount: string }[] = [];
let totalClaimable = BigInt(0);
// Check last 3 completed epochs for unclaimed rewards
for (let i = Math.max(0, currentEpoch - 3); i < currentEpoch; i++) {
try {
// Check if user has claimed this epoch already
const claimedResult = await api.query.pezRewards.claimedRewards(i, address);
if (claimedResult.isNone) {
// User hasn't claimed - check if they have rewards
const userScoreResult = await api.query.pezRewards.userEpochScores(i, address);
if (userScoreResult.isSome) {
// User has a score for this epoch - calculate their reward
const epochPoolResult = await api.query.pezRewards.epochRewardPools(i);
if (epochPoolResult.isSome) {
const epochPool = epochPoolResult.unwrap().toJSON() as any;
const userScore = BigInt(userScoreResult.unwrap().toString());
const rewardPerPoint = BigInt(epochPool.rewardPerTrustPoint || '0');
const rewardAmount = userScore * rewardPerPoint;
const rewardFormatted = formatBalance(rewardAmount.toString());
if (parseFloat(rewardFormatted) > 0) {
claimableRewards.push({
epoch: i,
amount: rewardFormatted
});
totalClaimable += rewardAmount;
}
}
}
}
} catch (err) {
console.warn(`Error checking epoch ${i} rewards:`, err);
}
}
return {
currentEpoch,
epochStartBlock,
claimableRewards,
totalClaimable: formatBalance(totalClaimable.toString()),
hasPendingClaim: claimableRewards.length > 0
};
} catch (error) {
console.warn('PEZ rewards not available:', error);
return null;
}
}
/**
* Get comprehensive staking info for an account
*/
export async function getStakingInfo(
api: ApiPromise,
address: string
): Promise<StakingInfo> {
const ledger = await getStakingLedger(api, address);
const nominations = await getNominations(api, address);
const currentEra = await getCurrentEra(api);
const unlocking = ledger?.unlocking || [];
const unlockingWithBlocks = await Promise.all(
unlocking.map(async (u) => {
const blocks = await getBlocksUntilEra(api, u.era);
return {
amount: formatBalance(u.value),
era: u.era,
blocksRemaining: blocks
};
})
);
// Calculate redeemable (unlocking chunks where era has passed)
const redeemableChunks = unlocking.filter(u => u.era <= currentEra);
const redeemable = redeemableChunks.reduce((sum, u) => {
return sum + BigInt(u.value);
}, BigInt(0));
// Get staking score if available
//
// IMPORTANT: This calculation MUST match pallet_staking_score::get_staking_score() exactly!
// The pallet calculates this score and reports it to pallet_pez_rewards.
// Any changes here must be synchronized with pallets/staking-score/src/lib.rs
//
// Score Formula:
// 1. Amount Score (20-50 points based on staked HEZ)
// - 0-100 HEZ: 20 points
// - 101-250 HEZ: 30 points
// - 251-750 HEZ: 40 points
// - 751+ HEZ: 50 points
// 2. Duration Multiplier (based on time staked)
// - < 1 month: x1.0
// - 1-2 months: x1.2
// - 3-5 months: x1.4
// - 6-11 months: x1.7
// - 12+ months: x2.0
// 3. Final Score = min(100, floor(amountScore * durationMultiplier))
//
let stakingScore: number | null = null;
let stakingDuration: number | null = null;
let hasStartedScoreTracking = false;
try {
if (api.query.stakingScore && api.query.stakingScore.stakingStartBlock) {
// Check if user has started score tracking
const scoreResult = await api.query.stakingScore.stakingStartBlock(address);
if (scoreResult.isSome) {
hasStartedScoreTracking = true;
const startBlock = Number(scoreResult.unwrap().toString());
const currentBlock = Number((await api.query.system.number()).toString());
const durationInBlocks = currentBlock - startBlock;
stakingDuration = durationInBlocks;
// Calculate amount-based score (20-50 points)
const stakedHEZ = ledger ? parseFloat(formatBalance(ledger.total)) : 0;
let amountScore = 20; // Default
if (stakedHEZ <= 100) {
amountScore = 20;
} else if (stakedHEZ <= 250) {
amountScore = 30;
} else if (stakedHEZ <= 750) {
amountScore = 40;
} else {
amountScore = 50; // 751+ HEZ
}
// Calculate duration multiplier
const MONTH_IN_BLOCKS = 30 * 24 * 60 * 10; // 432,000 blocks (~30 days, 6s per block)
let durationMultiplier = 1.0;
if (durationInBlocks >= 12 * MONTH_IN_BLOCKS) {
durationMultiplier = 2.0; // 12+ months
} else if (durationInBlocks >= 6 * MONTH_IN_BLOCKS) {
durationMultiplier = 1.7; // 6-11 months
} else if (durationInBlocks >= 3 * MONTH_IN_BLOCKS) {
durationMultiplier = 1.4; // 3-5 months
} else if (durationInBlocks >= MONTH_IN_BLOCKS) {
durationMultiplier = 1.2; // 1-2 months
} else {
durationMultiplier = 1.0; // < 1 month
}
// Final score calculation (max 100)
// This MUST match the pallet's integer math: amount_score * multiplier_numerator / multiplier_denominator
stakingScore = Math.min(100, Math.floor(amountScore * durationMultiplier));
console.log('Staking score calculated:', {
stakedHEZ,
amountScore,
durationInBlocks,
durationMultiplier,
finalScore: stakingScore
});
}
}
} catch (error) {
console.warn('Staking score not available:', error);
}
// Check if validator
const validatorsOption = await api.query.staking.validators(address);
const isValidator = validatorsOption.isSome;
// Get PEZ rewards information
const pezRewards = await getPezRewards(api, address);
return {
bonded: ledger ? formatBalance(ledger.total) : '0',
active: ledger ? formatBalance(ledger.active) : '0',
unlocking: unlockingWithBlocks,
redeemable: formatBalance(redeemable.toString()),
nominations: nominations?.targets || [],
stakingScore,
stakingDuration,
hasStartedScoreTracking,
isValidator,
pezRewards
};
}
/**
* Get list of active validators
* For Pezkuwi, we query staking.validators.entries() to get all registered validators
*/
export async function getActiveValidators(api: ApiPromise): Promise<string[]> {
try {
// Try multiple methods to get validators
// Method 1: Try validatorPool.currentValidatorSet() if available
if (api.query.validatorPool && api.query.validatorPool.currentValidatorSet) {
try {
const currentSetOption = await api.query.validatorPool.currentValidatorSet();
if (currentSetOption.isSome) {
const validatorSet = currentSetOption.unwrap() as any;
// Extract validators array from the set structure
if (validatorSet.validators && Array.isArray(validatorSet.validators)) {
const validators = validatorSet.validators.map((v: any) => v.toString());
if (validators.length > 0) {
console.log(`Found ${validators.length} validators from validatorPool.currentValidatorSet`);
return validators;
}
}
}
} catch (err) {
console.warn('validatorPool.currentValidatorSet query failed:', err);
}
}
// Method 2: Query staking.validators.entries() to get all registered validators
try {
const validatorEntries = await api.query.staking.validators.entries();
if (validatorEntries.length > 0) {
const validators = validatorEntries.map(([key]) => key.args[0].toString());
console.log(`Found ${validators.length} validators from staking.validators.entries()`);
return validators;
}
} catch (err) {
console.warn('staking.validators.entries() query failed:', err);
}
// Method 3: Fallback to session.validators()
const sessionValidators = await api.query.session.validators();
const validators = sessionValidators.map(v => v.toString());
console.log(`Found ${validators.length} validators from session.validators()`);
return validators;
} catch (error) {
console.error('Error fetching validators:', error);
return [];
}
}
/**
* Get minimum nominator bond
*/
export async function getMinNominatorBond(api: ApiPromise): Promise<string> {
try {
const minBond = await api.query.staking.minNominatorBond();
return formatBalance(minBond.toString());
} catch (error) {
console.error('Error fetching min nominator bond:', error);
return '0';
}
}
/**
* Get bonding duration in eras
*/
export async function getBondingDuration(api: ApiPromise): Promise<number> {
try {
const duration = api.consts.staking.bondingDuration;
return Number(duration.toString());
} catch (error) {
console.error('Error fetching bonding duration:', error);
return 28; // Default 28 eras
}
}
/**
* Parse amount to blockchain format (12 decimals for HEZ)
*/
export function parseAmount(amount: string | number, decimals: number = 12): string {
const amountNum = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(amountNum) || amountNum <= 0) {
throw new Error('Invalid amount');
}
const value = BigInt(Math.floor(amountNum * Math.pow(10, decimals)));
return value.toString();
}
+13
View File
@@ -0,0 +1,13 @@
import { createClient } from '@supabase/supabase-js';
// Initialize Supabase client from environment variables
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) {
console.warn('Supabase credentials not found in environment variables');
}
const supabase = createClient(supabaseUrl, supabaseKey);
export { supabase };
+399
View File
@@ -0,0 +1,399 @@
// ========================================
// Pallet-Tiki Integration
// ========================================
// This file handles all tiki-related blockchain interactions
// Based on: /Pezkuwi-SDK/pezkuwi/pallets/tiki/src/lib.rs
import type { ApiPromise } from '@polkadot/api';
// ========================================
// TIKI TYPES (from Rust enum)
// ========================================
export enum Tiki {
// Otomatik - KYC sonrası
Hemwelatî = 'Hemwelatî',
// Seçilen roller (Elected)
Parlementer = 'Parlementer',
SerokiMeclise = 'SerokiMeclise',
Serok = 'Serok',
// Atanan roller (Appointed) - Yargı
EndameDiwane = 'EndameDiwane',
Dadger = 'Dadger',
Dozger = 'Dozger',
Hiquqnas = 'Hiquqnas',
Noter = 'Noter',
// Atanan roller - Yürütme
Wezir = 'Wezir',
SerokWeziran = 'SerokWeziran',
WezireDarayiye = 'WezireDarayiye',
WezireParez = 'WezireParez',
WezireDad = 'WezireDad',
WezireBelaw = 'WezireBelaw',
WezireTend = 'WezireTend',
WezireAva = 'WezireAva',
WezireCand = 'WezireCand',
// Atanan roller - İdari
Xezinedar = 'Xezinedar',
Bacgir = 'Bacgir',
GerinendeyeCavkaniye = 'GerinendeyeCavkaniye',
OperatorêTorê = 'OperatorêTorê',
PisporêEwlehiyaSîber = 'PisporêEwlehiyaSîber',
GerinendeyeDaneye = 'GerinendeyeDaneye',
Berdevk = 'Berdevk',
Qeydkar = 'Qeydkar',
Balyoz = 'Balyoz',
Navbeynkar = 'Navbeynkar',
ParêzvaneÇandî = 'ParêzvaneÇandî',
Mufetîs = 'Mufetîs',
KalîteKontrolker = 'KalîteKontrolker',
// Atanan roller - Kültürel/Dini
Mela = 'Mela',
Feqî = 'Feqî',
Perwerdekar = 'Perwerdekar',
Rewsenbîr = 'Rewsenbîr',
RêveberêProjeyê = 'RêveberêProjeyê',
SerokêKomele = 'SerokêKomele',
ModeratorêCivakê = 'ModeratorêCivakê',
// Kazanılan roller (Earned)
Axa = 'Axa',
Pêseng = 'Pêseng',
Sêwirmend = 'Sêwirmend',
Hekem = 'Hekem',
Mamoste = 'Mamoste',
// Ekonomik rol
Bazargan = 'Bazargan',
}
// Role assignment types
export enum RoleAssignmentType {
Automatic = 'Automatic',
Appointed = 'Appointed',
Elected = 'Elected',
Earned = 'Earned',
}
// Tiki to Display Name mapping (English)
export const TIKI_DISPLAY_NAMES: Record<string, string> = {
Hemwelatî: 'Citizen',
Parlementer: 'Parliament Member',
SerokiMeclise: 'Speaker of Parliament',
Serok: 'President',
Wezir: 'Minister',
SerokWeziran: 'Prime Minister',
WezireDarayiye: 'Minister of Finance',
WezireParez: 'Minister of Defense',
WezireDad: 'Minister of Justice',
WezireBelaw: 'Minister of Education',
WezireTend: 'Minister of Health',
WezireAva: 'Minister of Water',
WezireCand: 'Minister of Culture',
EndameDiwane: 'Supreme Court Member',
Dadger: 'Judge',
Dozger: 'Prosecutor',
Hiquqnas: 'Lawyer',
Noter: 'Notary',
Xezinedar: 'Treasurer',
Bacgir: 'Tax Collector',
GerinendeyeCavkaniye: 'Resource Manager',
OperatorêTorê: 'Network Operator',
PisporêEwlehiyaSîber: 'Cybersecurity Expert',
GerinendeyeDaneye: 'Data Manager',
Berdevk: 'Representative',
Qeydkar: 'Registrar',
Balyoz: 'Ambassador',
Navbeynkar: 'Mediator',
ParêzvaneÇandî: 'Cultural Guardian',
Mufetîs: 'Inspector',
KalîteKontrolker: 'Quality Controller',
Mela: 'Religious Scholar',
Feqî: 'Religious Jurist',
Perwerdekar: 'Educator',
Rewsenbîr: 'Intellectual',
RêveberêProjeyê: 'Project Manager',
SerokêKomele: 'Community Leader',
ModeratorêCivakê: 'Community Moderator',
Axa: 'Elder',
Pêseng: 'Pioneer',
Sêwirmend: 'Advisor',
Hekem: 'Expert',
Mamoste: 'Teacher',
Bazargan: 'Merchant',
};
// Tiki scores (from get_bonus_for_tiki function)
export const TIKI_SCORES: Record<string, number> = {
Axa: 250,
RêveberêProjeyê: 250,
ModeratorêCivakê: 200,
Serok: 200,
EndameDiwane: 175,
Dadger: 150,
SerokiMeclise: 150,
SerokWeziran: 125,
Dozger: 120,
Wezir: 100,
WezireDarayiye: 100,
WezireParez: 100,
WezireDad: 100,
WezireBelaw: 100,
WezireTend: 100,
WezireAva: 100,
WezireCand: 100,
SerokêKomele: 100,
Xezinedar: 100,
PisporêEwlehiyaSîber: 100,
Parlementer: 100,
Mufetîs: 90,
Balyoz: 80,
Hiquqnas: 75,
Berdevk: 70,
Mamoste: 70,
Bazargan: 60,
OperatorêTorê: 60,
Mela: 50,
Feqî: 50,
Noter: 50,
Bacgir: 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,
Hemwelatî: 10,
Pêseng: 5, // Default for unlisted
};
// ========================================
// ROLE CATEGORIZATION
// ========================================
export const ROLE_CATEGORIES: Record<string, string[]> = {
Government: ['Serok', 'SerokWeziran', 'Wezir', 'WezireDarayiye', 'WezireParez', 'WezireDad', 'WezireBelaw', 'WezireTend', 'WezireAva', 'WezireCand'],
Legislature: ['Parlementer', 'SerokiMeclise'],
Judiciary: ['EndameDiwane', 'Dadger', 'Dozger', 'Hiquqnas', 'Noter'],
Administration: ['Xezinedar', 'Bacgir', 'Berdevk', 'Qeydkar', 'Balyoz', 'Mufetîs'],
Technical: ['OperatorêTorê', 'PisporêEwlehiyaSîber', 'GerinendeyeDaneye', 'GerinendeyeCavkaniye'],
Cultural: ['Mela', 'Feqî', 'ParêzvaneÇandî'],
Education: ['Mamoste', 'Perwerdekar', 'Rewsenbîr'],
Community: ['SerokêKomele', 'ModeratorêCivakê', 'Axa', 'Navbeynkar', 'Sêwirmend', 'Hekem'],
Economic: ['Bazargan'],
Leadership: ['RêveberêProjeyê', 'Pêseng'],
Quality: ['KalîteKontrolker'],
Citizen: ['Hemwelatî'],
};
// ========================================
// TIKI QUERY FUNCTIONS
// ========================================
/**
* Fetch user's tiki roles from blockchain
* @param api - Polkadot API instance
* @param address - User's substrate address
* @returns Array of tiki role strings
*/
export const fetchUserTikis = async (
api: ApiPromise,
address: string
): Promise<string[]> => {
try {
if (!api || !api.query.tiki) {
console.warn('Tiki pallet not available on this chain');
return [];
}
// Query UserTikis storage
const tikis = await api.query.tiki.userTikis(address);
const tikisArray = tikis.toJSON() as any[];
if (!tikisArray || tikisArray.length === 0) {
return [];
}
// Convert from enum index to string names
return tikisArray.map((tikiIndex: any) => {
// Tikis are stored as enum variants
if (typeof tikiIndex === 'string') {
return tikiIndex;
} else if (typeof tikiIndex === 'object' && tikiIndex !== null) {
// Handle object variant format {variantName: null}
return Object.keys(tikiIndex)[0];
}
return 'Unknown';
}).filter((t: string) => t !== 'Unknown');
} catch (error) {
console.error('Error fetching user tikis:', error);
return [];
}
};
/**
* Check if user is a citizen (has Hemwelatî tiki)
* @param api - Polkadot API instance
* @param address - User's substrate address
* @returns boolean
*/
export const isCitizen = async (
api: ApiPromise,
address: string
): Promise<boolean> => {
try {
if (!api || !api.query.tiki) {
return false;
}
const citizenNft = await api.query.tiki.citizenNft(address);
return !citizenNft.isEmpty;
} catch (error) {
console.error('Error checking citizenship:', error);
return false;
}
};
/**
* Calculate total tiki score for a user
* @param tikis - Array of tiki strings
* @returns Total score
*/
export const calculateTikiScore = (tikis: string[]): number => {
return tikis.reduce((total, tiki) => {
return total + (TIKI_SCORES[tiki] || 5);
}, 0);
};
/**
* Get primary role (highest scoring) from tikis
* @param tikis - Array of tiki strings
* @returns Primary role string
*/
export const getPrimaryRole = (tikis: string[]): string => {
if (!tikis || tikis.length === 0) {
return 'Member';
}
// Find highest scoring role
let primaryRole = tikis[0];
let highestScore = TIKI_SCORES[tikis[0]] || 5;
for (const tiki of tikis) {
const score = TIKI_SCORES[tiki] || 5;
if (score > highestScore) {
highestScore = score;
primaryRole = tiki;
}
}
return primaryRole;
};
/**
* Get display name for a tiki
* @param tiki - Tiki string
* @returns Display name
*/
export const getTikiDisplayName = (tiki: string): string => {
return TIKI_DISPLAY_NAMES[tiki] || tiki;
};
/**
* Get all role categories for user's tikis
* @param tikis - Array of tiki strings
* @returns Array of category names
*/
export const getUserRoleCategories = (tikis: string[]): string[] => {
const categories = new Set<string>();
for (const tiki of tikis) {
for (const [category, roles] of Object.entries(ROLE_CATEGORIES)) {
if (roles.includes(tiki)) {
categories.add(category);
}
}
}
return Array.from(categories);
};
/**
* Check if user has a specific tiki
* @param tikis - Array of tiki strings
* @param tiki - Tiki to check
* @returns boolean
*/
export const hasTiki = (tikis: string[], tiki: string): boolean => {
return tikis.includes(tiki);
};
// ========================================
// DISPLAY HELPERS
// ========================================
/**
* Get color for a tiki role
* @param tiki - Tiki string
* @returns Tailwind color class
*/
export const getTikiColor = (tiki: string): string => {
const score = TIKI_SCORES[tiki] || 5;
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';
};
/**
* Get emoji icon for a tiki category
* @param tiki - Tiki string
* @returns Emoji string
*/
export const getTikiEmoji = (tiki: string): string => {
for (const [category, roles] of Object.entries(ROLE_CATEGORIES)) {
if (roles.includes(tiki)) {
switch (category) {
case 'Government': return '👑';
case 'Legislature': return '🏛️';
case 'Judiciary': return '⚖️';
case 'Administration': return '📋';
case 'Technical': return '💻';
case 'Cultural': return '📿';
case 'Education': return '👨‍🏫';
case 'Community': return '🤝';
case 'Economic': return '💰';
case 'Leadership': return '⭐';
case 'Quality': return '✅';
case 'Citizen': return '👤';
}
}
}
return '👤';
};
/**
* Get badge variant for a tiki
* @param tiki - Tiki string
* @returns Badge variant string
*/
export const getTikiBadgeVariant = (tiki: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
const score = TIKI_SCORES[tiki] || 5;
if (score >= 150) return 'default'; // Purple/blue for high ranks
if (score >= 70) return 'secondary'; // Gray for mid ranks
return 'outline'; // Outline for low ranks
};
+314
View File
@@ -0,0 +1,314 @@
// ========================================
// USDT Bridge Utilities
// ========================================
// Handles wUSDT minting, burning, and reserve management
import type { ApiPromise } from '@polkadot/api';
import { ASSET_IDS } from './wallet';
import { getMultisigMembers, createMultisigTx } from './multisig';
// ========================================
// CONSTANTS
// ========================================
export const WUSDT_ASSET_ID = ASSET_IDS.WUSDT;
export const WUSDT_DECIMALS = 6; // USDT has 6 decimals
// Withdrawal limits and timeouts
export const WITHDRAWAL_LIMITS = {
instant: {
maxAmount: 1000, // $1,000
delay: 0, // No delay
},
standard: {
maxAmount: 10000, // $10,000
delay: 3600, // 1 hour in seconds
},
large: {
maxAmount: Infinity,
delay: 86400, // 24 hours
},
};
// ========================================
// ASSET QUERIES
// ========================================
/**
* Get wUSDT balance for an account
* @param api - Polkadot API instance
* @param address - Account address
* @returns Balance in human-readable format
*/
export async function getWUSDTBalance(api: ApiPromise, address: string): Promise<number> {
try {
const balance = await api.query.assets.account(WUSDT_ASSET_ID, address);
if (balance.isSome) {
const balanceData = balance.unwrap().toJSON() as any;
return Number(balanceData.balance) / Math.pow(10, WUSDT_DECIMALS);
}
return 0;
} catch (error) {
console.error('Error fetching wUSDT balance:', error);
return 0;
}
}
/**
* Get total wUSDT supply
* @param api - Polkadot API instance
* @returns Total supply in human-readable format
*/
export async function getWUSDTTotalSupply(api: ApiPromise): Promise<number> {
try {
const assetDetails = await api.query.assets.asset(WUSDT_ASSET_ID);
if (assetDetails.isSome) {
const details = assetDetails.unwrap().toJSON() as any;
return Number(details.supply) / Math.pow(10, WUSDT_DECIMALS);
}
return 0;
} catch (error) {
console.error('Error fetching wUSDT supply:', error);
return 0;
}
}
/**
* Get wUSDT asset metadata
* @param api - Polkadot API instance
* @returns Asset metadata
*/
export async function getWUSDTMetadata(api: ApiPromise) {
try {
const metadata = await api.query.assets.metadata(WUSDT_ASSET_ID);
return metadata.toJSON();
} catch (error) {
console.error('Error fetching wUSDT metadata:', error);
return null;
}
}
// ========================================
// MULTISIG OPERATIONS
// ========================================
/**
* Create multisig transaction to mint wUSDT
* @param api - Polkadot API instance
* @param beneficiary - Who will receive the wUSDT
* @param amount - Amount in human-readable format (e.g., 100.50 USDT)
* @param signerAddress - Address of the signer creating this tx
* @param specificAddresses - Addresses for non-unique multisig members
* @returns Multisig transaction
*/
export async function createMintWUSDTTx(
api: ApiPromise,
beneficiary: string,
amount: number,
signerAddress: string,
specificAddresses: Record<string, string> = {}
) {
// Convert to smallest unit
const amountBN = BigInt(Math.floor(amount * Math.pow(10, WUSDT_DECIMALS)));
// Create the mint call
const mintCall = api.tx.assets.mint(WUSDT_ASSET_ID, beneficiary, amountBN.toString());
// Get all multisig members
const allMembers = await getMultisigMembers(api, specificAddresses);
// Other signatories (excluding current signer)
const otherSignatories = allMembers.filter((addr) => addr !== signerAddress);
// Create multisig transaction
return createMultisigTx(api, mintCall, otherSignatories);
}
/**
* Create multisig transaction to burn wUSDT
* @param api - Polkadot API instance
* @param from - Who will have their wUSDT burned
* @param amount - Amount in human-readable format
* @param signerAddress - Address of the signer creating this tx
* @param specificAddresses - Addresses for non-unique multisig members
* @returns Multisig transaction
*/
export async function createBurnWUSDTTx(
api: ApiPromise,
from: string,
amount: number,
signerAddress: string,
specificAddresses: Record<string, string> = {}
) {
const amountBN = BigInt(Math.floor(amount * Math.pow(10, WUSDT_DECIMALS)));
const burnCall = api.tx.assets.burn(WUSDT_ASSET_ID, from, amountBN.toString());
const allMembers = await getMultisigMembers(api, specificAddresses);
const otherSignatories = allMembers.filter((addr) => addr !== signerAddress);
return createMultisigTx(api, burnCall, otherSignatories);
}
// ========================================
// WITHDRAWAL HELPERS
// ========================================
/**
* Calculate withdrawal delay based on amount
* @param amount - Withdrawal amount in USDT
* @returns Delay in seconds
*/
export function calculateWithdrawalDelay(amount: number): number {
if (amount <= WITHDRAWAL_LIMITS.instant.maxAmount) {
return WITHDRAWAL_LIMITS.instant.delay;
} else if (amount <= WITHDRAWAL_LIMITS.standard.maxAmount) {
return WITHDRAWAL_LIMITS.standard.delay;
} else {
return WITHDRAWAL_LIMITS.large.delay;
}
}
/**
* Get withdrawal tier name
* @param amount - Withdrawal amount
* @returns Tier name
*/
export function getWithdrawalTier(amount: number): string {
if (amount <= WITHDRAWAL_LIMITS.instant.maxAmount) return 'Instant';
if (amount <= WITHDRAWAL_LIMITS.standard.maxAmount) return 'Standard';
return 'Large';
}
/**
* Format delay time for display
* @param seconds - Delay in seconds
* @returns Human-readable format
*/
export function formatDelay(seconds: number): string {
if (seconds === 0) return 'Instant';
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
return `${Math.floor(seconds / 86400)} days`;
}
// ========================================
// RESERVE CHECKING
// ========================================
export interface ReserveStatus {
wusdtSupply: number;
offChainReserve: number; // This would come from off-chain oracle/API
collateralRatio: number;
isHealthy: boolean;
}
/**
* Check reserve health
* @param api - Polkadot API instance
* @param offChainReserve - Off-chain USDT reserve amount (from treasury)
* @returns Reserve status
*/
export async function checkReserveHealth(
api: ApiPromise,
offChainReserve: number
): Promise<ReserveStatus> {
const wusdtSupply = await getWUSDTTotalSupply(api);
const collateralRatio = wusdtSupply > 0 ? (offChainReserve / wusdtSupply) * 100 : 0;
return {
wusdtSupply,
offChainReserve,
collateralRatio,
isHealthy: collateralRatio >= 100, // At least 100% backed
};
}
// ========================================
// EVENT MONITORING
// ========================================
/**
* Subscribe to wUSDT mint events
* @param api - Polkadot API instance
* @param callback - Callback function for each mint event
*/
export function subscribeToMintEvents(
api: ApiPromise,
callback: (beneficiary: string, amount: number, txHash: string) => void
) {
return api.query.system.events((events) => {
events.forEach((record) => {
const { event } = record;
if (api.events.assets.Issued.is(event)) {
const [assetId, beneficiary, amount] = event.data;
if (assetId.toNumber() === WUSDT_ASSET_ID) {
const amountNum = Number(amount.toString()) / Math.pow(10, WUSDT_DECIMALS);
callback(beneficiary.toString(), amountNum, record.hash.toHex());
}
}
});
});
}
/**
* Subscribe to wUSDT burn events
* @param api - Polkadot API instance
* @param callback - Callback function for each burn event
*/
export function subscribeToBurnEvents(
api: ApiPromise,
callback: (account: string, amount: number, txHash: string) => void
) {
return api.query.system.events((events) => {
events.forEach((record) => {
const { event } = record;
if (api.events.assets.Burned.is(event)) {
const [assetId, account, amount] = event.data;
if (assetId.toNumber() === WUSDT_ASSET_ID) {
const amountNum = Number(amount.toString()) / Math.pow(10, WUSDT_DECIMALS);
callback(account.toString(), amountNum, record.hash.toHex());
}
}
});
});
}
// ========================================
// DISPLAY HELPERS
// ========================================
/**
* Format wUSDT amount for display
* @param amount - Amount in smallest unit or human-readable
* @param fromSmallestUnit - Whether input is in smallest unit
* @returns Formatted string
*/
export function formatWUSDT(amount: number | string, fromSmallestUnit = false): string {
const value = typeof amount === 'string' ? parseFloat(amount) : amount;
if (fromSmallestUnit) {
return (value / Math.pow(10, WUSDT_DECIMALS)).toFixed(2);
}
return value.toFixed(2);
}
/**
* Parse human-readable USDT to smallest unit
* @param amount - Human-readable amount
* @returns Amount in smallest unit (BigInt)
*/
export function parseWUSDT(amount: number | string): bigint {
const value = typeof amount === 'string' ? parseFloat(amount) : amount;
return BigInt(Math.floor(value * Math.pow(10, WUSDT_DECIMALS)));
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+140
View File
@@ -0,0 +1,140 @@
// ========================================
// PezkuwiChain - Substrate/Polkadot.js Configuration
// ========================================
// This file configures wallet connectivity for Substrate-based chains
import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types';
// ========================================
// NETWORK ENDPOINTS
// ========================================
export const NETWORK_ENDPOINTS = {
local: import.meta.env.VITE_DEVELOPMENT_WS || 'ws://127.0.0.1:9944',
testnet: import.meta.env.VITE_TESTNET_WS || 'wss://testnet.pezkuwichain.io',
mainnet: import.meta.env.VITE_MAINNET_WS || 'wss://mainnet.pezkuwichain.io',
staging: import.meta.env.VITE_STAGING_WS || 'wss://staging.pezkuwichain.io',
beta: import.meta.env.VITE_BETA_WS || 'wss://beta.pezkuwichain.io',
};
// ========================================
// CHAIN CONFIGURATION
// ========================================
export const CHAIN_CONFIG = {
name: import.meta.env.VITE_CHAIN_NAME || 'PezkuwiChain',
symbol: import.meta.env.VITE_CHAIN_TOKEN_SYMBOL || 'PEZ',
decimals: parseInt(import.meta.env.VITE_CHAIN_TOKEN_DECIMALS || '12'),
ss58Format: parseInt(import.meta.env.VITE_CHAIN_SS58_FORMAT || '42'),
};
// ========================================
// SUBSTRATE ASSET IDs (Assets Pallet)
// ========================================
// ⚠️ IMPORTANT: HEZ is the native token and does NOT have an Asset ID
// Only wrapped/asset tokens are listed here
export const ASSET_IDS = {
WHEZ: parseInt(import.meta.env.VITE_ASSET_WHEZ || '0'), // Wrapped HEZ
PEZ: parseInt(import.meta.env.VITE_ASSET_PEZ || '1'), // PEZ utility token
WUSDT: parseInt(import.meta.env.VITE_ASSET_WUSDT || '2'), // Wrapped USDT (multisig backed)
USDT: parseInt(import.meta.env.VITE_ASSET_USDT || '3'),
BTC: parseInt(import.meta.env.VITE_ASSET_BTC || '4'),
ETH: parseInt(import.meta.env.VITE_ASSET_ETH || '5'),
DOT: parseInt(import.meta.env.VITE_ASSET_DOT || '6'),
} as const;
// ========================================
// EXPLORER URLS
// ========================================
export const EXPLORER_URLS = {
polkadotJs: import.meta.env.VITE_EXPLORER_URL || 'https://polkadot.js.org/apps/?rpc=',
custom: import.meta.env.VITE_CUSTOM_EXPLORER_URL || 'https://explorer.pezkuwichain.io',
};
// ========================================
// WALLET ERROR MESSAGES
// ========================================
export const WALLET_ERRORS = {
NO_EXTENSION: 'No Polkadot.js extension detected. Please install Polkadot.js or compatible wallet.',
NO_ACCOUNTS: 'No accounts found. Please create an account in your wallet extension.',
CONNECTION_FAILED: 'Failed to connect wallet. Please try again.',
TRANSACTION_FAILED: 'Transaction failed. Please check your balance and try again.',
USER_REJECTED: 'User rejected the request.',
INSUFFICIENT_BALANCE: 'Insufficient balance to complete transaction.',
INVALID_ADDRESS: 'Invalid address format.',
API_NOT_READY: 'Blockchain API not ready. Please wait...',
};
// ========================================
// UTILITY FUNCTIONS
// ========================================
/**
* Format Substrate address for display (SS58 format)
* @param address - Full substrate address
* @returns Shortened address string (e.g., "5GrwV...xQjz")
*/
export const formatAddress = (address: string): string => {
if (!address) return '';
return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
/**
* Format balance from planck to human-readable format
* @param balance - Balance in smallest unit (planck)
* @param decimals - Token decimals (default 12 for PEZ)
* @returns Formatted balance string
*/
export const formatBalance = (balance: string | number, decimals = 12): string => {
if (!balance) return '0';
const value = typeof balance === 'string' ? parseFloat(balance) : balance;
return (value / Math.pow(10, decimals)).toFixed(4);
};
/**
* Parse human-readable amount to planck (smallest unit)
* @param amount - Human-readable amount
* @param decimals - Token decimals
* @returns Amount in planck
*/
export const parseAmount = (amount: string | number, decimals = 12): bigint => {
const value = typeof amount === 'string' ? parseFloat(amount) : amount;
return BigInt(Math.floor(value * Math.pow(10, decimals)));
};
/**
* Get asset symbol by ID
* @param assetId - Asset ID from Assets pallet
* @returns Asset symbol or 'UNKNOWN'
*/
export const getAssetSymbol = (assetId: number): string => {
const entry = Object.entries(ASSET_IDS).find(([_, id]) => id === assetId);
return entry ? entry[0] : 'UNKNOWN';
};
/**
* Get current network endpoint based on VITE_NETWORK env
* @returns WebSocket endpoint URL
*/
export const getCurrentEndpoint = (): string => {
const network = import.meta.env.VITE_NETWORK || 'local';
return NETWORK_ENDPOINTS[network as keyof typeof NETWORK_ENDPOINTS] || NETWORK_ENDPOINTS.local;
};
// ========================================
// TYPE DEFINITIONS
// ========================================
export interface PolkadotWalletState {
isConnected: boolean;
accounts: InjectedAccountWithMeta[];
selectedAccount: InjectedAccountWithMeta | null;
balance: string;
error: string | null;
}
export const initialPolkadotWalletState: PolkadotWalletState = {
isConnected: false,
accounts: [],
selectedAccount: null,
balance: '0',
error: null,
};