feat: Phase 3 - P2P Fiat Trading System (Production-Ready)

Major Updates:
- Footer improvements: English-only text, proper alignment, professional icons
- DEX Pool implementation with AMM-based token swapping
- Enhanced dashboard with DashboardContext for centralized data
- New Citizens section and government entrance page

DEX Features:
- Token swap interface with price impact calculation
- Pool management (add/remove liquidity)
- Founder-only admin panel for pool creation
- HEZ wrapping functionality (wHEZ)
- Multiple token support (HEZ, wHEZ, USDT, USDC, BTC)

UI/UX Improvements:
- Footer: Removed distracting images, added Mail icons, English text
- Footer: Proper left alignment for all sections
- DEX Dashboard: Founder access badge, responsive tabs
- Back to home navigation in DEX interface

Component Structure:
- src/components/dex/: DEX-specific components
- src/components/admin/: Admin panel components
- src/components/dashboard/: Dashboard widgets
- src/contexts/DashboardContext.tsx: Centralized dashboard state

Shared Libraries:
- shared/lib/kyc.ts: KYC status management
- shared/lib/citizenship-workflow.ts: Citizenship flow
- shared/utils/dex.ts: DEX calculations and utilities

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-19 05:44:48 +03:00
parent 2df29a6395
commit 51028e6344
18 changed files with 5886 additions and 68 deletions
@@ -0,0 +1,476 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/use-toast';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { Loader2, Plus, CheckCircle, AlertTriangle, Shield } from 'lucide-react';
import { COMMISSIONS } from '@/config/commissions';
import { Alert, AlertDescription } from '@/components/ui/alert';
export function CommissionSetupTab() {
const { api, isApiReady, selectedAccount } = usePolkadot();
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [commissionMembers, setCommissionMembers] = useState<string[]>([]);
const [proxyMembers, setProxyMembers] = useState<string[]>([]);
const [setupComplete, setSetupComplete] = useState(false);
const [processing, setProcessing] = useState(false);
const [newMemberAddress, setNewMemberAddress] = useState('');
useEffect(() => {
if (!api || !isApiReady) return;
checkSetup();
}, [api, isApiReady]);
const checkSetup = async () => {
if (!api) return;
setLoading(true);
try {
// Check DynamicCommissionCollective members
const members = await api.query.dynamicCommissionCollective.members();
const memberList = members.toJSON() as string[];
setCommissionMembers(memberList);
// Commission is initialized if there's at least one member
setSetupComplete(memberList.length > 0);
console.log('Commission members:', memberList);
console.log('Setup complete:', memberList.length > 0);
} catch (error) {
console.error('Error checking setup:', error);
} finally {
setLoading(false);
}
};
const handleAddMember = async () => {
if (!api || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your admin wallet',
variant: 'destructive',
});
return;
}
if (!newMemberAddress) {
toast({
title: 'No Addresses',
description: 'Please enter at least one address',
variant: 'destructive',
});
return;
}
setProcessing(true);
try {
const { web3FromAddress } = await import('@polkadot/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
// Parse addresses (one per line, trim whitespace)
const newAddresses = newMemberAddress
.split('\n')
.map(addr => addr.trim())
.filter(addr => addr.length > 0);
if (newAddresses.length === 0) {
toast({
title: 'No Valid Addresses',
description: 'Please enter at least one valid address',
variant: 'destructive',
});
setProcessing(false);
return;
}
// Get current members
const currentMembers = await api.query.dynamicCommissionCollective.members();
const memberList = (currentMembers.toJSON() as string[]) || [];
// Filter out already existing members
const newMembers = newAddresses.filter(addr => !memberList.includes(addr));
if (newMembers.length === 0) {
toast({
title: 'Already Members',
description: 'All addresses are already commission members',
variant: 'destructive',
});
setProcessing(false);
return;
}
// Add new members
const updatedList = [...memberList, ...newMembers];
console.log('Adding new members:', newMembers);
console.log('Updated member list:', updatedList);
const tx = api.tx.sudo.sudo(
api.tx.dynamicCommissionCollective.setMembers(
updatedList,
null,
updatedList.length
)
);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError }) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Failed to add member';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
} else {
toast({
title: 'Success',
description: `${newMembers.length} member(s) added successfully!`,
});
setNewMemberAddress('');
setTimeout(() => checkSetup(), 2000);
resolve();
}
}
}
);
});
} catch (error: any) {
console.error('Error adding member:', error);
toast({
title: 'Error',
description: error.message || 'Failed to add member',
variant: 'destructive',
});
} finally {
setProcessing(false);
}
};
const handleInitializeCommission = async () => {
if (!api || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your admin wallet',
variant: 'destructive',
});
return;
}
setProcessing(true);
try {
const { web3FromAddress } = await import('@polkadot/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
console.log('Initializing KYC Commission...');
console.log('Proxy account:', COMMISSIONS.KYC.proxyAccount);
// Initialize DynamicCommissionCollective with Alice as first member
// Other members can be added later
const tx = api.tx.sudo.sudo(
api.tx.dynamicCommissionCollective.setMembers(
[selectedAccount.address], // Add caller as first member
null,
1
)
);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.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('Setup error:', errorMessage);
toast({
title: 'Setup Failed',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
return;
}
// Check for Sudid event
const sudidEvent = events.find(({ event }) =>
event.section === 'sudo' && event.method === 'Sudid'
);
if (sudidEvent) {
console.log('✅ KYC Commission initialized');
toast({
title: 'Success',
description: 'KYC Commission initialized successfully!',
});
resolve();
} else {
console.warn('Transaction included but no Sudid event');
resolve();
}
}
}
).catch((error) => {
console.error('Failed to sign and send:', error);
toast({
title: 'Transaction Error',
description: error.message || 'Failed to submit transaction',
variant: 'destructive',
});
reject(error);
});
});
// Reload setup status
setTimeout(() => checkSetup(), 2000);
} catch (error: any) {
console.error('Error initializing commission:', error);
toast({
title: 'Error',
description: error.message || 'Failed to initialize commission',
variant: 'destructive',
});
} finally {
setProcessing(false);
}
};
if (!isApiReady) {
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>
</div>
</CardContent>
</Card>
);
}
if (!selectedAccount) {
return (
<Card>
<CardContent className="pt-6">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Please connect your admin wallet to manage commission setup.
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Setup Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
KYC Commission Setup
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-cyan-500" />
</div>
) : (
<>
<div className="flex items-center justify-between p-4 bg-gray-800/50 rounded-lg border border-gray-700">
<div>
<p className="font-medium">Commission Status</p>
<p className="text-sm text-gray-400 mt-1">
{setupComplete
? 'Commission is initialized and ready'
: 'Commission needs to be initialized'}
</p>
</div>
{setupComplete ? (
<Badge className="bg-green-600">
<CheckCircle className="w-3 h-3 mr-1" />
Ready
</Badge>
) : (
<Badge variant="secondary">
<AlertTriangle className="w-3 h-3 mr-1" />
Not Initialized
</Badge>
)}
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-400">Proxy Account</p>
<div className="p-3 bg-gray-800/50 rounded border border-gray-700">
<p className="font-mono text-xs">{COMMISSIONS.KYC.proxyAccount}</p>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-400">
Commission Members ({commissionMembers.length})
</p>
{commissionMembers.length === 0 ? (
<div className="p-4 bg-gray-800/50 rounded border border-gray-700 text-center text-gray-500">
No members yet
</div>
) : (
<div className="space-y-2">
{commissionMembers.map((member, index) => (
<div
key={index}
className="p-3 bg-gray-800/50 rounded border border-gray-700"
>
<p className="font-mono text-xs">{member}</p>
{member === COMMISSIONS.KYC.proxyAccount && (
<Badge className="mt-2 bg-cyan-600 text-xs">KYC Proxy</Badge>
)}
</div>
))}
</div>
)}
</div>
{!setupComplete && (
<Alert className="bg-yellow-500/10 border-yellow-500/30">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Required:</strong> Initialize the commission before members can join.
This requires sudo privileges.
</AlertDescription>
</Alert>
)}
{setupComplete && (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-400">Add Members</p>
<div className="flex gap-2 mb-2">
<Button
onClick={() => {
// Get wallet addresses from Polkadot.js extension
// For now, show instruction
toast({
title: 'Get Addresses',
description: 'Copy addresses from Polkadot.js wallet and paste below',
});
}}
variant="outline"
size="sm"
>
How to get addresses
</Button>
</div>
<div className="flex flex-col gap-2">
<textarea
placeholder="Paste addresses, one per line&#10;Example:&#10;5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty&#10;5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y"
value={newMemberAddress}
onChange={(e) => setNewMemberAddress(e.target.value)}
className="flex-1 font-mono text-sm p-3 bg-gray-800 border border-gray-700 rounded min-h-[120px]"
/>
<Button
onClick={handleAddMember}
disabled={processing || !newMemberAddress}
className="bg-cyan-600 hover:bg-cyan-700"
>
{processing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
{processing ? 'Adding Members...' : 'Add Members'}
</Button>
</div>
</div>
)}
<div className="flex gap-4">
<Button
onClick={handleInitializeCommission}
disabled={setupComplete || processing}
className="bg-green-600 hover:bg-green-700"
>
{processing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Initializing...
</>
) : setupComplete ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Already Initialized
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Initialize Commission
</>
)}
</Button>
<Button
onClick={checkSetup}
variant="outline"
disabled={loading}
>
Refresh
</Button>
</div>
</>
)}
</CardContent>
</Card>
{/* Instructions */}
<Card>
<CardHeader>
<CardTitle>Setup Instructions</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-400">
<li>
<strong className="text-white">Initialize Commission</strong> - Add proxy to
DynamicCommissionCollective (requires sudo)
</li>
<li>
<strong className="text-white">Join Commission</strong> - Members add proxy rights
via Commission Voting tab
</li>
<li>
<strong className="text-white">Start Voting</strong> - Create proposals and vote on
KYC applications
</li>
</ol>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,652 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, 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 { usePolkadot } from '@/contexts/PolkadotContext';
import { Loader2, ThumbsUp, ThumbsDown, CheckCircle, Clock, RefreshCw } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { COMMISSIONS } from '@/config/commissions';
interface Proposal {
hash: string;
proposalIndex: number;
threshold: number;
ayes: string[];
nays: string[];
end: number;
call?: any;
}
export function CommissionVotingTab() {
const { api, isApiReady, selectedAccount } = usePolkadot();
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [proposals, setProposals] = useState<Proposal[]>([]);
const [voting, setVoting] = useState<string | null>(null);
const [isCommissionMember, setIsCommissionMember] = useState(false);
useEffect(() => {
if (!api || !isApiReady) {
return;
}
checkMembership();
loadProposals();
}, [api, isApiReady, selectedAccount]);
const checkMembership = async () => {
if (!api || !selectedAccount) {
console.log('No API or selected account');
setIsCommissionMember(false);
return;
}
try {
console.log('Checking membership for:', selectedAccount.address);
// Check if user is directly a member of DynamicCommissionCollective
const members = await api.query.dynamicCommissionCollective.members();
const memberList = members.toJSON() as string[];
console.log('Commission members:', memberList);
const isMember = memberList.includes(selectedAccount.address);
console.log('Is commission member:', isMember);
setIsCommissionMember(isMember);
} catch (error) {
console.error('Error checking membership:', error);
setIsCommissionMember(false);
}
};
const loadProposals = async () => {
if (!api || !isApiReady) {
setLoading(false);
return;
}
setLoading(true);
try {
// Get all active proposal hashes
const proposalHashes = await api.query.dynamicCommissionCollective.proposals();
const proposalList: Proposal[] = [];
for (let i = 0; i < proposalHashes.length; i++) {
const hash = proposalHashes[i];
// Get voting info for this proposal
const voting = await api.query.dynamicCommissionCollective.voting(hash);
if (!voting.isEmpty) {
const voteData = voting.unwrap();
// Get proposal details
const proposalOption = await api.query.dynamicCommissionCollective.proposalOf(hash);
let proposalCall = null;
if (!proposalOption.isEmpty) {
proposalCall = proposalOption.unwrap();
}
// Get the actual proposal index from the chain
const proposalIndex = (voteData as any).index?.toNumber() || i;
proposalList.push({
hash: hash.toHex(),
proposalIndex: proposalIndex,
threshold: voteData.threshold.toNumber(),
ayes: voteData.ayes.map((a: any) => a.toString()),
nays: voteData.nays.map((n: any) => n.toString()),
end: voteData.end.toNumber(),
call: proposalCall?.toHuman(),
});
}
}
setProposals(proposalList);
console.log(`Loaded ${proposalList.length} active proposals`);
} catch (error) {
console.error('Error loading proposals:', error);
toast({
title: 'Error',
description: 'Failed to load proposals',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleVote = async (proposal: Proposal, approve: boolean) => {
if (!api || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your wallet first',
variant: 'destructive',
});
return;
}
if (!isCommissionMember) {
toast({
title: 'Not a Commission Member',
description: 'You are not a member of the KYC Approval Commission',
variant: 'destructive',
});
return;
}
setVoting(proposal.hash);
try {
const { web3FromAddress } = await import('@polkadot/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
console.log(`Voting ${approve ? 'AYE' : 'NAY'} on proposal:`, proposal.hash);
// Vote directly (no proxy needed)
const tx = api.tx.dynamicCommissionCollective.vote(
proposal.hash,
proposal.proposalIndex,
approve
);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.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('Vote error:', errorMessage);
toast({
title: 'Vote Failed',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
return;
}
// Check for Voted event
const votedEvent = events.find(({ event }) =>
event.section === 'dynamicCommissionCollective' && event.method === 'Voted'
);
// Check for Executed event (threshold reached)
const executedEvent = events.find(({ event }) =>
event.section === 'dynamicCommissionCollective' && event.method === 'Executed'
);
if (executedEvent) {
console.log('✅ Proposal executed (threshold reached)');
toast({
title: 'Success',
description: 'Proposal passed and executed! KYC approved.',
});
} else if (votedEvent) {
console.log('✅ Vote recorded');
toast({
title: 'Vote Recorded',
description: `Your ${approve ? 'AYE' : 'NAY'} vote has been recorded`,
});
}
resolve();
}
}
).catch((error) => {
console.error('Failed to sign and send:', error);
toast({
title: 'Transaction Error',
description: error.message || 'Failed to submit transaction',
variant: 'destructive',
});
reject(error);
});
});
// Reload proposals after voting
setTimeout(() => {
loadProposals();
}, 2000);
} catch (error: any) {
console.error('Error voting:', error);
toast({
title: 'Error',
description: error.message || 'Failed to vote',
variant: 'destructive',
});
} finally {
setVoting(null);
}
};
const handleExecute = async (proposal: Proposal) => {
if (!api || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your wallet first',
variant: 'destructive',
});
return;
}
setVoting(proposal.hash);
try {
const { web3FromAddress } = await import('@polkadot/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
console.log('Executing proposal:', proposal.hash);
// Get proposal length bound
const proposalOption = await api.query.dynamicCommissionCollective.proposalOf(proposal.hash);
const proposalCall = proposalOption.unwrap();
const lengthBound = proposalCall.encodedLength;
const tx = api.tx.dynamicCommissionCollective.close(
proposal.hash,
proposal.proposalIndex,
{
refTime: 1_000_000_000_000, // 1 trillion for ref time
proofSize: 64 * 1024, // 64 KB for proof size
},
lengthBound
);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.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('Execute error:', errorMessage);
toast({
title: 'Execute Failed',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
return;
}
const executedEvent = events.find(({ event }) =>
event.section === 'dynamicCommissionCollective' && event.method === 'Executed'
);
const closedEvent = events.find(({ event }) =>
event.section === 'dynamicCommissionCollective' && event.method === 'Closed'
);
if (executedEvent) {
const eventData = executedEvent.event.data.toHuman();
console.log('✅ Proposal executed');
console.log('Execute event data:', eventData);
console.log('Result:', eventData);
// Check if execution was successful
const result = eventData[eventData.length - 1]; // Last parameter is usually the result
if (result && typeof result === 'object' && 'Err' in result) {
console.error('Execution failed:', result.Err);
toast({
title: 'Execution Failed',
description: `Proposal closed but execution failed: ${JSON.stringify(result.Err)}`,
variant: 'destructive',
});
} else {
toast({
title: 'Proposal Executed!',
description: 'KYC approved and NFT minted successfully!',
});
}
} else if (closedEvent) {
console.log('Proposal closed');
toast({
title: 'Proposal Closed',
description: 'Proposal has been closed',
});
}
resolve();
}
}
).catch((error) => {
console.error('Failed to sign and send:', error);
toast({
title: 'Transaction Error',
description: error.message || 'Failed to submit transaction',
variant: 'destructive',
});
reject(error);
});
});
setTimeout(() => {
loadProposals();
}, 2000);
} catch (error: any) {
console.error('Error executing:', error);
toast({
title: 'Error',
description: error.message || 'Failed to execute proposal',
variant: 'destructive',
});
} finally {
setVoting(null);
}
};
const getProposalDescription = (call: any): string => {
if (!call) return 'Unknown proposal';
try {
const callStr = JSON.stringify(call);
if (callStr.includes('approveKyc')) {
return 'KYC Approval';
}
if (callStr.includes('rejectKyc')) {
return 'KYC Rejection';
}
return 'Commission Action';
} catch {
return 'Unknown proposal';
}
};
const getStatusBadge = (proposal: Proposal) => {
const progress = (proposal.ayes.length / proposal.threshold) * 100;
if (proposal.ayes.length >= proposal.threshold) {
return <Badge variant="default" className="bg-green-600">PASSED</Badge>;
}
if (progress >= 50) {
return <Badge variant="default" className="bg-yellow-600">VOTING ({progress.toFixed(0)}%)</Badge>;
}
return <Badge variant="secondary">VOTING ({progress.toFixed(0)}%)</Badge>;
};
if (!isApiReady) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span>Connecting to blockchain...</span>
</div>
</CardContent>
</Card>
);
}
if (!selectedAccount) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
<p>Please connect your wallet to view commission proposals</p>
</div>
</CardContent>
</Card>
);
}
if (!isCommissionMember) {
const handleJoinCommission = async () => {
if (!api || !selectedAccount) return;
try {
const { web3FromAddress } = await import('@polkadot/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
// Get current members
const currentMembers = await api.query.dynamicCommissionCollective.members();
const memberList = (currentMembers.toJSON() as string[]) || [];
// Add current user to members list
if (!memberList.includes(selectedAccount.address)) {
memberList.push(selectedAccount.address);
}
console.log('Adding member to commission:', selectedAccount.address);
console.log('New member list:', memberList);
// Use sudo to update members (requires sudo access)
const tx = api.tx.sudo.sudo(
api.tx.dynamicCommissionCollective.setMembers(
memberList,
null,
memberList.length
)
);
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError }) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Failed to join commission';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
} else {
toast({
title: 'Success',
description: 'You have joined the KYC Commission!',
});
setTimeout(() => checkMembership(), 2000);
}
}
}
);
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to join commission',
variant: 'destructive',
});
}
};
return (
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-muted-foreground mb-4">You are not a member of the KYC Approval Commission</p>
<p className="text-sm text-muted-foreground mb-6">Only commission members can view and vote on proposals</p>
<Button
onClick={handleJoinCommission}
className="bg-green-600 hover:bg-green-700"
>
Join Commission
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold">Commission Proposals</h2>
<p className="text-muted-foreground">
Active voting proposals for {COMMISSIONS.KYC.name}
</p>
</div>
<Button
onClick={loadProposals}
disabled={loading}
variant="outline"
>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{loading ? (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span>Loading proposals...</span>
</div>
</CardContent>
</Card>
) : proposals.length === 0 ? (
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
<Clock className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No active proposals</p>
<p className="text-sm mt-2">Proposals will appear here when commission members create them</p>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Active Proposals ({proposals.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Proposal</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Votes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{proposals.map((proposal) => (
<TableRow key={proposal.hash}>
<TableCell className="font-mono text-sm">
#{proposal.proposalIndex}
</TableCell>
<TableCell>
{getProposalDescription(proposal.call)}
</TableCell>
<TableCell>
{getStatusBadge(proposal)}
</TableCell>
<TableCell>
<div className="flex gap-2 items-center">
<Badge variant="outline" className="flex items-center gap-1">
<ThumbsUp className="h-3 w-3" />
{proposal.ayes.length}
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<ThumbsDown className="h-3 w-3" />
{proposal.nays.length}
</Badge>
<span className="text-sm text-muted-foreground">
/ {proposal.threshold}
</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex gap-2 justify-end">
{proposal.ayes.length >= proposal.threshold ? (
<Button
size="sm"
variant="default"
onClick={() => handleExecute(proposal)}
disabled={voting === proposal.hash}
className="bg-blue-600 hover:bg-blue-700"
>
{voting === proposal.hash ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>Execute Proposal</>
)}
</Button>
) : (
<>
<Button
size="sm"
variant="default"
onClick={() => handleVote(proposal, true)}
disabled={voting === proposal.hash}
className="bg-green-600 hover:bg-green-700"
>
{voting === proposal.hash ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<ThumbsUp className="h-4 w-4 mr-1" />
Aye
</>
)}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleVote(proposal, false)}
disabled={voting === proposal.hash}
>
{voting === proposal.hash ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<ThumbsDown className="h-4 w-4 mr-1" />
Nay
</>
)}
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
+559
View File
@@ -0,0 +1,559 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, 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 { usePolkadot } from '@/contexts/PolkadotContext';
import { Loader2, CheckCircle, XCircle, Clock, User, Mail, MapPin, FileText, AlertTriangle } from 'lucide-react';
import { COMMISSIONS } from '@/config/commissions';
import {
Table,
TableBody,
TableCell,
TableHead,
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;
}
export function KycApprovalTab() {
const { api, isApiReady, selectedAccount, connectWallet } = usePolkadot();
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);
// Load pending KYC applications
useEffect(() => {
if (!api || !isApiReady) {
return;
}
loadPendingApplications();
}, [api, isApiReady]);
const loadPendingApplications = async () => {
if (!api || !isApiReady) {
setLoading(false);
return;
}
setLoading(true);
try {
// Get all pending applications
const entries = await api.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 any;
// Get identity info for this address
try {
const identity = await api.query.identityKyc.identities(address);
if (!identity.isEmpty) {
const identityData = identity.toJSON() as any;
identityMap.set(address, {
name: identityData.name || 'Unknown',
email: identityData.email || 'No email'
});
}
} catch (err) {
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()
});
}
setPendingApps(apps);
setIdentities(identityMap);
console.log(`Loaded ${apps.length} pending KYC applications`);
} catch (error) {
console.error('Error loading pending applications:', error);
toast({
title: 'Error',
description: 'Failed to load pending applications',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleApprove = async (application: PendingApplication) => {
if (!api || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your admin wallet first',
variant: 'destructive',
});
return;
}
setProcessing(true);
try {
const { web3FromAddress } = await import('@polkadot/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
console.log('Proposing KYC approval for:', application.address);
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) {
toast({
title: 'Not a Commission Member',
description: 'You are not a member of the KYC Approval Commission',
variant: 'destructive',
});
setProcessing(false);
return;
}
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)
console.log('Creating commission proposal for KYC approval');
console.log('Applicant:', application.address);
console.log('Threshold:', COMMISSIONS.KYC.threshold);
const tx = api.tx.dynamicCommissionCollective.propose(
COMMISSIONS.KYC.threshold,
proposal,
lengthBound
);
console.log('Transaction created:', tx.toHuman());
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.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('Approval error:', errorMessage);
toast({
title: 'Approval Failed',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
return;
}
// Check for Proposed event
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) {
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 {
console.warn('Transaction included but no Proposed event');
resolve();
}
}
}
).catch((error) => {
console.error('Failed to sign and send:', error);
toast({
title: 'Transaction Error',
description: error.message || 'Failed to submit transaction',
variant: 'destructive',
});
reject(error);
});
});
// Reload applications after approval
setTimeout(() => {
loadPendingApplications();
setShowDetailsModal(false);
setSelectedApp(null);
}, 2000);
} catch (error: any) {
console.error('Error approving KYC:', error);
toast({
title: 'Error',
description: error.message || 'Failed to approve KYC',
variant: 'destructive',
});
} finally {
setProcessing(false);
}
};
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('@polkadot/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
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: any) {
console.error('Error rejecting KYC:', error);
toast({
title: 'Error',
description: error.message || 'Failed to reject KYC',
variant: 'destructive',
});
} finally {
setProcessing(false);
}
};
const openDetailsModal = (app: PendingApplication) => {
setSelectedApp(app);
setShowDetailsModal(true);
};
if (!isApiReady) {
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>
</div>
</CardContent>
</Card>
);
}
if (!selectedAccount) {
return (
<Card>
<CardContent className="pt-6">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Please connect your admin wallet to view and approve KYC applications.
<Button onClick={connectWallet} variant="outline" className="ml-4">
Connect Wallet
</Button>
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
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>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Applicant</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Documents</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>
);
})}
</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'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>
</>
);
}
function Label({ children, className }: { children: React.ReactNode; className?: string }) {
return <label className={`text-sm font-medium ${className}`}>{children}</label>;
}