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;