fix: rewrite citizenship workflow to referral-based model

- Replace governance-based KYC with trustless referral workflow
- New 3-step flow: applyForCitizenship -> approveReferral -> confirmCitizenship
- Fix FOUNDER_ADDRESS (was Alice test address)
- Use applications storage instead of legacy pendingKycApplications
- Add approveReferral, cancelApplication, confirmCitizenship functions
- Rewrite KycApprovalTab as referrer approval panel (no governance)
- Fix InviteUserModal to use peopleApi for referral pallet
- Add pending approvals section to ReferralDashboard
This commit is contained in:
2026-02-16 02:56:27 +03:00
parent e9f01685d0
commit e9d5fef39a
6 changed files with 728 additions and 743 deletions
+286 -132
View File
@@ -40,7 +40,7 @@ const web3FromAddress = async (address: string): Promise<InjectedExtension> => {
// TYPE DEFINITIONS
// ========================================
export type KycStatus = 'NotStarted' | 'Pending' | 'Approved' | 'Rejected';
export type KycStatus = 'NotStarted' | 'PendingReferral' | 'ReferrerApproved' | 'Approved' | 'Revoked';
export type Region =
| 'bakur' // North (Turkey)
@@ -107,7 +107,7 @@ export interface CitizenshipStatus {
tikiNumber?: string;
stakingScoreTracking: boolean;
ipfsCid?: string;
nextAction: 'APPLY_KYC' | 'CLAIM_TIKI' | 'START_TRACKING' | 'COMPLETE';
nextAction: 'APPLY_KYC' | 'WAIT_REFERRER' | 'CONFIRM' | 'CLAIM_TIKI' | 'START_TRACKING' | 'COMPLETE';
}
// ========================================
@@ -122,28 +122,37 @@ export async function getKycStatus(
address: string
): Promise<KycStatus> {
try {
// MOCK FOR DEV: Alice is Approved
if (address === '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY') {
return 'Approved';
}
if (!api?.query?.identityKyc) {
if (import.meta.env.DEV) console.log('Identity KYC pallet not available on this chain');
return 'NotStarted';
}
const status = await api.query.identityKyc.kycStatuses(address);
// Check Applications storage (new pallet API)
if (api.query.identityKyc.applications) {
const application = await api.query.identityKyc.applications(address);
if (status.isEmpty) {
return 'NotStarted';
if (!application.isEmpty) {
const appData = application.toJSON() as Record<string, unknown>;
const status = appData.status as string | undefined;
if (status === 'PendingReferral') return 'PendingReferral';
if (status === 'ReferrerApproved') return 'ReferrerApproved';
if (status === 'Approved') return 'Approved';
if (status === 'Revoked') return 'Revoked';
}
}
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';
// Fallback: check kycStatuses if applications storage doesn't exist
if (api.query.identityKyc.kycStatuses) {
const status = await api.query.identityKyc.kycStatuses(address);
if (!status.isEmpty) {
const statusStr = status.toString();
if (statusStr === 'Approved') return 'Approved';
if (statusStr === 'PendingReferral') return 'PendingReferral';
if (statusStr === 'ReferrerApproved') return 'ReferrerApproved';
if (statusStr === 'Revoked') return 'Revoked';
}
}
return 'NotStarted';
} catch (error) {
@@ -160,12 +169,15 @@ export async function hasPendingApplication(
address: string
): Promise<boolean> {
try {
if (!api?.query?.identityKyc?.pendingKycApplications) {
return false;
if (api?.query?.identityKyc?.applications) {
const application = await api.query.identityKyc.applications(address);
if (!application.isEmpty) {
const appData = application.toJSON() as Record<string, unknown>;
const status = appData.status as string | undefined;
return status === 'PendingReferral' || status === 'ReferrerApproved';
}
}
const application = await api.query.identityKyc.pendingKycApplications(address);
return !application.isEmpty;
return false;
} catch (error) {
console.error('Error checking pending application:', error);
return false;
@@ -344,15 +356,18 @@ export async function getCitizenshipStatus(
isStakingScoreTracking(api, address)
]);
const kycApproved = kycStatus === 'Approved';
const hasTiki = citizenCheck.hasTiki;
// Determine next action
// Determine next action based on workflow state
let nextAction: CitizenshipStatus['nextAction'];
if (!kycApproved) {
if (kycStatus === 'NotStarted' || kycStatus === 'Revoked') {
nextAction = 'APPLY_KYC';
} else if (!hasTiki) {
} else if (kycStatus === 'PendingReferral') {
nextAction = 'WAIT_REFERRER';
} else if (kycStatus === 'ReferrerApproved') {
nextAction = 'CONFIRM';
} else if (kycStatus === 'Approved' && !hasTiki) {
nextAction = 'CLAIM_TIKI';
} else if (!stakingTracking) {
nextAction = 'START_TRACKING';
@@ -453,172 +468,113 @@ export async function validateReferralCode(
// ========================================
/**
* Submit KYC application to blockchain
* This is a two-step process:
* 1. Set identity (name, email)
* 2. Apply for KYC (IPFS CID, notes)
* Submit citizenship application to blockchain
* Single call: applyForCitizenship(identity_hash, referrer)
* Requires 1 HEZ deposit (reserved by pallet automatically)
*/
export async function submitKycApplication(
api: ApiPromise,
account: InjectedAccountWithMeta,
name: string,
email: string,
ipfsCid: string,
notes: string = 'Citizenship application'
identityHash: string,
referrerAddress?: string
): Promise<{ success: boolean; error?: string; blockHash?: string }> {
try {
if (!api?.tx?.identityKyc?.setIdentity || !api?.tx?.identityKyc?.applyForKyc) {
if (!api?.tx?.identityKyc?.applyForCitizenship) {
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');
// Check if user already has a pending application
const hasPending = await hasPendingApplication(api, account.address);
if (hasPending) {
return {
success: false,
error: 'You already have a pending citizenship application. Please wait for approval.'
error: 'You already have a pending citizenship application. Please wait for referrer 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');
const currentStatus = await getKycStatus(api, account.address);
if (currentStatus === '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}` };
if (import.meta.env.DEV) {
console.log('=== submitKycApplication Debug ===');
console.log('account.address:', account.address);
console.log('identityHash:', identityHash);
console.log('referrerAddress:', referrerAddress || '(default referrer)');
console.log('===================================');
}
// 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);
// Single call: applyForCitizenship(identity_hash, referrer)
// referrer is Option<AccountId> - null means pallet uses DefaultReferrer
const referrerParam = referrerAddress || null;
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)
.applyForCitizenship(identityHash, referrerParam)
.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError, events }) => {
console.log('Transaction status:', status.type);
if (import.meta.env.DEV) 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);
if (import.meta.env.DEV) 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'
const appliedEvent = events.find(({ event }: any) =>
event.section === 'identityKyc' && event.method === 'CitizenshipApplied'
);
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 });
if (appliedEvent) {
if (import.meta.env.DEV) console.log('Citizenship application submitted successfully');
}
resolve({
success: true,
blockHash: status.isInBlock ? status.asInBlock.toString() : undefined
});
}
})
.catch((error) => {
console.error('Failed to sign and send transaction:', error);
.catch((error: any) => {
if (import.meta.env.DEV) console.error('Failed to sign and send transaction:', error);
reject(error);
});
});
return result;
} catch (error: any) {
console.error('Error submitting KYC application:', error);
console.error('Error submitting citizenship application:', error);
return {
success: false,
error: error.message || 'Failed to submit KYC application'
error: error.message || 'Failed to submit citizenship application'
};
}
}
/**
* Subscribe to KYC approval events for an address
* Subscribe to citizenship-related events for an address
* Listens for ReferralApproved and CitizenshipConfirmed
*/
export function subscribeToKycApproval(
api: ApiPromise,
address: string,
onApproved: () => void,
onError?: (error: string) => void
onError?: (error: string) => void,
onReferralApproved?: () => void
): () => void {
try {
if (!api?.query?.system?.events) {
@@ -633,11 +589,20 @@ export function subscribeToKycApproval(
events.forEach((record: any) => {
const { event } = record;
if (event.section === 'identityKyc' && event.method === 'KycApproved') {
const [approvedAddress] = event.data;
// Referrer approved the application
if (event.section === 'identityKyc' && event.method === 'ReferralApproved') {
const [applicantAddress] = event.data;
if (applicantAddress.toString() === address) {
if (import.meta.env.DEV) console.log('Referral approved for:', address);
if (onReferralApproved) onReferralApproved();
}
}
if (approvedAddress.toString() === address) {
console.log('✅ KYC Approved for:', address);
// Citizenship fully confirmed (NFT minted)
if (event.section === 'identityKyc' && event.method === 'CitizenshipConfirmed') {
const [confirmedAddress] = event.data;
if (confirmedAddress.toString() === address) {
if (import.meta.env.DEV) console.log('Citizenship confirmed for:', address);
onApproved();
}
}
@@ -646,17 +611,206 @@ export function subscribeToKycApproval(
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');
console.error('Error subscribing to citizenship events:', error);
if (onError) onError(error.message || 'Failed to subscribe to events');
return () => {};
}
}
// ========================================
// REFERRER ACTIONS
// ========================================
/**
* Approve a referral as a referrer
* Called by the referrer to vouch for an applicant
*/
export async function approveReferral(
api: ApiPromise,
account: InjectedAccountWithMeta,
applicantAddress: string
): Promise<{ success: boolean; error?: string; blockHash?: string }> {
try {
if (!api?.tx?.identityKyc?.approveReferral) {
return { success: false, error: 'Identity KYC pallet not available' };
}
const injector = await web3FromAddress(account.address);
const result = await new Promise<{ success: boolean; error?: string; blockHash?: string }>((resolve, reject) => {
api.tx.identityKyc
.approveReferral(applicantAddress)
.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError, events }) => {
if (import.meta.env.DEV) console.log('Approve referral tx 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();
}
resolve({ success: false, error: errorMessage });
return;
}
resolve({
success: true,
blockHash: status.isInBlock ? status.asInBlock.toString() : undefined
});
}
})
.catch((error: any) => reject(error));
});
return result;
} catch (error: any) {
console.error('Error approving referral:', error);
return { success: false, error: error.message || 'Failed to approve referral' };
}
}
/**
* Cancel a pending citizenship application
* Called by the applicant to withdraw and get deposit back
*/
export async function cancelApplication(
api: ApiPromise,
account: InjectedAccountWithMeta
): Promise<{ success: boolean; error?: string }> {
try {
if (!api?.tx?.identityKyc?.cancelApplication) {
return { success: false, error: 'Identity KYC pallet not available' };
}
const injector = await web3FromAddress(account.address);
const result = await new Promise<{ success: boolean; error?: string }>((resolve, reject) => {
api.tx.identityKyc
.cancelApplication()
.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError }) => {
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();
}
resolve({ success: false, error: errorMessage });
return;
}
resolve({ success: true });
}
})
.catch((error: any) => reject(error));
});
return result;
} catch (error: any) {
console.error('Error canceling application:', error);
return { success: false, error: error.message || 'Failed to cancel application' };
}
}
/**
* Confirm citizenship after referrer approval
* Called by the applicant to mint the Welati Tiki NFT
*/
export async function confirmCitizenship(
api: ApiPromise,
account: InjectedAccountWithMeta
): Promise<{ success: boolean; error?: string; blockHash?: string }> {
try {
if (!api?.tx?.identityKyc?.confirmCitizenship) {
return { success: false, error: 'Identity KYC pallet not available' };
}
const injector = await web3FromAddress(account.address);
const result = await new Promise<{ success: boolean; error?: string; blockHash?: string }>((resolve, reject) => {
api.tx.identityKyc
.confirmCitizenship()
.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError, events }) => {
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();
}
resolve({ success: false, error: errorMessage });
return;
}
resolve({
success: true,
blockHash: status.isInBlock ? status.asInBlock.toString() : undefined
});
}
})
.catch((error: any) => reject(error));
});
return result;
} catch (error: any) {
console.error('Error confirming citizenship:', error);
return { success: false, error: error.message || 'Failed to confirm citizenship' };
}
}
export interface PendingApproval {
applicantAddress: string;
identityHash: string;
}
/**
* Get pending approvals where current user is the referrer
*/
export async function getPendingApprovalsForReferrer(
api: ApiPromise,
referrerAddress: string
): Promise<PendingApproval[]> {
try {
if (!api?.query?.identityKyc?.applications) {
return [];
}
const entries = await api.query.identityKyc.applications.entries();
const pending: PendingApproval[] = [];
for (const [key, value] of entries) {
const applicantAddress = key.args[0].toString();
const appData = (value as any).toJSON() as Record<string, unknown>;
if (
appData.status === 'PendingReferral' &&
appData.referrer?.toString() === referrerAddress
) {
pending.push({
applicantAddress,
identityHash: (appData.identityHash as string) || ''
});
}
}
return pending;
} catch (error) {
console.error('Error fetching pending approvals for referrer:', error);
return [];
}
}
// ========================================
// FOUNDER ADDRESS
// ========================================
export const FOUNDER_ADDRESS = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; // Satoshi Qazi Muhammed
export const FOUNDER_ADDRESS = '5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF'; // Satoshi Qazi Muhammed
export interface AuthChallenge {
message: string;
+99 -446
View File
@@ -4,8 +4,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { Loader2, CheckCircle, XCircle, Clock, User, Mail, FileText, AlertTriangle } from 'lucide-react';
import { COMMISSIONS } from '@/config/commissions';
import { Loader2, CheckCircle, Clock, User, AlertTriangle } from 'lucide-react';
import {
Table,
TableBody,
@@ -14,96 +13,42 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface PendingApplication {
address: string;
cids: string[];
notes: string;
timestamp?: number;
}
interface IdentityInfo {
name: string;
email: string;
}
import { approveReferral, getPendingApprovalsForReferrer } from '@pezkuwi/lib/citizenship-workflow';
import type { PendingApproval } from '@pezkuwi/lib/citizenship-workflow';
export function KycApprovalTab() {
// identityKyc pallet is on People Chain, dynamicCommissionCollective is on Relay Chain
const { api, isApiReady, peopleApi, isPeopleReady, selectedAccount, connectWallet } = usePezkuwi();
// identityKyc pallet is on People Chain - use peopleApi
const { peopleApi, isPeopleReady, selectedAccount, connectWallet } = usePezkuwi();
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [pendingApps, setPendingApps] = useState<PendingApplication[]>([]);
const [identities, setIdentities] = useState<Map<string, IdentityInfo>>(new Map());
const [selectedApp, setSelectedApp] = useState<PendingApplication | null>(null);
const [processing, setProcessing] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [pendingApps, setPendingApps] = useState<PendingApproval[]>([]);
const [processingAddress, setProcessingAddress] = useState<string | null>(null);
// Load pending KYC applications from People Chain
// Load pending applications where current user is the referrer
useEffect(() => {
if (!peopleApi || !isPeopleReady) {
if (!peopleApi || !isPeopleReady || !selectedAccount) {
setLoading(false);
return;
}
loadPendingApplications();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peopleApi, isPeopleReady]);
}, [peopleApi, isPeopleReady, selectedAccount]);
const loadPendingApplications = async () => {
// identityKyc pallet is on People Chain
if (!peopleApi || !isPeopleReady) {
if (!peopleApi || !isPeopleReady || !selectedAccount) {
setLoading(false);
return;
}
setLoading(true);
try {
// Get all pending applications from People Chain
const entries = await peopleApi.query.identityKyc.pendingKycApplications.entries();
const apps: PendingApplication[] = [];
const identityMap = new Map<string, IdentityInfo>();
for (const [key, value] of entries) {
const address = key.args[0].toString();
const application = value.toJSON() as Record<string, unknown>;
// Get identity info for this address from People Chain
try {
const identity = await peopleApi.query.identityKyc.identities(address);
if (!identity.isEmpty) {
const identityData = identity.toJSON() as Record<string, unknown>;
identityMap.set(address, {
name: identityData.name || 'Unknown',
email: identityData.email || 'No email'
});
}
} catch (err) {
if (import.meta.env.DEV) console.error('Error fetching identity for', address, err);
}
apps.push({
address,
cids: application.cids || [],
notes: application.notes || 'No notes provided',
timestamp: application.timestamp || Date.now()
});
}
const apps = await getPendingApprovalsForReferrer(peopleApi, selectedAccount.address);
setPendingApps(apps);
setIdentities(identityMap);
if (import.meta.env.DEV) console.log(`Loaded ${apps.length} pending KYC applications`);
if (import.meta.env.DEV) console.log(`Loaded ${apps.length} pending referral approvals`);
} catch (error) {
if (import.meta.env.DEV) console.error('Error loading pending applications:', error);
toast({
@@ -116,233 +61,55 @@ export function KycApprovalTab() {
}
};
const handleApprove = async (application: PendingApplication) => {
if (!api || !selectedAccount) {
const handleApproveReferral = async (applicantAddress: string) => {
if (!peopleApi || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your admin wallet first',
description: 'Please connect your wallet first',
variant: 'destructive',
});
return;
}
setProcessing(true);
setProcessingAddress(applicantAddress);
try {
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
const result = await approveReferral(peopleApi, selectedAccount, applicantAddress);
if (import.meta.env.DEV) console.log('Proposing KYC approval for:', application.address);
if (import.meta.env.DEV) console.log('Commission member wallet:', selectedAccount.address);
// Check if user is a member of DynamicCommissionCollective
const members = await api.query.dynamicCommissionCollective.members();
const memberList = members.toJSON() as string[];
const isMember = memberList.includes(selectedAccount.address);
if (!isMember) {
if (!result.success) {
toast({
title: 'Not a Commission Member',
description: 'You are not a member of the KYC Approval Commission',
title: 'Approval Failed',
description: result.error || 'Failed to approve referral',
variant: 'destructive',
});
setProcessing(false);
return;
}
if (import.meta.env.DEV) console.log('✅ User is commission member');
// Create proposal for KYC approval
const proposal = api.tx.identityKyc.approveKyc(application.address);
const lengthBound = proposal.encodedLength;
// Create proposal directly (no proxy needed)
if (import.meta.env.DEV) console.log('Creating commission proposal for KYC approval');
if (import.meta.env.DEV) console.log('Applicant:', application.address);
if (import.meta.env.DEV) console.log('Threshold:', COMMISSIONS.KYC.threshold);
const tx = api.tx.dynamicCommissionCollective.propose(
COMMISSIONS.KYC.threshold,
proposal,
lengthBound
);
if (import.meta.env.DEV) console.log('Transaction created:', tx.toHuman());
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError, events }) => {
if (import.meta.env.DEV) 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();
}
if (import.meta.env.DEV) console.error('Approval error:', errorMessage);
toast({
title: 'Approval Failed',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
return;
}
// Check for Proposed event
if (import.meta.env.DEV) console.log('All events:', events.map(e => `${e.event.section}.${e.event.method}`));
const proposedEvent = events.find(({ event }) =>
event.section === 'dynamicCommissionCollective' && event.method === 'Proposed'
);
if (proposedEvent) {
if (import.meta.env.DEV) console.log('✅ KYC Approval proposal created');
toast({
title: 'Proposal Created',
description: `KYC approval proposed for ${application.address.slice(0, 8)}... Waiting for other commission members to vote.`,
});
resolve();
} else {
if (import.meta.env.DEV) console.warn('Transaction included but no Proposed event');
resolve();
}
}
}
).catch((error) => {
if (import.meta.env.DEV) console.error('Failed to sign and send:', error);
toast({
title: 'Transaction Error',
description: error instanceof Error ? error.message : 'Failed to submit transaction',
variant: 'destructive',
});
reject(error);
});
toast({
title: 'Referral Approved',
description: `Successfully vouched for ${applicantAddress.slice(0, 8)}...${applicantAddress.slice(-4)}`,
});
// Reload applications after approval
setTimeout(() => {
loadPendingApplications();
setShowDetailsModal(false);
setSelectedApp(null);
}, 2000);
// Reload after approval
setTimeout(() => loadPendingApplications(), 2000);
} catch (error) {
if (import.meta.env.DEV) console.error('Error approving KYC:', error);
if (import.meta.env.DEV) console.error('Error approving referral:', error);
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to approve KYC',
description: error instanceof Error ? error.message : 'Failed to approve referral',
variant: 'destructive',
});
} finally {
setProcessing(false);
setProcessingAddress(null);
}
};
const handleReject = async (application: PendingApplication) => {
if (!api || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your admin wallet first',
variant: 'destructive',
});
return;
}
const confirmReject = window.confirm(
`Are you sure you want to REJECT KYC for ${application.address}?\n\nThis will slash their deposit.`
);
if (!confirmReject) return;
setProcessing(true);
try {
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
if (import.meta.env.DEV) console.log('Rejecting KYC for:', application.address);
const tx = api.tx.identityKyc.rejectKyc(application.address);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError, events }) => {
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();
}
toast({
title: 'Rejection Failed',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
return;
}
const rejectedEvent = events.find(({ event }) =>
event.section === 'identityKyc' && event.method === 'KycRejected'
);
if (rejectedEvent) {
toast({
title: 'Rejected',
description: `KYC rejected for ${application.address.slice(0, 8)}...`,
});
resolve();
} else {
resolve();
}
}
}
).catch(reject);
});
setTimeout(() => {
loadPendingApplications();
setShowDetailsModal(false);
setSelectedApp(null);
}, 2000);
} catch (error) {
if (import.meta.env.DEV) console.error('Error rejecting KYC:', error);
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to reject KYC',
variant: 'destructive',
});
} finally {
setProcessing(false);
}
};
const openDetailsModal = (app: PendingApplication) => {
setSelectedApp(app);
setShowDetailsModal(true);
};
if (!isApiReady) {
if (!isPeopleReady) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
<span className="ml-3 text-gray-400">Connecting to blockchain...</span>
<span className="ml-3 text-gray-400">Connecting to People Chain...</span>
</div>
</CardContent>
</Card>
@@ -356,7 +123,7 @@ export function KycApprovalTab() {
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Please connect your admin wallet to view and approve KYC applications.
Please connect your wallet to view referral approvals.
<Button onClick={connectWallet} variant="outline" className="ml-4">
Connect Wallet
</Button>
@@ -368,196 +135,82 @@ export function KycApprovalTab() {
}
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Pending KYC Applications</CardTitle>
<Button onClick={loadPendingApplications} variant="outline" size="sm" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Refresh'}
</Button>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
) : pendingApps.length === 0 ? (
<div className="text-center py-12">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="text-gray-400">No pending applications</p>
<p className="text-sm text-gray-600 mt-2">All KYC applications have been processed</p>
</div>
) : (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Pending Referral Approvals</CardTitle>
<Button onClick={loadPendingApplications} variant="outline" size="sm" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Refresh'}
</Button>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
) : pendingApps.length === 0 ? (
<div className="text-center py-12">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="text-gray-400">No pending approvals</p>
<p className="text-sm text-gray-600 mt-2">No one is waiting for your referral approval</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground mb-4">
These users listed you as their referrer. Approve to vouch for their identity.
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Applicant</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Documents</TableHead>
<TableHead>Identity Hash</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingApps.map((app) => {
const identity = identities.get(app.address);
return (
<TableRow key={app.address}>
<TableCell className="font-mono text-xs">
{app.address.slice(0, 6)}...{app.address.slice(-4)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
{identity?.name || 'Loading...'}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400" />
{identity?.email || 'Loading...'}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
<FileText className="w-3 h-3 mr-1" />
{app.cids.length} CID(s)
</Badge>
</TableCell>
<TableCell>
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">
<Clock className="w-3 h-3 mr-1" />
Pending
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openDetailsModal(app)}
>
View Details
</Button>
</div>
</TableCell>
</TableRow>
);
})}
{pendingApps.map((app) => (
<TableRow key={app.applicantAddress}>
<TableCell>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
<span className="font-mono text-xs">
{app.applicantAddress.slice(0, 8)}...{app.applicantAddress.slice(-6)}
</span>
</div>
</TableCell>
<TableCell>
<span className="font-mono text-xs text-gray-500">
{app.identityHash ? `${app.identityHash.slice(0, 12)}...` : 'N/A'}
</span>
</TableCell>
<TableCell>
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">
<Clock className="w-3 h-3 mr-1" />
Pending Referral
</Badge>
</TableCell>
<TableCell>
<Button
size="sm"
onClick={() => handleApproveReferral(app.applicantAddress)}
disabled={processingAddress === app.applicantAddress}
className="bg-green-600 hover:bg-green-700"
>
{processingAddress === app.applicantAddress ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
Approve
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Details Modal */}
<Dialog open={showDetailsModal} onOpenChange={setShowDetailsModal}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>KYC Application Details</DialogTitle>
<DialogDescription>
Review application before approving or rejecting
</DialogDescription>
</DialogHeader>
{selectedApp && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-gray-400">Applicant Address</Label>
<p className="font-mono text-sm mt-1">{selectedApp.address}</p>
</div>
<div>
<Label className="text-gray-400">Name</Label>
<p className="text-sm mt-1">{identities.get(selectedApp.address)?.name || 'Unknown'}</p>
</div>
<div>
<Label className="text-gray-400">Email</Label>
<p className="text-sm mt-1">{identities.get(selectedApp.address)?.email || 'No email'}</p>
</div>
<div>
<Label className="text-gray-400">Application Time</Label>
<p className="text-sm mt-1">
{selectedApp.timestamp
? new Date(selectedApp.timestamp).toLocaleString()
: 'Unknown'}
</p>
</div>
</div>
<div>
<Label className="text-gray-400">Notes</Label>
<p className="text-sm mt-1 p-3 bg-gray-800/50 rounded border border-gray-700">
{selectedApp.notes}
</p>
</div>
<div>
<Label className="text-gray-400">IPFS Documents ({selectedApp.cids.length})</Label>
<div className="mt-2 space-y-2">
{selectedApp.cids.map((cid, index) => (
<div key={index} className="p-2 bg-gray-800/50 rounded border border-gray-700">
<p className="font-mono text-xs">{cid}</p>
<a
href={`https://ipfs.io/ipfs/${cid}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 text-xs"
>
View on IPFS
</a>
</div>
))}
</div>
</div>
<Alert className="bg-yellow-500/10 border-yellow-500/30">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
<strong>Important:</strong> Approving this application will:
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Unreserve the applicant&apos;s deposit</li>
<li>Mint a Welati (Citizen) NFT automatically</li>
<li>Enable trust score tracking</li>
<li>Grant governance voting rights</li>
</ul>
</AlertDescription>
</Alert>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setShowDetailsModal(false)}
disabled={processing}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => selectedApp && handleReject(selectedApp)}
disabled={processing}
>
{processing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <XCircle className="w-4 h-4 mr-2" />}
Reject
</Button>
<Button
onClick={() => selectedApp && handleApprove(selectedApp)}
disabled={processing}
className="bg-green-600 hover:bg-green-700"
>
{processing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
Approve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
</>
)}
</CardContent>
</Card>
);
}
function Label({ children, className }: { children: React.ReactNode; className?: string }) {
return <label className={`text-sm font-medium ${className}`}>{children}</label>;
}
@@ -10,9 +10,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2, AlertTriangle, CheckCircle, User, Users as UsersIcon, MapPin, Briefcase, Mail, Check, X, AlertCircle } from 'lucide-react';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import type { CitizenshipData, Region, MaritalStatus } from '@pezkuwi/lib/citizenship-workflow';
import { FOUNDER_ADDRESS, submitKycApplication, subscribeToKycApproval, getKycStatus } from '@pezkuwi/lib/citizenship-workflow';
import { generateCommitmentHash, generateNullifierHash, encryptData, saveLocalCitizenshipData, uploadToIPFS } from '@pezkuwi/lib/citizenship-workflow';
import { blake2AsHex } from '@pezkuwi/util-crypto';
import type { CitizenshipData, Region, MaritalStatus, KycStatus } from '@pezkuwi/lib/citizenship-workflow';
import { FOUNDER_ADDRESS, submitKycApplication, subscribeToKycApproval, getKycStatus, cancelApplication, confirmCitizenship } from '@pezkuwi/lib/citizenship-workflow';
import { encryptData, saveLocalCitizenshipData, uploadToIPFS } from '@pezkuwi/lib/citizenship-workflow';
interface NewCitizenApplicationProps {
onClose: () => void;
@@ -28,83 +29,75 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [waitingForApproval, setWaitingForApproval] = useState(false);
const [currentStatus, setCurrentStatus] = useState<KycStatus>('NotStarted');
const [kycApproved, setKycApproved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [agreed, setAgreed] = useState(false);
const [confirming, setConfirming] = useState(false);
const [canceling, setCanceling] = useState(false);
const [applicationHash, setApplicationHash] = useState<string>('');
const maritalStatus = watch('maritalStatus');
const childrenCount = watch('childrenCount');
const handleApprove = async () => {
// identityKyc pallet is on People Chain
const handleConfirmCitizenship = async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) {
setError('Please connect your wallet and wait for People Chain connection');
return;
}
setConfirming(true);
setError(null);
try {
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
const result = await confirmCitizenship(peopleApi, selectedAccount);
if (import.meta.env.DEV) console.log('Confirming citizenship application on People Chain (self-confirmation)...');
if (!result.success) {
setError(result.error || 'Failed to confirm citizenship');
setConfirming(false);
return;
}
// Call confirm_citizenship() extrinsic on People Chain - self-confirmation for Welati Tiki
const tx = peopleApi.tx.identityKyc.confirmCitizenship();
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, events, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
if (import.meta.env.DEV) console.error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`);
setError(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`);
} else {
if (import.meta.env.DEV) console.error(dispatchError.toString());
setError(dispatchError.toString());
}
setConfirming(false);
return;
}
if (status.isInBlock || status.isFinalized) {
if (import.meta.env.DEV) console.log('✅ Citizenship confirmed successfully on People Chain!');
if (import.meta.env.DEV) console.log('Block hash:', status.asInBlock || status.asFinalized);
// Check for CitizenshipConfirmed event
events.forEach(({ event }) => {
if (event.section === 'identityKyc' && event.method === 'CitizenshipConfirmed') {
if (import.meta.env.DEV) console.log('📢 CitizenshipConfirmed event detected');
setKycApproved(true);
setWaitingForApproval(false);
// Redirect to citizen dashboard after 2 seconds
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
}
});
setConfirming(false);
}
});
setKycApproved(true);
setCurrentStatus('Approved');
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
} catch (err) {
if (import.meta.env.DEV) console.error('Approval error:', err);
setError((err as Error).message || 'Failed to approve application');
if (import.meta.env.DEV) console.error('Confirmation error:', err);
setError((err as Error).message || 'Failed to confirm citizenship');
} finally {
setConfirming(false);
}
};
const handleReject = async () => {
// Cancel/withdraw the application - simply close modal and go back
// No blockchain interaction needed - application will remain Pending until confirmed or admin-rejected
if (import.meta.env.DEV) console.log('Canceling citizenship application (no blockchain interaction)');
onClose();
window.location.href = '/';
const handleCancelApplication = async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) {
setError('Please connect your wallet and wait for People Chain connection');
return;
}
setCanceling(true);
setError(null);
try {
const result = await cancelApplication(peopleApi, selectedAccount);
if (!result.success) {
setError(result.error || 'Failed to cancel application');
setCanceling(false);
return;
}
if (import.meta.env.DEV) console.log('Application canceled, deposit returned');
onClose();
window.location.href = '/';
} catch (err) {
if (import.meta.env.DEV) console.error('Cancel error:', err);
setError((err as Error).message || 'Failed to cancel application');
} finally {
setCanceling(false);
}
};
// Check KYC status on mount (identityKyc pallet is on People Chain)
@@ -117,19 +110,14 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
try {
const status = await getKycStatus(peopleApi, selectedAccount.address);
if (import.meta.env.DEV) console.log('Current KYC Status from People Chain:', status);
setCurrentStatus(status);
if (status === 'Approved') {
if (import.meta.env.DEV) console.log('KYC already approved! Redirecting to dashboard...');
setKycApproved(true);
// Redirect to dashboard after 2 seconds
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
} else if (status === 'Pending') {
// If pending, show the waiting screen
setWaitingForApproval(true);
}
} catch (err) {
if (import.meta.env.DEV) console.error('Error checking KYC status:', err);
@@ -139,31 +127,34 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
checkKycStatus();
}, [peopleApi, isPeopleReady, selectedAccount, onClose]);
// Subscribe to KYC approval events on People Chain
// Subscribe to citizenship events on People Chain
useEffect(() => {
if (!peopleApi || !isPeopleReady || !selectedAccount || !waitingForApproval) {
const isPending = currentStatus === 'PendingReferral' || currentStatus === 'ReferrerApproved';
if (!peopleApi || !isPeopleReady || !selectedAccount || !isPending) {
return;
}
if (import.meta.env.DEV) console.log('Setting up KYC approval listener on People Chain for:', selectedAccount.address);
if (import.meta.env.DEV) console.log('Setting up citizenship event listener on People Chain for:', selectedAccount.address);
const unsubscribe = subscribeToKycApproval(
peopleApi,
selectedAccount.address,
() => {
if (import.meta.env.DEV) console.log('KYC Approved on People Chain! Redirecting to dashboard...');
// CitizenshipConfirmed
setKycApproved(true);
setWaitingForApproval(false);
// Redirect to citizen dashboard after 2 seconds
setCurrentStatus('Approved');
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
},
(error) => {
if (import.meta.env.DEV) console.error('KYC approval subscription error:', error);
setError(`Failed to monitor approval status: ${error}`);
if (import.meta.env.DEV) console.error('Citizenship event subscription error:', error);
setError(`Failed to monitor status: ${error}`);
},
() => {
// ReferralApproved - referrer vouched, now user can confirm
setCurrentStatus('ReferrerApproved');
}
);
@@ -172,7 +163,7 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
unsubscribe();
}
};
}, [peopleApi, isPeopleReady, selectedAccount, waitingForApproval, onClose]);
}, [peopleApi, isPeopleReady, selectedAccount, currentStatus, onClose]);
const onSubmit = async (data: FormData) => {
// identityKyc pallet is on People Chain
@@ -191,10 +182,10 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
try {
// Check KYC status before submitting (from People Chain)
const currentStatus = await getKycStatus(peopleApi, selectedAccount.address);
const status = await getKycStatus(peopleApi, selectedAccount.address);
if (currentStatus === 'Approved') {
setError('Your KYC has already been approved! Redirecting to dashboard...');
if (status === 'Approved') {
setError('Your citizenship is already approved! Redirecting to dashboard...');
setKycApproved(true);
setTimeout(() => {
onClose();
@@ -203,82 +194,71 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
return;
}
if (currentStatus === 'Pending') {
setError('You already have a pending KYC application. Please wait for admin approval.');
setWaitingForApproval(true);
if (status === 'PendingReferral' || status === 'ReferrerApproved') {
setError('You already have a pending citizenship application.');
setCurrentStatus(status);
return;
}
// Note: Referral initiation must be done by the REFERRER before the referee does KYC
// The referrer calls api.tx.referral.initiateReferral(refereeAddress) from InviteUserModal
// Here we just use the referrerAddress in the citizenship data if provided
if (referrerAddress) {
if (import.meta.env.DEV) console.log(`KYC application with referrer: ${referrerAddress}`);
}
// Prepare complete citizenship data
const citizenshipData: CitizenshipData = {
...data,
walletAddress: selectedAccount.address,
timestamp: Date.now(),
referralCode: data.referralCode || FOUNDER_ADDRESS // Auto-assign to founder if empty
referralCode: data.referralCode || referrerAddress || undefined
};
// Generate commitment and nullifier hashes
const commitmentHash = await generateCommitmentHash(citizenshipData);
const nullifierHash = await generateNullifierHash(selectedAccount.address, citizenshipData.timestamp);
// Compute identity hash (H256) from citizenship data
const identityHash = blake2AsHex(
JSON.stringify({
fullName: citizenshipData.fullName,
fatherName: citizenshipData.fatherName,
grandfatherName: citizenshipData.grandfatherName,
motherName: citizenshipData.motherName,
tribe: citizenshipData.tribe,
region: citizenshipData.region,
email: citizenshipData.email,
profession: citizenshipData.profession,
walletAddress: citizenshipData.walletAddress
}),
256
);
if (import.meta.env.DEV) console.log('Commitment Hash:', commitmentHash);
if (import.meta.env.DEV) console.log('Nullifier Hash:', nullifierHash);
if (import.meta.env.DEV) console.log('Identity Hash:', identityHash);
// Encrypt data
const encryptedData = await encryptData(citizenshipData, selectedAccount.address);
// Save to local storage (backup)
await saveLocalCitizenshipData(citizenshipData, selectedAccount.address);
// Upload to IPFS
// Encrypt data and upload to IPFS as off-chain backup
const encryptedData = encryptData(citizenshipData);
saveLocalCitizenshipData(citizenshipData);
const ipfsCid = await uploadToIPFS(encryptedData);
if (import.meta.env.DEV) console.log('IPFS CID (off-chain backup):', ipfsCid);
if (import.meta.env.DEV) console.log('IPFS CID:', ipfsCid);
if (import.meta.env.DEV) console.log('IPFS CID type:', typeof ipfsCid);
if (import.meta.env.DEV) console.log('IPFS CID value:', JSON.stringify(ipfsCid));
// Determine referrer: explicit referrer address > referral code > default (pallet uses DefaultReferrer)
const effectiveReferrer = referrerAddress || data.referralCode || undefined;
// Ensure ipfsCid is a string
const cidString = String(ipfsCid);
if (!cidString || cidString === 'undefined' || cidString === '[object Object]') {
throw new Error(`Invalid IPFS CID: ${cidString}`);
}
// Submit to blockchain (identityKyc pallet is on People Chain)
if (import.meta.env.DEV) console.log('Submitting KYC application to People Chain...');
// Submit to blockchain - single call: applyForCitizenship(identity_hash, referrer)
if (import.meta.env.DEV) console.log('Submitting citizenship application to People Chain...');
const result = await submitKycApplication(
peopleApi,
selectedAccount,
citizenshipData.fullName,
citizenshipData.email,
cidString,
`Citizenship application for ${citizenshipData.fullName}`
identityHash,
effectiveReferrer
);
if (!result.success) {
setError(result.error || 'Failed to submit KYC application to blockchain');
setError(result.error || 'Failed to submit citizenship application');
setSubmitting(false);
return;
}
if (import.meta.env.DEV) console.log('✅ KYC application submitted to blockchain');
if (import.meta.env.DEV) console.log('Block hash:', result.blockHash);
if (import.meta.env.DEV) console.log('Citizenship application submitted to blockchain');
// Save block hash for display
if (result.blockHash) {
setApplicationHash(result.blockHash.slice(0, 16) + '...');
}
// Move to waiting for approval state
setSubmitted(true);
setSubmitting(false);
setWaitingForApproval(true);
setCurrentStatus('PendingReferral');
} catch (err) {
if (import.meta.env.DEV) console.error('Submission error:', err);
@@ -320,34 +300,32 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
);
}
// Waiting for self-confirmation
if (waitingForApproval) {
// PendingReferral - waiting for referrer to approve
if (currentStatus === 'PendingReferral') {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-6">
{/* Icon */}
<div className="relative">
<div className="h-24 w-24 rounded-full border-4 border-primary/20 flex items-center justify-center">
<CheckCircle className="h-10 w-10 text-primary" />
<div className="h-24 w-24 rounded-full border-4 border-yellow-500/20 flex items-center justify-center">
<Loader2 className="h-10 w-10 text-yellow-500 animate-spin" />
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Confirm Your Citizenship Application</h3>
<h3 className="text-lg font-semibold">Waiting for Referrer Approval</h3>
<p className="text-sm text-muted-foreground max-w-md">
Your application has been submitted to the blockchain. Please review and confirm your identity to mint your Citizen NFT (Welati Tiki).
Your application has been submitted. Your referrer needs to vouch for your identity before you can proceed.
</p>
</div>
{/* Status steps */}
<div className="w-full max-w-md space-y-3 pt-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Data Encrypted</p>
<p className="text-xs text-muted-foreground">Your KYC data has been encrypted and stored on IPFS</p>
<p className="text-sm font-medium">Application Submitted</p>
<p className="text-xs text-muted-foreground">Transaction hash: {applicationHash || 'Confirmed'}</p>
</div>
</div>
@@ -356,8 +334,92 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Blockchain Submitted</p>
<p className="text-xs text-muted-foreground">Transaction hash: {applicationHash || 'Processing...'}</p>
<p className="text-sm font-medium">1 HEZ Deposit Reserved</p>
<p className="text-xs text-muted-foreground">Deposit will be returned if you cancel</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900">
<Loader2 className="h-5 w-5 text-yellow-600 dark:text-yellow-400 animate-spin" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Waiting for Referrer</p>
<p className="text-xs text-muted-foreground">Your referrer must approve your identity</p>
</div>
</div>
</div>
<div className="flex gap-3 w-full max-w-md pt-4">
<Button
onClick={handleCancelApplication}
disabled={canceling}
variant="destructive"
className="flex-1"
>
{canceling ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Canceling...
</>
) : (
<>
<X className="h-4 w-4 mr-2" />
Cancel Application
</>
)}
</Button>
</div>
{error && (
<Alert variant="destructive" className="w-full max-w-md">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button variant="outline" onClick={onClose} className="mt-2">
Close
</Button>
</CardContent>
</Card>
);
}
// ReferrerApproved - referrer vouched, now user can confirm
if (currentStatus === 'ReferrerApproved') {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-6">
<div className="relative">
<div className="h-24 w-24 rounded-full border-4 border-green-500/20 flex items-center justify-center">
<CheckCircle className="h-10 w-10 text-green-500" />
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold text-green-600">Referrer Approved!</h3>
<p className="text-sm text-muted-foreground max-w-md">
Your referrer has vouched for you. Confirm your citizenship to mint your Welati Tiki NFT.
</p>
</div>
<div className="w-full max-w-md space-y-3 pt-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Application Submitted</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Referrer Approved</p>
</div>
</div>
@@ -366,16 +428,15 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Awaiting Your Confirmation</p>
<p className="text-xs text-muted-foreground">Confirm or reject your application below</p>
<p className="text-sm font-medium">Confirm Citizenship</p>
<p className="text-xs text-muted-foreground">Click below to mint your Welati Tiki NFT</p>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 w-full max-w-md pt-4">
<Button
onClick={handleApprove}
onClick={handleConfirmCitizenship}
disabled={confirming}
className="flex-1 bg-green-600 hover:bg-green-700"
>
@@ -387,25 +448,7 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
) : (
<>
<Check className="h-4 w-4 mr-2" />
Approve
</>
)}
</Button>
<Button
onClick={handleReject}
disabled={confirming}
variant="destructive"
className="flex-1"
>
{confirming ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Rejecting...
</>
) : (
<>
<X className="h-4 w-4 mr-2" />
Reject
Confirm Citizenship
</>
)}
</Button>
@@ -427,7 +470,7 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
}
// Initial submission success (before blockchain confirmation)
if (submitted && !waitingForApproval) {
if (submitted && currentStatus === 'NotStarted') {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-4">
@@ -630,6 +673,21 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
</CardContent>
</Card>
{/* Deposit Notice */}
<Card className="bg-yellow-500/10 border-yellow-500/30">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-semibold text-yellow-600">1 HEZ Deposit Required</p>
<p className="text-muted-foreground mt-1">
A deposit of 1 HEZ will be reserved when you submit your application. It will be returned if you cancel your application.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Terms Agreement */}
<Card>
<CardContent className="pt-6 space-y-4">
@@ -21,7 +21,7 @@ interface InviteUserModalProps {
}
export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClose }) => {
const { api, selectedAccount } = usePezkuwi();
const { peopleApi, isPeopleReady, selectedAccount } = usePezkuwi();
const [copied, setCopied] = useState(false);
const [inviteeAddress, setInviteeAddress] = useState('');
const [initiating, setInitiating] = useState(false);
@@ -69,8 +69,8 @@ export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClos
};
const handleInitiateReferral = async () => {
if (!api || !selectedAccount || !inviteeAddress) {
setInitiateError('Please enter a valid address');
if (!peopleApi || !isPeopleReady || !selectedAccount || !inviteeAddress) {
setInitiateError('Please connect wallet and enter a valid address');
return;
}
@@ -84,13 +84,14 @@ export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClos
if (import.meta.env.DEV) console.log(`Initiating referral from ${selectedAccount.address} to ${inviteeAddress}...`);
const tx = api.tx.referral.initiateReferral(inviteeAddress);
// referral pallet is on People Chain
const tx = peopleApi.tx.referral.initiateReferral(inviteeAddress);
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => {
if (dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else {
errorMessage = dispatchError.toString();
@@ -110,7 +111,7 @@ export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClos
});
} catch (err: unknown) {
if (import.meta.env.DEV) console.error('Failed to initiate referral:', err);
setInitiateError(err.message || 'Failed to initiate referral');
setInitiateError(err instanceof Error ? err.message : 'Failed to initiate referral');
setInitiating(false);
}
};
@@ -1,13 +1,72 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useReferral } from '@/contexts/ReferralContext';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { InviteUserModal } from './InviteUserModal';
import { Users, UserPlus, Trophy, Award, Loader2 } from 'lucide-react';
import { Users, UserPlus, Trophy, Award, Loader2, CheckCircle, Clock, User } from 'lucide-react';
import { getPendingApprovalsForReferrer, approveReferral } from '@pezkuwi/lib/citizenship-workflow';
import type { PendingApproval } from '@pezkuwi/lib/citizenship-workflow';
export const ReferralDashboard: React.FC = () => {
const { stats, myReferrals, loading } = useReferral();
const { peopleApi, isPeopleReady, selectedAccount } = usePezkuwi();
const { toast } = useToast();
const [showInviteModal, setShowInviteModal] = useState(false);
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
const [loadingApprovals, setLoadingApprovals] = useState(false);
const [processingAddress, setProcessingAddress] = useState<string | null>(null);
// Load pending approvals for this referrer
useEffect(() => {
if (!peopleApi || !isPeopleReady || !selectedAccount) return;
const loadApprovals = async () => {
setLoadingApprovals(true);
try {
const approvals = await getPendingApprovalsForReferrer(peopleApi, selectedAccount.address);
setPendingApprovals(approvals);
} catch (err) {
if (import.meta.env.DEV) console.error('Error loading pending approvals:', err);
} finally {
setLoadingApprovals(false);
}
};
loadApprovals();
}, [peopleApi, isPeopleReady, selectedAccount]);
const handleApprove = async (applicantAddress: string) => {
if (!peopleApi || !selectedAccount) return;
setProcessingAddress(applicantAddress);
try {
const result = await approveReferral(peopleApi, selectedAccount, applicantAddress);
if (result.success) {
toast({
title: 'Referral Approved',
description: `Vouched for ${applicantAddress.slice(0, 8)}...${applicantAddress.slice(-4)}`,
});
setPendingApprovals(prev => prev.filter(a => a.applicantAddress !== applicantAddress));
} else {
toast({
title: 'Approval Failed',
description: result.error || 'Failed to approve',
variant: 'destructive',
});
}
} catch (err) {
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to approve referral',
variant: 'destructive',
});
} finally {
setProcessingAddress(null);
}
};
if (loading) {
return (
@@ -135,6 +194,66 @@ export const ReferralDashboard: React.FC = () => {
</CardContent>
</Card>
{/* Pending Approvals */}
{(pendingApprovals.length > 0 || loadingApprovals) && (
<Card className="bg-yellow-900/20 border-yellow-600/30">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-500" />
Pending Approvals ({pendingApprovals.length})
</CardTitle>
<CardDescription className="text-gray-400">
These users listed you as their referrer and are waiting for your approval
</CardDescription>
</CardHeader>
<CardContent>
{loadingApprovals ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-yellow-500" />
</div>
) : (
<div className="space-y-2">
{pendingApprovals.map((approval) => (
<div
key={approval.applicantAddress}
className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-yellow-600/20 flex items-center justify-center">
<User className="w-4 h-4 text-yellow-400" />
</div>
<div>
<div className="text-sm font-mono text-white">
{approval.applicantAddress.slice(0, 10)}...{approval.applicantAddress.slice(-8)}
</div>
<div className="text-xs text-gray-500">
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30 text-[10px] px-1 py-0">
Pending Referral
</Badge>
</div>
</div>
</div>
<Button
size="sm"
onClick={() => handleApprove(approval.applicantAddress)}
disabled={processingAddress === approval.applicantAddress}
className="bg-green-600 hover:bg-green-700"
>
{processingAddress === approval.applicantAddress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<CheckCircle className="w-4 h-4 mr-1" />
)}
Approve
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* My Referrals List */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
+2 -2
View File
@@ -176,8 +176,8 @@ const BeCitizen: React.FC = () => {
<CardContent className="text-gray-700 font-medium space-y-2 text-sm">
<p> Fill detailed KYC application</p>
<p> Data encrypted with ZK-proofs</p>
<p> Submit for admin approval</p>
<p> Receive your Welati Tiki NFT</p>
<p> Your referrer approves your identity</p>
<p> Confirm and receive your Welati Tiki NFT</p>
</CardContent>
</Card>