feat: Add citizenship NFT workflow and wallet enhancements

- Add BeCitizen page with citizenship application workflow
- Add NftList component to display Tiki role NFTs
- Implement citizenship crypto and workflow utilities
- Add wallet score tracking and calculation
- Update WalletDashboard to display NFTs and scores
- Update AccountBalance and AppLayout for better UX
- Enhance PolkadotContext with Tiki pallet integration
- Clean up temporary debugging scripts

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-07 19:39:02 +03:00
parent 3212af89b2
commit 591e63e99e
15 changed files with 2890 additions and 87 deletions
+2
View File
@@ -10,6 +10,7 @@ import AdminPanel from '@/pages/AdminPanel';
import WalletDashboard from './pages/WalletDashboard';
import PoolDashboardPage from './pages/PoolDashboard';
import ReservesDashboardPage from './pages/ReservesDashboardPage';
import BeCitizen from './pages/BeCitizen';
import { AppProvider } from '@/contexts/AppContext';
import { PolkadotProvider } from '@/contexts/PolkadotContext';
import { WalletProvider } from '@/contexts/WalletContext';
@@ -38,6 +39,7 @@ function App() {
<Route path="/email-verification" element={<EmailVerification />} />
<Route path="/reset-password" element={<PasswordReset />} />
<Route path="/" element={<Index />} />
<Route path="/be-citizen" element={<BeCitizen />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
+106 -32
View File
@@ -1,11 +1,12 @@
import React, { useEffect, useState } from 'react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Wallet, TrendingUp, ArrowUpRight, ArrowDownRight, RefreshCw, Award, Plus, Coins, Send } from 'lucide-react';
import { Wallet, TrendingUp, ArrowUpRight, ArrowDownRight, RefreshCw, Award, Plus, Coins, Send, Shield, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ASSET_IDS, getAssetSymbol } from '@/lib/wallet';
import { AddTokenModal } from './AddTokenModal';
import { TransferModal } from './TransferModal';
import { getAllScores, type UserScores } from '@/lib/scores';
interface TokenBalance {
assetId: number;
@@ -31,7 +32,14 @@ export const AccountBalance: React.FC = () => {
const [usdtBalance, setUsdtBalance] = useState<string>('0');
const [hezUsdPrice, setHezUsdPrice] = useState<number>(0);
const [pezUsdPrice, setPezUsdPrice] = useState<number>(0);
const [trustScore, setTrustScore] = useState<string>('-');
const [scores, setScores] = useState<UserScores>({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
const [loadingScores, setLoadingScores] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [otherTokens, setOtherTokens] = useState<TokenBalance[]>([]);
const [isAddTokenModalOpen, setIsAddTokenModalOpen] = useState(false);
@@ -316,23 +324,38 @@ export const AccountBalance: React.FC = () => {
fetchBalance();
fetchTokenPrices(); // Fetch token USD prices from pools
// Fetch Trust Score
const fetchTrustScore = async () => {
// Fetch All Scores from blockchain
const fetchAllScores = async () => {
if (!api || !isApiReady || !selectedAccount?.address) {
setTrustScore('-');
setScores({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
return;
}
setLoadingScores(true);
try {
const score = await api.query.trust.trustScores(selectedAccount.address);
setTrustScore(score.toString());
const userScores = await getAllScores(api, selectedAccount.address);
setScores(userScores);
} catch (err) {
console.error('Failed to fetch trust score:', err);
setTrustScore('-');
console.error('Failed to fetch scores:', err);
setScores({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
} finally {
setLoadingScores(false);
}
};
fetchTrustScore();
fetchAllScores();
// Subscribe to HEZ balance updates
let unsubscribeHez: () => void;
@@ -535,30 +558,81 @@ export const AccountBalance: React.FC = () => {
</CardContent>
</Card>
{/* Account Info */}
{/* Account Info & Scores */}
<Card className="bg-gray-900 border-gray-800">
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Account</span>
<span className="text-white font-mono">
{selectedAccount.meta.name || 'Unnamed'}
</span>
<CardHeader className="pb-3">
<CardTitle className="text-lg font-medium text-gray-300">
Account Information
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Account Details */}
<div className="space-y-2 pb-4 border-b border-gray-800">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Account</span>
<span className="text-white font-mono">
{selectedAccount.meta.name || 'Unnamed'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Address</span>
<span className="text-white font-mono text-xs">
{selectedAccount.address.slice(0, 8)}...{selectedAccount.address.slice(-8)}
</span>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Address</span>
<span className="text-white font-mono text-xs">
{selectedAccount.address.slice(0, 8)}...{selectedAccount.address.slice(-8)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400 flex items-center gap-1">
<Award className="w-3 h-3 text-purple-400" />
Trust Score
</span>
<span className="text-lg font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
{trustScore}
</span>
{/* Scores from Blockchain */}
<div>
<div className="text-xs text-gray-400 mb-3">Scores from Blockchain</div>
{loadingScores ? (
<div className="text-sm text-gray-400">Loading scores...</div>
) : (
<div className="space-y-3">
{/* Score Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<Shield className="h-3 w-3 text-purple-400" />
<span className="text-xs text-gray-400">Trust</span>
</div>
<span className="text-base font-bold text-purple-400">{scores.trustScore}</span>
</div>
<div className="bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<Users className="h-3 w-3 text-cyan-400" />
<span className="text-xs text-gray-400">Referral</span>
</div>
<span className="text-base font-bold text-cyan-400">{scores.referralScore}</span>
</div>
<div className="bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<TrendingUp className="h-3 w-3 text-green-400" />
<span className="text-xs text-gray-400">Staking</span>
</div>
<span className="text-base font-bold text-green-400">{scores.stakingScore}</span>
</div>
<div className="bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<Award className="h-3 w-3 text-pink-400" />
<span className="text-xs text-gray-400">Tiki</span>
</div>
<span className="text-base font-bold text-pink-400">{scores.tikiScore}</span>
</div>
</div>
{/* Total Score */}
<div className="pt-3 border-t border-gray-800">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Total Score</span>
<span className="text-xl font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
{scores.totalScore}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</CardContent>
+25 -8
View File
@@ -21,7 +21,7 @@ import { TreasuryOverview } from './treasury/TreasuryOverview';
import { FundingProposal } from './treasury/FundingProposal';
import { SpendingHistory } from './treasury/SpendingHistory';
import { MultiSigApproval } from './treasury/MultiSigApproval';
import { Github, FileText, ExternalLink, Shield, Award, User, FileEdit, Users2, MessageSquare, ShieldCheck, Wifi, WifiOff, Wallet, DollarSign, PiggyBank, History, Key, TrendingUp, ArrowRightLeft, Lock, LogIn, LayoutDashboard, Settings, UserCog, Repeat } from 'lucide-react';
import { Github, FileText, ExternalLink, Shield, Award, User, FileEdit, Users2, MessageSquare, ShieldCheck, Wifi, WifiOff, Wallet, DollarSign, PiggyBank, History, Key, TrendingUp, ArrowRightLeft, Lock, LogIn, LayoutDashboard, Settings, UserCog, Repeat, Users } from 'lucide-react';
import GovernanceInterface from './GovernanceInterface';
import RewardDistribution from './RewardDistribution';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -100,6 +100,14 @@ const AppLayout: React.FC = () => {
Wallet
</button>
<button
onClick={() => navigate('/be-citizen')}
className="text-cyan-300 hover:text-cyan-100 transition-colors flex items-center gap-1 text-sm font-semibold"
>
<Users className="w-4 h-4" />
Be Citizen
</button>
{/* Governance Dropdown */}
<div className="relative group">
<button className="text-gray-300 hover:text-white transition-colors flex items-center gap-1 text-sm">
@@ -212,13 +220,22 @@ const AppLayout: React.FC = () => {
</button>
</>
) : (
<button
onClick={() => navigate('/login')}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<LogIn className="w-4 h-4" />
Login
</button>
<>
<button
onClick={() => navigate('/be-citizen')}
className="text-cyan-300 hover:text-cyan-100 transition-colors flex items-center gap-1 text-sm font-semibold"
>
<Users className="w-4 h-4" />
Be Citizen
</button>
<button
onClick={() => navigate('/login')}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<LogIn className="w-4 h-4" />
Login
</button>
</>
)}
<a
+175
View File
@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, Award, Crown, Shield, Users } from 'lucide-react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { getUserTikis } from '@/lib/citizenship-workflow';
import type { TikiInfo } from '@/lib/citizenship-workflow';
// Icon map for different Tiki roles
const getTikiIcon = (role: string) => {
const roleLower = role.toLowerCase();
if (roleLower.includes('hemwelatî') || roleLower.includes('welati') || roleLower.includes('citizen')) {
return <Shield className="w-6 h-6 text-cyan-500" />;
}
if (roleLower.includes('leader') || roleLower.includes('chief')) {
return <Crown className="w-6 h-6 text-yellow-500" />;
}
if (roleLower.includes('elder') || roleLower.includes('wise')) {
return <Award className="w-6 h-6 text-purple-500" />;
}
return <Users className="w-6 h-6 text-green-500" />;
};
// Color scheme for different roles
const getRoleBadgeColor = (role: string) => {
const roleLower = role.toLowerCase();
if (roleLower.includes('hemwelatî') || roleLower.includes('welati') || roleLower.includes('citizen')) {
return 'bg-cyan-500/10 text-cyan-500 border-cyan-500/30';
}
if (roleLower.includes('leader') || roleLower.includes('chief')) {
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30';
}
if (roleLower.includes('elder') || roleLower.includes('wise')) {
return 'bg-purple-500/10 text-purple-500 border-purple-500/30';
}
return 'bg-green-500/10 text-green-500 border-green-500/30';
};
export const NftList: React.FC = () => {
const { api, isApiReady, selectedAccount } = usePolkadot();
const [tikis, setTikis] = useState<TikiInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTikis = async () => {
if (!api || !isApiReady || !selectedAccount) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const userTikis = await getUserTikis(api, selectedAccount.address);
setTikis(userTikis);
} catch (err) {
console.error('Error fetching Tikis:', err);
setError('Failed to load NFTs');
} finally {
setLoading(false);
}
};
fetchTikis();
}, [api, isApiReady, selectedAccount]);
if (loading) {
return (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white">Your NFTs (Tikis)</CardTitle>
<CardDescription>Your Tiki collection</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white">Your NFTs (Tikis)</CardTitle>
<CardDescription>Your Tiki collection</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<p className="text-red-500">{error}</p>
</div>
</CardContent>
</Card>
);
}
if (tikis.length === 0) {
return (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white">Your NFTs (Tikis)</CardTitle>
<CardDescription>Your Tiki collection</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<Award className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 mb-2">No NFTs yet</p>
<p className="text-gray-600 text-sm">
Complete your citizenship application to receive your Welati Tiki NFT
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Award className="w-5 h-5" />
Your NFTs (Tikiler)
</CardTitle>
<CardDescription>Your Tiki collection ({tikis.length} total)</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{tikis.map((tiki, index) => (
<div
key={index}
className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 hover:border-cyan-500/50 transition-colors"
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className="flex-shrink-0">
{getTikiIcon(tiki.role)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-white text-sm">
Tiki #{tiki.id}
</h3>
<Badge className={getRoleBadgeColor(tiki.role)}>
{tiki.role === 'Hemwelatî' ? 'Welati' : tiki.role}
</Badge>
</div>
{/* Metadata if available */}
{tiki.metadata && typeof tiki.metadata === 'object' && (
<div className="space-y-1 mt-2">
{Object.entries(tiki.metadata).map(([key, value]) => (
<div key={key} className="text-xs text-gray-400">
<span className="font-medium">{key}:</span>{' '}
<span>{String(value)}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,50 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ExistingCitizenAuth } from './ExistingCitizenAuth';
import { NewCitizenApplication } from './NewCitizenApplication';
interface CitizenshipModalProps {
isOpen: boolean;
onClose: () => void;
}
export const CitizenshipModal: React.FC<CitizenshipModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'existing' | 'new'>('existing');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-2xl">
🏛 Digital Kurdistan Citizenship
</DialogTitle>
<DialogDescription>
Join the Digital Kurdistan State as a citizen or authenticate your existing citizenship
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'existing' | 'new')} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="existing">I am Already a Citizen</TabsTrigger>
<TabsTrigger value="new">I Want to Become a Citizen</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-6">
<ExistingCitizenAuth onClose={onClose} />
</TabsContent>
<TabsContent value="new" className="mt-6">
<NewCitizenApplication onClose={onClose} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, CheckCircle, AlertTriangle, Shield } from 'lucide-react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { verifyNftOwnership } from '@/lib/citizenship-workflow';
import { generateAuthChallenge, signChallenge, verifySignature, saveCitizenSession } from '@/lib/citizenship-crypto';
import type { AuthChallenge } from '@/lib/citizenship-crypto';
interface ExistingCitizenAuthProps {
onClose: () => void;
}
export const ExistingCitizenAuth: React.FC<ExistingCitizenAuthProps> = ({ onClose }) => {
const { api, isApiReady, selectedAccount, connectWallet } = usePolkadot();
const [tikiNumber, setTikiNumber] = useState('');
const [step, setStep] = useState<'input' | 'verifying' | 'signing' | 'success' | 'error'>('input');
const [error, setError] = useState<string | null>(null);
const [challenge, setChallenge] = useState<AuthChallenge | null>(null);
const handleVerifyNFT = async () => {
if (!api || !isApiReady || !selectedAccount) {
setError('Please connect your wallet first');
return;
}
if (!tikiNumber.trim()) {
setError('Please enter your Welati Tiki NFT number');
return;
}
setError(null);
setStep('verifying');
try {
// Verify NFT ownership
const ownsNFT = await verifyNftOwnership(api, tikiNumber, selectedAccount.address);
if (!ownsNFT) {
setError(`NFT #${tikiNumber} not found in your wallet or not a Welati Tiki`);
setStep('error');
return;
}
// Generate challenge for signature
const authChallenge = generateAuthChallenge(tikiNumber);
setChallenge(authChallenge);
setStep('signing');
} catch (err) {
console.error('Verification error:', err);
setError('Failed to verify NFT ownership');
setStep('error');
}
};
const handleSignChallenge = async () => {
if (!selectedAccount || !challenge) {
setError('Missing authentication data');
return;
}
setError(null);
try {
// Sign the challenge
const signature = await signChallenge(selectedAccount, challenge);
// Verify signature (self-verification for demonstration)
const isValid = await verifySignature(signature, challenge, selectedAccount.address);
if (!isValid) {
setError('Signature verification failed');
setStep('error');
return;
}
// Save session
const session = {
tikiNumber,
walletAddress: selectedAccount.address,
sessionToken: signature, // In production, use proper JWT
lastAuthenticated: Date.now(),
expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours
};
await saveCitizenSession(session);
setStep('success');
// Redirect to citizen dashboard after 2 seconds
setTimeout(() => {
// TODO: Navigate to citizen dashboard
onClose();
window.location.href = '/dashboard'; // Or use router.push('/dashboard')
}, 2000);
} catch (err) {
console.error('Signature error:', err);
setError('Failed to sign authentication challenge');
setStep('error');
}
};
const handleConnectWallet = async () => {
try {
await connectWallet();
} catch (err) {
setError('Failed to connect wallet');
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-cyan-500" />
Authenticate as Citizen
</CardTitle>
<CardDescription>
Enter your Welati Tiki NFT number to authenticate
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Step 1: Enter NFT Number */}
{step === 'input' && (
<>
<div className="space-y-2">
<Label htmlFor="tikiNumber">Welati Tiki NFT Number</Label>
<Input
id="tikiNumber"
placeholder="e.g., 12345"
value={tikiNumber}
onChange={(e) => setTikiNumber(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleVerifyNFT()}
/>
<p className="text-xs text-muted-foreground">
This is your unique citizen ID number received after KYC approval
</p>
</div>
{!selectedAccount ? (
<Button onClick={handleConnectWallet} className="w-full">
Connect Wallet First
</Button>
) : (
<Button onClick={handleVerifyNFT} className="w-full">
Verify NFT Ownership
</Button>
)}
</>
)}
{/* Step 2: Verifying */}
{step === 'verifying' && (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<Loader2 className="h-12 w-12 animate-spin text-cyan-500" />
<p className="text-sm text-muted-foreground">Verifying NFT ownership on blockchain...</p>
</div>
)}
{/* Step 3: Sign Challenge */}
{step === 'signing' && (
<>
<Alert>
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
NFT ownership verified! Now sign to prove you control this wallet.
</AlertDescription>
</Alert>
<div className="bg-muted p-4 rounded-lg space-y-2">
<p className="text-sm font-medium">Authentication Challenge:</p>
<p className="text-xs text-muted-foreground font-mono break-all">
{challenge?.nonce}
</p>
</div>
<Button onClick={handleSignChallenge} className="w-full">
Sign Message to Authenticate
</Button>
</>
)}
{/* Step 4: Success */}
{step === 'success' && (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<CheckCircle className="h-16 w-16 text-green-500" />
<h3 className="text-lg font-semibold">Authentication Successful!</h3>
<p className="text-sm text-muted-foreground text-center">
Welcome back, Citizen #{tikiNumber}
</p>
<p className="text-xs text-muted-foreground">
Redirecting to citizen dashboard...
</p>
</div>
)}
{/* Error State */}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{step === 'error' && (
<Button onClick={() => { setStep('input'); setError(null); }} variant="outline" className="w-full">
Try Again
</Button>
)}
</CardContent>
</Card>
{/* Security Info */}
<Card className="bg-cyan-500/10 border-cyan-500/30">
<CardContent className="pt-6">
<div className="space-y-2 text-sm">
<h4 className="font-semibold flex items-center gap-2">
<Shield className="h-4 w-4" />
Security Information
</h4>
<ul className="space-y-1 text-xs text-muted-foreground">
<li> Your NFT number is cryptographically verified on-chain</li>
<li> Signature proves you control the wallet without revealing private keys</li>
<li> Session expires after 24 hours for your security</li>
<li> No personal data is transmitted or stored on-chain</li>
</ul>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,538 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2, AlertTriangle, CheckCircle, User, Users as UsersIcon, MapPin, Briefcase, Mail, Clock } from 'lucide-react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import type { CitizenshipData, Region, MaritalStatus } from '@/lib/citizenship-workflow';
import { FOUNDER_ADDRESS, submitKycApplication, subscribeToKycApproval, getKycStatus } from '@/lib/citizenship-workflow';
import { generateCommitmentHash, generateNullifierHash, encryptData, saveLocalCitizenshipData, uploadToIPFS } from '@/lib/citizenship-crypto';
interface NewCitizenApplicationProps {
onClose: () => void;
}
type FormData = Omit<CitizenshipData, 'walletAddress' | 'timestamp'>;
export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ onClose }) => {
const { api, isApiReady, selectedAccount, connectWallet } = usePolkadot();
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormData>();
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [waitingForApproval, setWaitingForApproval] = useState(false);
const [kycApproved, setKycApproved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [agreed, setAgreed] = useState(false);
const [checkingStatus, setCheckingStatus] = useState(false);
const maritalStatus = watch('maritalStatus');
const childrenCount = watch('childrenCount');
// Check KYC status on mount
useEffect(() => {
const checkKycStatus = async () => {
if (!api || !isApiReady || !selectedAccount) {
return;
}
setCheckingStatus(true);
try {
const status = await getKycStatus(api, selectedAccount.address);
console.log('Current KYC Status:', status);
if (status === 'Approved') {
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) {
console.error('Error checking KYC status:', err);
} finally {
setCheckingStatus(false);
}
};
checkKycStatus();
}, [api, isApiReady, selectedAccount, onClose]);
// Subscribe to KYC approval events
useEffect(() => {
if (!api || !isApiReady || !selectedAccount || !waitingForApproval) {
return;
}
console.log('Setting up KYC approval listener for:', selectedAccount.address);
const unsubscribe = subscribeToKycApproval(
api,
selectedAccount.address,
() => {
console.log('KYC Approved! Redirecting to dashboard...');
setKycApproved(true);
setWaitingForApproval(false);
// Redirect to citizen dashboard after 2 seconds
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
},
(error) => {
console.error('KYC approval subscription error:', error);
setError(`Failed to monitor approval status: ${error}`);
}
);
return () => {
if (unsubscribe) {
unsubscribe();
}
};
}, [api, isApiReady, selectedAccount, waitingForApproval, onClose]);
const onSubmit = async (data: FormData) => {
if (!api || !isApiReady || !selectedAccount) {
setError('Please connect your wallet first');
return;
}
if (!agreed) {
setError('Please agree to the terms');
return;
}
setError(null);
setSubmitting(true);
try {
// Check KYC status before submitting
const currentStatus = await getKycStatus(api, selectedAccount.address);
if (currentStatus === 'Approved') {
setError('Your KYC has already been approved! Redirecting to dashboard...');
setKycApproved(true);
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
return;
}
if (currentStatus === 'Pending') {
setError('You already have a pending KYC application. Please wait for admin approval.');
setWaitingForApproval(true);
return;
}
// 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
};
// Generate commitment and nullifier hashes
const commitmentHash = await generateCommitmentHash(citizenshipData);
const nullifierHash = await generateNullifierHash(selectedAccount.address, citizenshipData.timestamp);
console.log('Commitment Hash:', commitmentHash);
console.log('Nullifier Hash:', nullifierHash);
// Encrypt data
const encryptedData = await encryptData(citizenshipData, selectedAccount.address);
// Save to local storage (backup)
await saveLocalCitizenshipData(citizenshipData, selectedAccount.address);
// Upload to IPFS
const ipfsCid = await uploadToIPFS(encryptedData);
console.log('IPFS CID:', ipfsCid);
console.log('IPFS CID type:', typeof ipfsCid);
console.log('IPFS CID value:', JSON.stringify(ipfsCid));
// 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
console.log('Submitting KYC application to blockchain...');
const result = await submitKycApplication(
api,
selectedAccount,
citizenshipData.fullName,
citizenshipData.email,
cidString,
`Citizenship application for ${citizenshipData.fullName}`
);
if (!result.success) {
setError(result.error || 'Failed to submit KYC application to blockchain');
setSubmitting(false);
return;
}
console.log('✅ KYC application submitted to blockchain');
console.log('Block hash:', result.blockHash);
// Move to waiting for approval state
setSubmitted(true);
setSubmitting(false);
setWaitingForApproval(true);
} catch (err) {
console.error('Submission error:', err);
setError('Failed to submit citizenship application');
setSubmitting(false);
}
};
if (!selectedAccount) {
return (
<Card>
<CardHeader>
<CardTitle>Connect Wallet Required</CardTitle>
<CardDescription>
You need to connect your wallet to apply for citizenship
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={connectWallet} className="w-full">
Connect Wallet
</Button>
</CardContent>
</Card>
);
}
// KYC Approved - Success state
if (kycApproved) {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-4">
<CheckCircle className="h-16 w-16 text-green-500 animate-pulse" />
<h3 className="text-lg font-semibold text-center text-green-500">KYC Approved!</h3>
<p className="text-sm text-muted-foreground text-center max-w-md">
Congratulations! Your citizenship application has been approved. Redirecting to citizen dashboard...
</p>
</CardContent>
</Card>
);
}
// Waiting for approval - Loading state
if (waitingForApproval) {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-6">
{/* Animated Loader with Halos */}
<div className="relative flex items-center justify-center">
{/* Outer halo */}
<div className="absolute w-32 h-32 border-4 border-cyan-500/20 rounded-full animate-ping"></div>
{/* Middle halo */}
<div className="absolute w-24 h-24 border-4 border-purple-500/30 rounded-full animate-pulse"></div>
{/* Inner spinning sun */}
<div className="relative w-16 h-16 flex items-center justify-center">
<Loader2 className="w-16 h-16 text-cyan-500 animate-spin" />
<Clock className="absolute w-8 h-8 text-yellow-400 animate-pulse" />
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Waiting for Admin Approval</h3>
<p className="text-sm text-muted-foreground max-w-md">
Your application has been submitted to the blockchain and is waiting for admin approval.
This page will automatically update when your citizenship is approved.
</p>
</div>
{/* Status steps */}
<div className="w-full max-w-md space-y-3 pt-4">
<div className="flex items-center gap-3 text-sm">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0" />
<span className="text-muted-foreground">Application encrypted and stored on IPFS</span>
</div>
<div className="flex items-center gap-3 text-sm">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0" />
<span className="text-muted-foreground">Transaction submitted to blockchain</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Loader2 className="h-5 w-5 text-cyan-500 animate-spin flex-shrink-0" />
<span className="font-medium">Waiting for admin to approve KYC...</span>
</div>
<div className="flex items-center gap-3 text-sm opacity-50">
<Clock className="h-5 w-5 text-gray-400 flex-shrink-0" />
<span className="text-muted-foreground">Receive Welati Tiki NFT</span>
</div>
</div>
{/* Info */}
<Alert className="bg-cyan-500/10 border-cyan-500/30">
<AlertDescription className="text-xs">
<strong>Note:</strong> Do not close this page. The system is monitoring the blockchain
for approval events in real-time. You will be automatically redirected once approved.
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
// Initial submission success (before blockchain confirmation)
if (submitted && !waitingForApproval) {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-4">
<Loader2 className="h-16 w-16 text-cyan-500 animate-spin" />
<h3 className="text-lg font-semibold text-center">Processing Application...</h3>
<p className="text-sm text-muted-foreground text-center max-w-md">
Encrypting your data and submitting to the blockchain. Please wait...
</p>
</CardContent>
</Card>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Personal Identity Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Nasnameya Kesane (Personal Identity)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="fullName">Navê Te (Your Full Name) *</Label>
<Input {...register('fullName', { required: true })} placeholder="e.g., Berzê Ronahî" />
{errors.fullName && <p className="text-xs text-red-500">Required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="fatherName">Navê Bavê Te (Father's Name) *</Label>
<Input {...register('fatherName', { required: true })} placeholder="e.g., Şêrko" />
{errors.fatherName && <p className="text-xs text-red-500">Required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="grandfatherName">Navê Bavkalê Te (Grandfather's Name) *</Label>
<Input {...register('grandfatherName', { required: true })} placeholder="e.g., Welat" />
{errors.grandfatherName && <p className="text-xs text-red-500">Required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="motherName">Navê Dayika Te (Mother's Name) *</Label>
<Input {...register('motherName', { required: true })} placeholder="e.g., Gula" />
{errors.motherName && <p className="text-xs text-red-500">Required</p>}
</div>
</CardContent>
</Card>
{/* Tribal Affiliation */}
<Card>
<CardHeader>
<CardTitle>Eşîra Te (Tribal Affiliation)</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="tribe">Eşîra Te (Your Tribe) *</Label>
<Input {...register('tribe', { required: true })} placeholder="e.g., Barzanî, Soran, Hewramî..." />
{errors.tribe && <p className="text-xs text-red-500">Required</p>}
</div>
</CardContent>
</Card>
{/* Family Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersIcon className="h-5 w-5" />
Rewşa Malbatê (Family Status)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Zewicî / Nezewicî (Married / Unmarried) *</Label>
<RadioGroup
onValueChange={(value) => setValue('maritalStatus', value as MaritalStatus)}
defaultValue="nezewici"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="zewici" id="married" />
<Label htmlFor="married">Zewicî (Married)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="nezewici" id="unmarried" />
<Label htmlFor="unmarried">Nezewicî (Unmarried)</Label>
</div>
</RadioGroup>
</div>
{maritalStatus === 'zewici' && (
<>
<div className="space-y-2">
<Label htmlFor="childrenCount">Hejmara Zarokan (Number of Children)</Label>
<Input
type="number"
{...register('childrenCount', { valueAsNumber: true })}
placeholder="0"
min="0"
/>
</div>
{childrenCount && childrenCount > 0 && (
<div className="space-y-3">
<Label>Navên Zarokan (Children's Names)</Label>
{Array.from({ length: childrenCount }).map((_, i) => (
<div key={i} className="grid grid-cols-2 gap-2">
<Input
{...register(`children.${i}.name` as const)}
placeholder={`Zaroka ${i + 1} - Nav`}
/>
<Input
type="number"
{...register(`children.${i}.birthYear` as const, { valueAsNumber: true })}
placeholder="Sala Dayikbûnê"
min="1900"
max={new Date().getFullYear()}
/>
</div>
))}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Geographic Origin */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Herêma Te (Your Region)
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="region">Ji Kuderê ? (Where are you from?) *</Label>
<Select onValueChange={(value) => setValue('region', value as Region)}>
<SelectTrigger>
<SelectValue placeholder="Herêmeke hilbijêre (Select a region)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bakur">Bakur (North - Turkey/Türkiye)</SelectItem>
<SelectItem value="basur">Başûr (South - Iraq)</SelectItem>
<SelectItem value="rojava">Rojava (West - Syria)</SelectItem>
<SelectItem value="rojhelat">Rojhilat (East - Iran)</SelectItem>
<SelectItem value="kurdistan_a_sor">Kurdistan a Sor (Red Kurdistan - Armenia/Azerbaijan)</SelectItem>
<SelectItem value="diaspora">Diaspora (Living Abroad)</SelectItem>
</SelectContent>
</Select>
{errors.region && <p className="text-xs text-red-500">Required</p>}
</div>
</CardContent>
</Card>
{/* Contact & Profession */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="h-5 w-5" />
Têkilî û Pîşe (Contact & Profession)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4" />
E-mail *
</Label>
<Input
type="email"
{...register('email', { required: true, pattern: /^\S+@\S+$/i })}
placeholder="example@email.com"
/>
{errors.email && <p className="text-xs text-red-500">Valid email required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profession">Pîşeya Te (Your Profession) *</Label>
<Input {...register('profession', { required: true })} placeholder="e.g., Mamosta, Bijîşk, Xebatkar..." />
{errors.profession && <p className="text-xs text-red-500">Required</p>}
</div>
</CardContent>
</Card>
{/* Referral */}
<Card className="bg-purple-500/10 border-purple-500/30">
<CardHeader>
<CardTitle>Koda Referral (Referral Code - Optional)</CardTitle>
<CardDescription>
If you were invited by another citizen, enter their referral code
</CardDescription>
</CardHeader>
<CardContent>
<Input {...register('referralCode')} placeholder="Optional - Leave empty to be auto-assigned to Founder" />
<p className="text-xs text-muted-foreground mt-2">
If empty, you will be automatically linked to the Founder (Satoshi Qazi Muhammed)
</p>
</CardContent>
</Card>
{/* Terms Agreement */}
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-start space-x-2">
<Checkbox id="terms" checked={agreed} onCheckedChange={(checked) => setAgreed(checked as boolean)} />
<Label htmlFor="terms" className="text-sm leading-relaxed cursor-pointer">
Ez pejirandim ku daneyên min bi awayekî ewle (ZK-proof) tên hilanîn û li ser blockchain-ê hash-a wan tomarkirin.
<br />
<span className="text-xs text-muted-foreground">
(I agree that my data is securely stored with ZK-proof and only its hash is recorded on the blockchain)
</span>
</Label>
</div>
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={submitting || !agreed} className="w-full" size="lg">
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Şandina Daxwazê...
</>
) : (
'Şandina Daxwazê (Submit Application)'
)}
</Button>
</CardContent>
</Card>
</form>
);
};
+74 -30
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Wallet, Chrome, ExternalLink, Copy, Check, LogOut, Award } from 'lucide-react';
import { Wallet, Chrome, ExternalLink, Copy, Check, LogOut, Award, Users, TrendingUp, Shield } from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { formatAddress } from '@/lib/wallet';
import { getAllScores, type UserScores } from '@/lib/scores';
interface WalletModalProps {
isOpen: boolean;
@@ -29,7 +30,14 @@ export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose }) =>
} = usePolkadot();
const [copied, setCopied] = useState(false);
const [trustScore, setTrustScore] = useState<string>('-');
const [scores, setScores] = useState<UserScores>({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
const [loadingScores, setLoadingScores] = useState(false);
const handleCopyAddress = () => {
if (selectedAccount?.address) {
@@ -53,39 +61,39 @@ export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose }) =>
onClose();
};
// Fetch trust score from blockchain
// Fetch all scores from blockchain
useEffect(() => {
const fetchTrustScore = async () => {
console.log('🔍 Fetching trust score...', {
hasApi: !!api,
isApiReady,
hasAccount: !!selectedAccount,
address: selectedAccount?.address
});
const fetchAllScores = async () => {
if (!api || !isApiReady || !selectedAccount?.address) {
console.log('⚠️ Cannot fetch trust score - missing requirements');
setTrustScore('-');
setScores({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
return;
}
setLoadingScores(true);
try {
console.log('📡 Querying api.query.trust.trustScores...');
const score = await api.query.trust.trustScores(selectedAccount.address);
const scoreStr = score.toString();
setTrustScore(scoreStr);
console.log('✅ Trust score fetched successfully:', scoreStr);
const userScores = await getAllScores(api, selectedAccount.address);
setScores(userScores);
} catch (err) {
console.error('Failed to fetch trust score:', err);
console.error('Error details:', {
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
console.error('Failed to fetch scores:', err);
setScores({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
setTrustScore('-');
} finally {
setLoadingScores(false);
}
};
fetchTrustScore();
fetchAllScores();
}, [api, isApiReady, selectedAccount]);
return (
@@ -164,12 +172,48 @@ export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose }) =>
</div>
<div>
<div className="text-xs text-gray-400 mb-1">Trust Score</div>
<div className="flex items-center gap-2">
<Award className="h-4 w-4 text-purple-400" />
<span className="text-lg font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
{trustScore}
</span>
<div className="text-xs text-gray-400 mb-2">Scores from Blockchain</div>
{loadingScores ? (
<div className="text-sm text-gray-400">Loading scores...</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-900/50 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<Shield className="h-3 w-3 text-purple-400" />
<span className="text-xs text-gray-400">Trust</span>
</div>
<span className="text-sm font-bold text-purple-400">{scores.trustScore}</span>
</div>
<div className="bg-gray-900/50 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<Users className="h-3 w-3 text-cyan-400" />
<span className="text-xs text-gray-400">Referral</span>
</div>
<span className="text-sm font-bold text-cyan-400">{scores.referralScore}</span>
</div>
<div className="bg-gray-900/50 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<TrendingUp className="h-3 w-3 text-green-400" />
<span className="text-xs text-gray-400">Staking</span>
</div>
<span className="text-sm font-bold text-green-400">{scores.stakingScore}</span>
</div>
<div className="bg-gray-900/50 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<Award className="h-3 w-3 text-pink-400" />
<span className="text-xs text-gray-400">Tiki</span>
</div>
<span className="text-sm font-bold text-pink-400">{scores.tikiScore}</span>
</div>
</div>
)}
<div className="mt-2 pt-2 border-t border-gray-700">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Total Score</span>
<span className="text-lg font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
{loadingScores ? '...' : scores.totalScore}
</span>
</div>
</div>
</div>
+1 -1
View File
@@ -23,7 +23,7 @@ interface PolkadotProviderProps {
export const PolkadotProvider: React.FC<PolkadotProviderProps> = ({
children,
endpoint = 'wss://beta.pezkuwichain.io' // Beta testnet RPC
endpoint = 'wss://beta-rpc.pezkuwi.art' // Beta testnet RPC
}) => {
const [api, setApi] = useState<ApiPromise | null>(null);
const [isApiReady, setIsApiReady] = useState(false);
+404
View File
@@ -0,0 +1,404 @@
// ========================================
// Citizenship Crypto Utilities
// ========================================
// Handles encryption, hashing, signatures for citizenship data
import { web3FromAddress } from '@polkadot/extension-dapp';
import { stringToHex, hexToU8a, u8aToHex, stringToU8a } from '@polkadot/util';
import { decodeAddress, signatureVerify, cryptoWaitReady } from '@polkadot/util-crypto';
import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types';
import type { CitizenshipData } from './citizenship-workflow';
// ========================================
// HASHING FUNCTIONS
// ========================================
/**
* Generate SHA-256 hash from data
*/
export async function generateHash(data: string): Promise<string> {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return `0x${hashHex}`;
}
/**
* Generate commitment hash from citizenship data
*/
export async function generateCommitmentHash(
data: CitizenshipData
): Promise<string> {
const dataString = JSON.stringify({
fullName: data.fullName,
fatherName: data.fatherName,
grandfatherName: data.grandfatherName,
motherName: data.motherName,
tribe: data.tribe,
maritalStatus: data.maritalStatus,
childrenCount: data.childrenCount,
children: data.children,
region: data.region,
email: data.email,
profession: data.profession,
referralCode: data.referralCode,
walletAddress: data.walletAddress,
timestamp: data.timestamp
});
return generateHash(dataString);
}
/**
* Generate nullifier hash (prevents double-registration)
*/
export async function generateNullifierHash(
walletAddress: string,
timestamp: number
): Promise<string> {
const nullifierData = `${walletAddress}-${timestamp}-nullifier`;
return generateHash(nullifierData);
}
// ========================================
// ENCRYPTION / DECRYPTION (AES-GCM)
// ========================================
/**
* Derive encryption key from wallet address
* NOTE: For MVP, we use a deterministic key. For production, use proper key derivation
*/
async function deriveEncryptionKey(walletAddress: string): Promise<CryptoKey> {
// Create a deterministic seed from wallet address
const seed = await generateHash(walletAddress);
// Convert hex to ArrayBuffer
const keyMaterial = hexToU8a(seed).slice(0, 32); // 256-bit key
// Import as AES-GCM key
return crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypt citizenship data
*/
export async function encryptData(
data: CitizenshipData,
walletAddress: string
): Promise<string> {
try {
const key = await deriveEncryptionKey(walletAddress);
// Generate random IV (Initialization Vector)
const iv = crypto.getRandomValues(new Uint8Array(12));
// Encrypt data
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(JSON.stringify(data));
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
dataBuffer
);
// Combine IV + encrypted data
const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encryptedBuffer), iv.length);
// Convert to hex
return u8aToHex(combined);
} catch (error) {
console.error('Encryption error:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Decrypt citizenship data
*/
export async function decryptData(
encryptedHex: string,
walletAddress: string
): Promise<CitizenshipData> {
try {
const key = await deriveEncryptionKey(walletAddress);
// Convert hex to Uint8Array
const combined = hexToU8a(encryptedHex);
// Extract IV and encrypted data
const iv = combined.slice(0, 12);
const encryptedData = combined.slice(12);
// Decrypt
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
encryptedData
);
// Convert to string and parse JSON
const decoder = new TextDecoder();
const decryptedString = decoder.decode(decryptedBuffer);
return JSON.parse(decryptedString) as CitizenshipData;
} catch (error) {
console.error('Decryption error:', error);
throw new Error('Failed to decrypt data');
}
}
// ========================================
// SIGNATURE GENERATION & VERIFICATION
// ========================================
export interface AuthChallenge {
nonce: string; // Random UUID
timestamp: number; // Current timestamp
tikiNumber: string; // NFT number to prove
expiresAt: number; // Expiry timestamp (5 min)
}
/**
* Generate authentication challenge
*/
export function generateAuthChallenge(tikiNumber: string): AuthChallenge {
const now = Date.now();
const nonce = crypto.randomUUID();
return {
nonce,
timestamp: now,
tikiNumber,
expiresAt: now + (5 * 60 * 1000) // 5 minutes
};
}
/**
* Format challenge message for signing
*/
export function formatChallengeMessage(challenge: AuthChallenge): string {
return `Prove ownership of Welati Tiki #${challenge.tikiNumber}
Nonce: ${challenge.nonce}
Timestamp: ${challenge.timestamp}
Expires: ${new Date(challenge.expiresAt).toISOString()}
By signing this message, you prove you control the wallet that owns this Tiki NFT.`;
}
/**
* Sign authentication challenge with wallet
*/
export async function signChallenge(
account: InjectedAccountWithMeta,
challenge: AuthChallenge
): Promise<string> {
try {
await cryptoWaitReady();
const injector = await web3FromAddress(account.address);
const signRaw = injector?.signer?.signRaw;
if (!signRaw) {
throw new Error('Signer not available');
}
const message = formatChallengeMessage(challenge);
const { signature } = await signRaw({
address: account.address,
data: stringToHex(message),
type: 'bytes'
});
return signature;
} catch (error) {
console.error('Signature error:', error);
throw new Error('Failed to sign challenge');
}
}
/**
* Verify signature
*/
export async function verifySignature(
signature: string,
challenge: AuthChallenge,
expectedAddress: string
): Promise<boolean> {
try {
await cryptoWaitReady();
// Check if challenge has expired
if (Date.now() > challenge.expiresAt) {
console.warn('Challenge has expired');
return false;
}
const message = formatChallengeMessage(challenge);
const messageU8a = stringToU8a(message);
const signatureU8a = hexToU8a(signature);
const publicKey = decodeAddress(expectedAddress);
const result = signatureVerify(messageU8a, signatureU8a, publicKey);
return result.isValid;
} catch (error) {
console.error('Verification error:', error);
return false;
}
}
// ========================================
// LOCAL STORAGE UTILITIES
// ========================================
const STORAGE_KEY_PREFIX = 'pezkuwi_citizen_';
export interface CitizenSession {
tikiNumber: string;
walletAddress: string;
sessionToken: string; // JWT-like token
encryptedDataCID?: string; // IPFS CID
lastAuthenticated: number; // Timestamp
expiresAt: number; // Session expiry (24h)
}
/**
* Save encrypted citizen session to localStorage
*/
export async function saveCitizenSession(session: CitizenSession): Promise<void> {
try {
const sessionJson = JSON.stringify(session);
const sessionKey = `${STORAGE_KEY_PREFIX}session`;
// For MVP, store plainly. For production, encrypt with device key
localStorage.setItem(sessionKey, sessionJson);
} catch (error) {
console.error('Error saving session:', error);
throw new Error('Failed to save session');
}
}
/**
* Load citizen session from localStorage
*/
export function loadCitizenSession(): CitizenSession | null {
try {
const sessionKey = `${STORAGE_KEY_PREFIX}session`;
const sessionJson = localStorage.getItem(sessionKey);
if (!sessionJson) {
return null;
}
const session = JSON.parse(sessionJson) as CitizenSession;
// Check if session has expired
if (Date.now() > session.expiresAt) {
clearCitizenSession();
return null;
}
return session;
} catch (error) {
console.error('Error loading session:', error);
return null;
}
}
/**
* Clear citizen session from localStorage
*/
export function clearCitizenSession(): void {
try {
const sessionKey = `${STORAGE_KEY_PREFIX}session`;
localStorage.removeItem(sessionKey);
} catch (error) {
console.error('Error clearing session:', error);
}
}
/**
* Save encrypted citizenship data to localStorage (backup)
*/
export async function saveLocalCitizenshipData(
data: CitizenshipData,
walletAddress: string
): Promise<void> {
try {
const encrypted = await encryptData(data, walletAddress);
const dataKey = `${STORAGE_KEY_PREFIX}data_${walletAddress}`;
localStorage.setItem(dataKey, encrypted);
} catch (error) {
console.error('Error saving citizenship data:', error);
throw new Error('Failed to save citizenship data');
}
}
/**
* Load encrypted citizenship data from localStorage
*/
export async function loadLocalCitizenshipData(
walletAddress: string
): Promise<CitizenshipData | null> {
try {
const dataKey = `${STORAGE_KEY_PREFIX}data_${walletAddress}`;
const encrypted = localStorage.getItem(dataKey);
if (!encrypted) {
return null;
}
return await decryptData(encrypted, walletAddress);
} catch (error) {
console.error('Error loading citizenship data:', error);
return null;
}
}
// ========================================
// IPFS UTILITIES (Placeholder)
// ========================================
/**
* Upload encrypted data to IPFS
* NOTE: This is a placeholder. Implement with actual IPFS client (Pinata, Web3.Storage, etc.)
*/
export async function uploadToIPFS(encryptedData: string): Promise<string> {
// TODO: Implement actual IPFS upload
// For MVP, we can use Pinata API or Web3.Storage
console.warn('IPFS upload not yet implemented. Using mock CID.');
// Mock CID for development
const mockCid = `Qm${Math.random().toString(36).substring(2, 15)}`;
return mockCid;
}
/**
* Fetch encrypted data from IPFS
* NOTE: This is a placeholder. Implement with actual IPFS client
*/
export async function fetchFromIPFS(cid: string): Promise<string> {
// TODO: Implement actual IPFS fetch
// For MVP, use public IPFS gateways or dedicated service
console.warn('IPFS fetch not yet implemented. Returning mock data.');
// Mock encrypted data
return '0x000000000000000000000000';
}
+624
View File
@@ -0,0 +1,624 @@
// ========================================
// Citizenship Workflow Library
// ========================================
// Handles citizenship verification, status checks, and workflow logic
import type { ApiPromise } from '@polkadot/api';
import { web3FromAddress } from '@polkadot/extension-dapp';
import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types';
// ========================================
// TYPE DEFINITIONS
// ========================================
export type KycStatus = 'NotStarted' | 'Pending' | 'Approved' | 'Rejected';
export type Region =
| 'bakur' // North (Turkey)
| 'basur' // South (Iraq)
| 'rojava' // West (Syria)
| 'rojhelat' // East (Iran)
| 'diaspora' // Diaspora
| 'kurdistan_a_sor'; // Red Kurdistan (Armenia/Azerbaijan)
export type MaritalStatus = 'zewici' | 'nezewici'; // Married / Unmarried
export interface ChildInfo {
name: string;
birthYear: number;
}
export interface CitizenshipData {
// Personal Identity
fullName: string;
fatherName: string;
grandfatherName: string;
motherName: string;
// Tribal Affiliation
tribe: string;
// Family Status
maritalStatus: MaritalStatus;
childrenCount?: number;
children?: ChildInfo[];
// Geographic Origin
region: Region;
// Contact & Profession
email: string;
profession: string;
// Referral
referralCode?: string;
// Metadata
walletAddress: string;
timestamp: number;
}
export interface CitizenshipCommitment {
commitmentHash: string; // SHA256 hash of all data
nullifierHash: string; // Prevents double-registration
ipfsCid: string; // IPFS CID of encrypted data
publicKey: string; // User's encryption public key
timestamp: number;
}
export interface TikiInfo {
id: string;
role: string;
metadata?: any;
}
export interface CitizenshipStatus {
kycStatus: KycStatus;
hasCitizenTiki: boolean;
tikiNumber?: string;
stakingScoreTracking: boolean;
ipfsCid?: string;
nextAction: 'APPLY_KYC' | 'CLAIM_TIKI' | 'START_TRACKING' | 'COMPLETE';
}
// ========================================
// KYC STATUS CHECKS
// ========================================
/**
* Get KYC status for a wallet address
*/
export async function getKycStatus(
api: ApiPromise,
address: string
): Promise<KycStatus> {
try {
if (!api?.query?.identityKyc) {
console.warn('Identity KYC pallet not available');
return 'NotStarted';
}
const status = await api.query.identityKyc.kycStatuses(address);
if (status.isEmpty) {
return 'NotStarted';
}
const statusStr = status.toString();
// Map on-chain status to our type
if (statusStr === 'Approved') return 'Approved';
if (statusStr === 'Pending') return 'Pending';
if (statusStr === 'Rejected') return 'Rejected';
return 'NotStarted';
} catch (error) {
console.error('Error fetching KYC status:', error);
return 'NotStarted';
}
}
/**
* Check if user has pending KYC application
*/
export async function hasPendingApplication(
api: ApiPromise,
address: string
): Promise<boolean> {
try {
if (!api?.query?.identityKyc?.pendingKycApplications) {
return false;
}
const application = await api.query.identityKyc.pendingKycApplications(address);
return !application.isEmpty;
} catch (error) {
console.error('Error checking pending application:', error);
return false;
}
}
// ========================================
// TIKI / CITIZENSHIP CHECKS
// ========================================
/**
* Get all Tiki roles for a user
*/
// Tiki enum mapping from pallet-tiki
const TIKI_ROLES = [
'Hemwelatî', 'Parlementer', 'SerokiMeclise', 'Serok', 'Wezir', 'EndameDiwane', 'Dadger',
'Dozger', 'Hiquqnas', 'Noter', 'Xezinedar', 'Bacgir', 'GerinendeyeCavkaniye', 'OperatorêTorê',
'PisporêEwlehiyaSîber', 'GerinendeyeDaneye', 'Berdevk', 'Qeydkar', 'Balyoz', 'Navbeynkar',
'ParêzvaneÇandî', 'Mufetîs', 'KalîteKontrolker', 'Mela', 'Feqî', 'Perwerdekar', 'Rewsenbîr',
'RêveberêProjeyê', 'SerokêKomele', 'ModeratorêCivakê', 'Axa', 'Pêseng', 'Sêwirmend', 'Hekem', 'Mamoste',
'Bazargan',
'SerokWeziran', 'WezireDarayiye', 'WezireParez', 'WezireDad', 'WezireBelaw', 'WezireTend', 'WezireAva', 'WezireCand'
];
export async function getUserTikis(
api: ApiPromise,
address: string
): Promise<TikiInfo[]> {
try {
if (!api?.query?.tiki?.userTikis) {
console.warn('Tiki pallet not available');
return [];
}
const tikis = await api.query.tiki.userTikis(address);
if (tikis.isEmpty) {
return [];
}
// userTikis returns a BoundedVec of Tiki enum values (as indices)
const tikiIndices = tikis.toJSON() as number[];
return tikiIndices.map((index, i) => ({
id: `${index}`,
role: TIKI_ROLES[index] || `Unknown Role (${index})`,
metadata: {}
}));
} catch (error) {
console.error('Error fetching user tikis:', error);
return [];
}
}
/**
* Check if user has Welati (Citizen) Tiki
* Backend checks for "Hemwelatî" (actual blockchain role name)
*/
export async function hasCitizenTiki(
api: ApiPromise,
address: string
): Promise<{ hasTiki: boolean; tikiNumber?: string }> {
try {
const tikis = await getUserTikis(api, address);
const citizenTiki = tikis.find(t =>
t.role.toLowerCase() === 'hemwelatî' ||
t.role.toLowerCase() === 'welati' ||
t.role.toLowerCase() === 'citizen'
);
return {
hasTiki: !!citizenTiki,
tikiNumber: citizenTiki?.id
};
} catch (error) {
console.error('Error checking citizen tiki:', error);
return { hasTiki: false };
}
}
/**
* Verify NFT ownership by NFT number
*/
export async function verifyNftOwnership(
api: ApiPromise,
nftNumber: string,
walletAddress: string
): Promise<boolean> {
try {
const tikis = await getUserTikis(api, walletAddress);
return tikis.some(tiki =>
tiki.id === nftNumber &&
(
tiki.role.toLowerCase() === 'hemwelatî' ||
tiki.role.toLowerCase() === 'welati' ||
tiki.role.toLowerCase() === 'citizen'
)
);
} catch (error) {
console.error('Error verifying NFT ownership:', error);
return false;
}
}
// ========================================
// STAKING SCORE TRACKING
// ========================================
/**
* Check if staking score tracking has been started
*/
export async function isStakingScoreTracking(
api: ApiPromise,
address: string
): Promise<boolean> {
try {
if (!api?.query?.stakingScore?.stakingStartBlock) {
console.warn('Staking score pallet not available');
return false;
}
const startBlock = await api.query.stakingScore.stakingStartBlock(address);
return !startBlock.isNone;
} catch (error) {
console.error('Error checking staking score tracking:', error);
return false;
}
}
/**
* Check if user is staking
*/
export async function isStaking(
api: ApiPromise,
address: string
): Promise<boolean> {
try {
if (!api?.query?.staking?.ledger) {
return false;
}
const ledger = await api.query.staking.ledger(address);
return !ledger.isNone;
} catch (error) {
console.error('Error checking staking status:', error);
return false;
}
}
// ========================================
// COMPREHENSIVE CITIZENSHIP STATUS
// ========================================
/**
* Get complete citizenship status and next action needed
*/
export async function getCitizenshipStatus(
api: ApiPromise,
address: string
): Promise<CitizenshipStatus> {
try {
if (!api || !address) {
return {
kycStatus: 'NotStarted',
hasCitizenTiki: false,
stakingScoreTracking: false,
nextAction: 'APPLY_KYC'
};
}
// Fetch all status in parallel
const [kycStatus, citizenCheck, stakingTracking] = await Promise.all([
getKycStatus(api, address),
hasCitizenTiki(api, address),
isStakingScoreTracking(api, address)
]);
const kycApproved = kycStatus === 'Approved';
const hasTiki = citizenCheck.hasTiki;
// Determine next action
let nextAction: CitizenshipStatus['nextAction'];
if (!kycApproved) {
nextAction = 'APPLY_KYC';
} else if (!hasTiki) {
nextAction = 'CLAIM_TIKI';
} else if (!stakingTracking) {
nextAction = 'START_TRACKING';
} else {
nextAction = 'COMPLETE';
}
return {
kycStatus,
hasCitizenTiki: hasTiki,
tikiNumber: citizenCheck.tikiNumber,
stakingScoreTracking: stakingTracking,
nextAction
};
} catch (error) {
console.error('Error fetching citizenship status:', error);
return {
kycStatus: 'NotStarted',
hasCitizenTiki: false,
stakingScoreTracking: false,
nextAction: 'APPLY_KYC'
};
}
}
// ========================================
// IPFS COMMITMENT RETRIEVAL
// ========================================
/**
* Get IPFS CID for citizen data
*/
export async function getCitizenDataCid(
api: ApiPromise,
address: string
): Promise<string | null> {
try {
if (!api?.query?.identityKyc?.identities) {
return null;
}
// Try to get from identity storage
// This assumes the pallet stores IPFS CID somewhere
// Adjust based on actual pallet storage structure
const identity = await api.query.identityKyc.identities(address);
if (identity.isNone) {
return null;
}
const identityData = identity.unwrap().toJSON() as any;
// Try different possible field names
return identityData.ipfsCid ||
identityData.cid ||
identityData.dataCid ||
null;
} catch (error) {
console.error('Error fetching citizen data CID:', error);
return null;
}
}
// ========================================
// REFERRAL VALIDATION
// ========================================
/**
* Validate referral code
*/
export async function validateReferralCode(
api: ApiPromise,
referralCode: string
): Promise<boolean> {
try {
if (!referralCode || referralCode.trim() === '') {
return true; // Empty is valid (will use founder)
}
// Check if referral code exists in trust pallet
if (!api?.query?.trust?.referrals) {
return false;
}
// Referral code could be an address or custom code
// For now, check if it's a valid address format
// TODO: Implement proper referral code lookup
return referralCode.length > 0;
} catch (error) {
console.error('Error validating referral code:', error);
return false;
}
}
// ========================================
// BLOCKCHAIN TRANSACTIONS
// ========================================
/**
* Submit KYC application to blockchain
* This is a two-step process:
* 1. Set identity (name, email)
* 2. Apply for KYC (IPFS CID, notes)
*/
export async function submitKycApplication(
api: ApiPromise,
account: InjectedAccountWithMeta,
name: string,
email: string,
ipfsCid: string,
notes: string = 'Citizenship application'
): Promise<{ success: boolean; error?: string; blockHash?: string }> {
try {
if (!api?.tx?.identityKyc?.setIdentity || !api?.tx?.identityKyc?.applyForKyc) {
return { success: false, error: 'Identity KYC pallet not available' };
}
// Check if user already has a pending KYC application
const pendingApp = await api.query.identityKyc.pendingKycApplications(account.address);
if (!pendingApp.isEmpty) {
console.log('⚠️ User already has a pending KYC application');
return {
success: false,
error: 'You already have a pending citizenship application. Please wait for approval.'
};
}
// Check if user is already approved
const kycStatus = await api.query.identityKyc.kycStatuses(account.address);
if (kycStatus.toString() === 'Approved') {
console.log('✅ User KYC is already approved');
return {
success: false,
error: 'Your citizenship application is already approved!'
};
}
// Get the injector for signing
const injector = await web3FromAddress(account.address);
// Debug logging
console.log('=== submitKycApplication Debug ===');
console.log('account.address:', account.address);
console.log('name:', name);
console.log('email:', email);
console.log('ipfsCid:', ipfsCid);
console.log('notes:', notes);
console.log('===================================');
// Ensure ipfsCid is a string
const cidString = String(ipfsCid);
if (!cidString || cidString === 'undefined' || cidString === '[object Object]') {
return { success: false, error: `Invalid IPFS CID received: ${cidString}` };
}
// Step 1: Set identity first
console.log('Step 1: Setting identity...');
const identityResult = await new Promise<{ success: boolean; error?: string }>((resolve, reject) => {
api.tx.identityKyc
.setIdentity(name, email)
.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError, events }) => {
console.log('Identity transaction status:', status.type);
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Identity transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else {
errorMessage = dispatchError.toString();
}
console.error('Identity transaction error:', errorMessage);
resolve({ success: false, error: errorMessage });
return;
}
// Check for IdentitySet event
const identitySetEvent = events.find(({ event }) =>
event.section === 'identityKyc' && event.method === 'IdentitySet'
);
if (identitySetEvent) {
console.log('✅ Identity set successfully');
resolve({ success: true });
} else {
resolve({ success: true }); // Still consider it success if in block
}
}
})
.catch((error) => {
console.error('Failed to sign and send identity transaction:', error);
reject(error);
});
});
if (!identityResult.success) {
return identityResult;
}
// Step 2: Apply for KYC
console.log('Step 2: Applying for KYC...');
const result = await new Promise<{ success: boolean; error?: string; blockHash?: string }>((resolve, reject) => {
api.tx.identityKyc
.applyForKyc([cidString], notes)
.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError, events }) => {
console.log('Transaction status:', status.type);
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else {
errorMessage = dispatchError.toString();
}
console.error('Transaction error:', errorMessage);
resolve({ success: false, error: errorMessage });
return;
}
// Check for KycApplied event
const kycAppliedEvent = events.find(({ event }) =>
event.section === 'identityKyc' && event.method === 'KycApplied'
);
if (kycAppliedEvent) {
console.log('✅ KYC Application submitted successfully');
resolve({
success: true,
blockHash: status.asInBlock.toString()
});
} else {
console.warn('Transaction included but KycApplied event not found');
resolve({ success: true });
}
}
})
.catch((error) => {
console.error('Failed to sign and send transaction:', error);
reject(error);
});
});
return result;
} catch (error: any) {
console.error('Error submitting KYC application:', error);
return {
success: false,
error: error.message || 'Failed to submit KYC application'
};
}
}
/**
* Subscribe to KYC approval events for an address
*/
export function subscribeToKycApproval(
api: ApiPromise,
address: string,
onApproved: () => void,
onError?: (error: string) => void
): () => void {
try {
if (!api?.query?.system?.events) {
console.error('Cannot subscribe to events: system.events not available');
if (onError) onError('Event subscription not available');
return () => {};
}
const unsubscribe = api.query.system.events((events) => {
events.forEach((record) => {
const { event } = record;
if (event.section === 'identityKyc' && event.method === 'KycApproved') {
const [approvedAddress] = event.data;
if (approvedAddress.toString() === address) {
console.log('✅ KYC Approved for:', address);
onApproved();
}
}
});
});
return unsubscribe as unknown as () => void;
} catch (error: any) {
console.error('Error subscribing to KYC approval:', error);
if (onError) onError(error.message || 'Failed to subscribe to approval events');
return () => {};
}
}
// ========================================
// FOUNDER ADDRESS
// ========================================
export const FOUNDER_ADDRESS = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; // Satoshi Qazi Muhammed
+355
View File
@@ -0,0 +1,355 @@
// ========================================
// Score Systems Integration
// ========================================
// Centralized score fetching from blockchain pallets
import type { ApiPromise } from '@polkadot/api';
// ========================================
// TYPE DEFINITIONS
// ========================================
export interface UserScores {
trustScore: number;
referralScore: number;
stakingScore: number;
tikiScore: number;
totalScore: number;
}
export interface TrustScoreDetails {
totalScore: number;
stakingPoints: number;
referralPoints: number;
tikiPoints: number;
activityPoints: number;
historyLength: number;
}
// ========================================
// TRUST SCORE (pallet_trust)
// ========================================
/**
* Fetch user's trust score from blockchain
* pallet_trust::TrustScores storage
*/
export async function getTrustScore(
api: ApiPromise,
address: string
): Promise<number> {
try {
if (!api?.query?.trust) {
console.warn('Trust pallet not available');
return 0;
}
const score = await api.query.trust.trustScores(address);
if (score.isEmpty) {
return 0;
}
return Number(score.toString());
} catch (error) {
console.error('Error fetching trust score:', error);
return 0;
}
}
/**
* Fetch detailed trust score breakdown
* pallet_trust::ScoreHistory storage
*/
export async function getTrustScoreDetails(
api: ApiPromise,
address: string
): Promise<TrustScoreDetails | null> {
try {
if (!api?.query?.trust) {
return null;
}
const totalScore = await getTrustScore(api, address);
// Get score history to show detailed breakdown
const historyResult = await api.query.trust.scoreHistory(address);
if (historyResult.isEmpty) {
return {
totalScore,
stakingPoints: 0,
referralPoints: 0,
tikiPoints: 0,
activityPoints: 0,
historyLength: 0
};
}
const history = historyResult.toJSON() as any[];
// Calculate points from history
// History format: [{blockNumber, score, reason}]
let stakingPoints = 0;
let referralPoints = 0;
let tikiPoints = 0;
let activityPoints = 0;
for (const entry of history) {
const reason = entry.reason || '';
const score = entry.score || 0;
if (reason.includes('Staking')) stakingPoints += score;
else if (reason.includes('Referral')) referralPoints += score;
else if (reason.includes('Tiki') || reason.includes('Role')) tikiPoints += score;
else activityPoints += score;
}
return {
totalScore,
stakingPoints,
referralPoints,
tikiPoints,
activityPoints,
historyLength: history.length
};
} catch (error) {
console.error('Error fetching trust score details:', error);
return null;
}
}
// ========================================
// REFERRAL SCORE (pallet_trust)
// ========================================
/**
* Fetch user's referral score
* pallet_trust::ReferralScores storage
*/
export async function getReferralScore(
api: ApiPromise,
address: string
): Promise<number> {
try {
if (!api?.query?.trust?.referralScores) {
console.warn('Referral scores not available in trust pallet');
return 0;
}
const score = await api.query.trust.referralScores(address);
if (score.isEmpty) {
return 0;
}
return Number(score.toString());
} catch (error) {
console.error('Error fetching referral score:', error);
return 0;
}
}
/**
* Get referral count for user
* pallet_trust::Referrals storage
*/
export async function getReferralCount(
api: ApiPromise,
address: string
): Promise<number> {
try {
if (!api?.query?.trust?.referrals) {
return 0;
}
const referrals = await api.query.trust.referrals(address);
if (referrals.isEmpty) {
return 0;
}
const referralList = referrals.toJSON() as any[];
return Array.isArray(referralList) ? referralList.length : 0;
} catch (error) {
console.error('Error fetching referral count:', error);
return 0;
}
}
// ========================================
// STAKING SCORE (pallet_staking_score)
// ========================================
/**
* Get staking score from pallet_staking_score
* This is already implemented in lib/staking.ts
* Re-exported here for consistency
*/
export async function getStakingScoreFromPallet(
api: ApiPromise,
address: string
): Promise<number> {
try {
if (!api?.query?.stakingScore) {
console.warn('Staking score pallet not available');
return 0;
}
// Check if user has started score tracking
const scoreResult = await api.query.stakingScore.stakingStartBlock(address);
if (scoreResult.isNone) {
return 0;
}
// Get staking info from staking pallet
const ledger = await api.query.staking.ledger(address);
if (ledger.isNone) {
return 0;
}
const ledgerData = ledger.unwrap().toJSON() as any;
const stakedAmount = Number(ledgerData.total || 0) / 1e12; // Convert to HEZ
// Get duration
const startBlock = Number(scoreResult.unwrap().toString());
const currentBlock = Number((await api.query.system.number()).toString());
const durationInBlocks = currentBlock - startBlock;
// Calculate score based on amount and duration
// Amount-based score (20-50 points)
let amountScore = 20;
if (stakedAmount <= 100) amountScore = 20;
else if (stakedAmount <= 250) amountScore = 30;
else if (stakedAmount <= 750) amountScore = 40;
else amountScore = 50;
// Duration multiplier
const MONTH_IN_BLOCKS = 30 * 24 * 60 * 10; // ~30 days
let durationMultiplier = 1.0;
if (durationInBlocks >= 12 * MONTH_IN_BLOCKS) durationMultiplier = 2.0;
else if (durationInBlocks >= 6 * MONTH_IN_BLOCKS) durationMultiplier = 1.7;
else if (durationInBlocks >= 3 * MONTH_IN_BLOCKS) durationMultiplier = 1.4;
else if (durationInBlocks >= MONTH_IN_BLOCKS) durationMultiplier = 1.2;
return Math.min(100, Math.floor(amountScore * durationMultiplier));
} catch (error) {
console.error('Error fetching staking score:', error);
return 0;
}
}
// ========================================
// TIKI SCORE (from lib/tiki.ts)
// ========================================
/**
* Calculate Tiki score from user's roles
* Import from lib/tiki.ts
*/
import { fetchUserTikis, calculateTikiScore } from './tiki';
export async function getTikiScore(
api: ApiPromise,
address: string
): Promise<number> {
try {
const tikis = await fetchUserTikis(api, address);
return calculateTikiScore(tikis);
} catch (error) {
console.error('Error fetching tiki score:', error);
return 0;
}
}
// ========================================
// COMPREHENSIVE SCORE FETCHING
// ========================================
/**
* Fetch all scores for a user in one call
*/
export async function getAllScores(
api: ApiPromise,
address: string
): Promise<UserScores> {
try {
if (!api || !address) {
return {
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
};
}
// Fetch all scores in parallel
const [trustScore, referralScore, stakingScore, tikiScore] = await Promise.all([
getTrustScore(api, address),
getReferralScore(api, address),
getStakingScoreFromPallet(api, address),
getTikiScore(api, address)
]);
const totalScore = trustScore + referralScore + stakingScore + tikiScore;
return {
trustScore,
referralScore,
stakingScore,
tikiScore,
totalScore
};
} catch (error) {
console.error('Error fetching all scores:', error);
return {
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
};
}
}
// ========================================
// SCORE DISPLAY HELPERS
// ========================================
/**
* Get color class based on score
*/
export function getScoreColor(score: number): string {
if (score >= 200) return 'text-purple-500';
if (score >= 150) return 'text-pink-500';
if (score >= 100) return 'text-blue-500';
if (score >= 70) return 'text-cyan-500';
if (score >= 40) return 'text-teal-500';
if (score >= 20) return 'text-green-500';
return 'text-gray-500';
}
/**
* Get score rating label
*/
export function getScoreRating(score: number): string {
if (score >= 250) return 'Legendary';
if (score >= 200) return 'Excellent';
if (score >= 150) return 'Very Good';
if (score >= 100) return 'Good';
if (score >= 70) return 'Average';
if (score >= 40) return 'Fair';
if (score >= 20) return 'Low';
return 'Very Low';
}
/**
* Format score for display
*/
export function formatScore(score: number): string {
return score.toFixed(0);
}
+206
View File
@@ -0,0 +1,206 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CitizenshipModal } from '@/components/citizenship/CitizenshipModal';
import { Shield, Users, Award, Globe, ChevronRight, ArrowLeft, Home } from 'lucide-react';
const BeCitizen: React.FC = () => {
const navigate = useNavigate();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900">
<div className="container mx-auto px-4 py-16">
{/* Back to Home Button */}
<div className="mb-8">
<Button
onClick={() => navigate('/')}
variant="outline"
className="bg-white/10 hover:bg-white/20 border-white/30 text-white"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Button>
</div>
{/* Hero Section */}
<div className="text-center mb-16">
<h1 className="text-5xl md:text-6xl font-bold text-white mb-6">
🏛 Digital Kurdistan
</h1>
<h2 className="text-3xl md:text-4xl font-semibold text-cyan-300 mb-4">
Bibe Welati / Be a Citizen
</h2>
<p className="text-xl text-gray-200 max-w-3xl mx-auto">
Join the Digital Kurdistan State as a sovereign citizen. Receive your Welati Tiki NFT and unlock governance, trust scoring, and community benefits.
</p>
</div>
{/* Benefits Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
<Card className="bg-white/10 backdrop-blur-md border-cyan-500/30 hover:border-cyan-500 transition-all">
<CardHeader>
<Shield className="h-12 w-12 text-cyan-400 mb-3" />
<CardTitle className="text-white">Privacy Protected</CardTitle>
<CardDescription className="text-gray-300">
Your data is encrypted with ZK-proofs. Only hashes are stored on-chain.
</CardDescription>
</CardHeader>
</Card>
<Card className="bg-white/10 backdrop-blur-md border-purple-500/30 hover:border-purple-500 transition-all">
<CardHeader>
<Award className="h-12 w-12 text-purple-400 mb-3" />
<CardTitle className="text-white">Welati Tiki NFT</CardTitle>
<CardDescription className="text-gray-300">
Receive your unique soulbound citizenship NFT after KYC approval.
</CardDescription>
</CardHeader>
</Card>
<Card className="bg-white/10 backdrop-blur-md border-green-500/30 hover:border-green-500 transition-all">
<CardHeader>
<Users className="h-12 w-12 text-green-400 mb-3" />
<CardTitle className="text-white">Trust Scoring</CardTitle>
<CardDescription className="text-gray-300">
Build trust through referrals, staking, and community contributions.
</CardDescription>
</CardHeader>
</Card>
<Card className="bg-white/10 backdrop-blur-md border-yellow-500/30 hover:border-yellow-500 transition-all">
<CardHeader>
<Globe className="h-12 w-12 text-yellow-400 mb-3" />
<CardTitle className="text-white">Governance Access</CardTitle>
<CardDescription className="text-gray-300">
Participate in on-chain governance and shape the future of Digital Kurdistan.
</CardDescription>
</CardHeader>
</Card>
</div>
{/* CTA Section */}
<div className="max-w-4xl mx-auto">
<Card className="bg-white/5 backdrop-blur-lg border-cyan-500/50">
<CardContent className="pt-8 pb-8">
<div className="text-center space-y-6">
<div>
<h3 className="text-2xl font-bold text-white mb-3">Ready to Join?</h3>
<p className="text-gray-300 mb-6">
Whether you're already a citizen or want to become one, start your journey here.
</p>
</div>
<Button
onClick={() => setIsModalOpen(true)}
size="lg"
className="bg-gradient-to-r from-cyan-500 to-purple-600 hover:from-cyan-600 hover:to-purple-700 text-white font-semibold px-8 py-6 text-lg group"
>
<span>Start Citizenship Process</span>
<ChevronRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Button>
<div className="flex flex-col md:flex-row gap-4 justify-center items-center text-sm text-gray-400 pt-4">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
<span>Secure ZK-Proof Authentication</span>
</div>
<div className="hidden md:block"></div>
<div className="flex items-center gap-2">
<Award className="h-4 w-4" />
<span>Soulbound NFT Citizenship</span>
</div>
<div className="hidden md:block"></div>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4" />
<span>Decentralized Identity</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Process Overview */}
<div className="mt-16 max-w-5xl mx-auto">
<h3 className="text-3xl font-bold text-white text-center mb-8">How It Works</h3>
<div className="grid md:grid-cols-3 gap-8">
{/* Existing Citizens */}
<Card className="bg-white/5 backdrop-blur-md border-cyan-500/30">
<CardHeader>
<div className="bg-cyan-500/20 w-12 h-12 rounded-full flex items-center justify-center mb-4">
<span className="text-2xl font-bold text-cyan-400">1</span>
</div>
<CardTitle className="text-white">Already a Citizen?</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-2 text-sm">
<p> Enter your Welati Tiki NFT number</p>
<p> Verify NFT ownership on-chain</p>
<p> Sign authentication challenge</p>
<p> Access your citizen dashboard</p>
</CardContent>
</Card>
{/* New Citizens */}
<Card className="bg-white/5 backdrop-blur-md border-purple-500/30">
<CardHeader>
<div className="bg-purple-500/20 w-12 h-12 rounded-full flex items-center justify-center mb-4">
<span className="text-2xl font-bold text-purple-400">2</span>
</div>
<CardTitle className="text-white">New to Citizenship?</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 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>
</CardContent>
</Card>
{/* After Citizenship */}
<Card className="bg-white/5 backdrop-blur-md border-green-500/30">
<CardHeader>
<div className="bg-green-500/20 w-12 h-12 rounded-full flex items-center justify-center mb-4">
<span className="text-2xl font-bold text-green-400">3</span>
</div>
<CardTitle className="text-white">Citizen Benefits</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-2 text-sm">
<p> Trust score calculation enabled</p>
<p> Governance voting rights</p>
<p> Referral tree participation</p>
<p> Staking multiplier bonuses</p>
</CardContent>
</Card>
</div>
</div>
{/* Security Notice */}
<div className="mt-12 max-w-3xl mx-auto">
<Card className="bg-yellow-500/10 border-yellow-500/30">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<Shield className="h-6 w-6 text-yellow-400 mt-1 flex-shrink-0" />
<div className="text-sm text-gray-200">
<p className="font-semibold text-yellow-400 mb-2">Privacy & Security</p>
<p>
Your personal data is encrypted using AES-GCM with your wallet-derived keys.
Only commitment hashes are stored on the blockchain. Encrypted data is stored
on IPFS and locally on your device. No personal information is ever publicly visible.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Citizenship Modal */}
<CitizenshipModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
);
};
export default BeCitizen;
+89 -16
View File
@@ -7,9 +7,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAuth } from '@/contexts/AuthContext';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { supabase } from '@/lib/supabase';
import { User, Mail, Phone, Globe, MapPin, Calendar, Shield, AlertCircle, ArrowLeft, Award } from 'lucide-react';
import { User, Mail, Phone, Globe, MapPin, Calendar, Shield, AlertCircle, ArrowLeft, Award, Users, TrendingUp } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { fetchUserTikis, calculateTikiScore, getPrimaryRole, getTikiDisplayName, getTikiColor, getTikiEmoji, getUserRoleCategories } from '@/lib/tiki';
import { getAllScores, type UserScores } from '@/lib/scores';
export default function Dashboard() {
const { user } = useAuth();
@@ -19,13 +20,19 @@ export default function Dashboard() {
const [profile, setProfile] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [tikis, setTikis] = useState<string[]>([]);
const [tikiScore, setTikiScore] = useState<number>(0);
const [loadingTikis, setLoadingTikis] = useState(false);
const [scores, setScores] = useState<UserScores>({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
const [loadingScores, setLoadingScores] = useState(false);
useEffect(() => {
fetchProfile();
if (selectedAccount && api && isApiReady) {
fetchTikiData();
fetchScoresAndTikis();
}
}, [user, selectedAccount, api, isApiReady]);
@@ -85,18 +92,22 @@ export default function Dashboard() {
}
};
const fetchTikiData = async () => {
const fetchScoresAndTikis = async () => {
if (!selectedAccount || !api) return;
setLoadingTikis(true);
setLoadingScores(true);
try {
// Fetch all scores from blockchain (includes trust, referral, staking, tiki)
const allScores = await getAllScores(api, selectedAccount.address);
setScores(allScores);
// Also fetch tikis separately for role display (needed for role details)
const userTikis = await fetchUserTikis(api, selectedAccount.address);
setTikis(userTikis);
setTikiScore(calculateTikiScore(userTikis));
} catch (error) {
console.error('Error fetching tiki data:', error);
console.error('Error fetching scores and tikis:', error);
} finally {
setLoadingTikis(false);
setLoadingScores(false);
}
};
@@ -167,7 +178,7 @@ export default function Dashboard() {
};
const getRoleDisplay = (): string => {
if (loadingTikis) return 'Loading...';
if (loadingScores) return 'Loading...';
if (!selectedAccount) return 'Member';
if (tikis.length === 0) return 'Member';
@@ -248,12 +259,74 @@ export default function Dashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tiki Score</CardTitle>
<CardTitle className="text-sm font-medium">Total Score</CardTitle>
<Award className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loadingTikis ? '...' : tikiScore}
{loadingScores ? '...' : scores.totalScore}
</div>
<p className="text-xs text-muted-foreground">
Combined from all score types
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Trust Score</CardTitle>
<Shield className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-purple-600">
{loadingScores ? '...' : scores.trustScore}
</div>
<p className="text-xs text-muted-foreground">
From pallet_trust
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Referral Score</CardTitle>
<Users className="h-4 w-4 text-cyan-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-cyan-600">
{loadingScores ? '...' : scores.referralScore}
</div>
<p className="text-xs text-muted-foreground">
From referral system
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Staking Score</CardTitle>
<TrendingUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{loadingScores ? '...' : scores.stakingScore}
</div>
<p className="text-xs text-muted-foreground">
From pallet_staking_score
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tiki Score</CardTitle>
<Award className="h-4 w-4 text-pink-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-pink-600">
{loadingScores ? '...' : scores.tikiScore}
</div>
<p className="text-xs text-muted-foreground">
{tikis.length} {tikis.length === 1 ? 'role' : 'roles'} assigned
@@ -347,13 +420,13 @@ export default function Dashboard() {
</div>
)}
{selectedAccount && loadingTikis && (
{selectedAccount && loadingScores && (
<div className="text-center py-8">
<p className="text-muted-foreground">Loading roles from blockchain...</p>
</div>
)}
{selectedAccount && !loadingTikis && tikis.length === 0 && (
{selectedAccount && !loadingScores && tikis.length === 0 && (
<div className="text-center py-8">
<Award className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-2">
@@ -365,7 +438,7 @@ export default function Dashboard() {
</div>
)}
{selectedAccount && !loadingTikis && tikis.length > 0 && (
{selectedAccount && !loadingScores && tikis.length > 0 && (
<div className="space-y-4">
<div className="grid gap-2">
<div className="flex items-center gap-2">
@@ -376,7 +449,7 @@ export default function Dashboard() {
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Total Score:</span>
<span className="text-lg font-bold text-purple-600">{tikiScore}</span>
<span className="text-lg font-bold text-purple-600">{scores.totalScore}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Categories:</span>
+4
View File
@@ -5,6 +5,7 @@ import { AccountBalance } from '@/components/AccountBalance';
import { TransferModal } from '@/components/TransferModal';
import { ReceiveModal } from '@/components/ReceiveModal';
import { TransactionHistory } from '@/components/TransactionHistory';
import { NftList } from '@/components/NftList';
import { Button } from '@/components/ui/button';
import { ArrowUpRight, ArrowDownRight, History, ArrowLeft, Activity } from 'lucide-react';
@@ -104,6 +105,9 @@ const WalletDashboard: React.FC = () => {
</Button>
</div>
</div>
{/* NFT Collection */}
<NftList />
</div>
</div>
</div>