diff --git a/contracts/ZKPassportVerifier.sol b/contracts/ZKPassportVerifier.sol index f1979e0..ee640b6 100644 --- a/contracts/ZKPassportVerifier.sol +++ b/contracts/ZKPassportVerifier.sol @@ -31,6 +31,7 @@ interface IZKVerifier { contract ZKPassportVerifier { // Structure to store user verification data struct Verification { + bool initialized; // Whether the user has set their verification data bool adult; // Whether user is 18+ string country; // User's country of nationality string gender; // User's gender @@ -39,8 +40,9 @@ contract ZKPassportVerifier { // Mapping from user address to their verification data mapping(address => Verification) public verifications; - // Mapping to track used unique identifiers - mapping(bytes32 => bool) public usedUniqueIdentifiers; + // Mapping to track used unique identifiers per wallet + // This prevents cross-wallet correlation while maintaining Sybil resistance + mapping(address => mapping(bytes32 => bool)) public walletUniqueIdentifiers; // Address of the ZKVerifier contract IZKVerifier public zkVerifier; @@ -81,14 +83,47 @@ contract ZKPassportVerifier { // Revert if proof is not valid require(verified, "Proof verification failed"); - // Check if this unique identifier has already been used - require(!usedUniqueIdentifiers[uniqueIdentifier], "Unique identifier already used"); + // Always enforce wallet-scoped uniqueness + require(!walletUniqueIdentifiers[msg.sender][uniqueIdentifier], "Unique identifier already used by this wallet"); - // Mark this unique identifier as used - usedUniqueIdentifiers[uniqueIdentifier] = true; + // Mark this unique identifier as used by this wallet + walletUniqueIdentifiers[msg.sender][uniqueIdentifier] = true; // Store the verification data verifications[msg.sender] = Verification({ + initialized: true, + adult: adult, + country: country, + gender: gender + }); + + emit VerificationUpdated(msg.sender, adult, country, gender); + } + + /** + * @notice Update verification data without requiring a new proof + * @param adult Whether the sender is 18+ + * @param country The sender's country of nationality + * @param gender The sender's gender + */ + function updateVerification( + bool adult, + string calldata country, + string calldata gender, + ProofVerificationParams calldata params + ) external { + // Verify the proof first using the ZKVerifier contract + (bool verified, bytes32 uniqueIdentifier) = zkVerifier.verifyProof(params); + + // Revert if proof is not valid + require(verified, "Proof verification failed"); + + // Always enforce wallet-scoped uniqueness + require(walletUniqueIdentifiers[msg.sender][uniqueIdentifier], "Unique identifier already used by this wallet"); + + // Update the verification data + verifications[msg.sender] = Verification({ + initialized: true, adult: adult, country: country, gender: gender @@ -106,9 +141,9 @@ contract ZKPassportVerifier { */ function getVerification(address user) external view - returns (bool, string memory, string memory) + returns (bool, bool, string memory, string memory) { Verification storage verification = verifications[user]; - return (verification.adult, verification.country, verification.gender); + return (verification.initialized, verification.adult, verification.country, verification.gender); } } \ No newline at end of file diff --git a/src/components/ui/author-display.tsx b/src/components/ui/author-display.tsx index 04b0bf7..5328496 100644 --- a/src/components/ui/author-display.tsx +++ b/src/components/ui/author-display.tsx @@ -13,7 +13,7 @@ export function AuthorDisplay({ className = '', showBadge = true, }: AuthorDisplayProps) { - const { displayName, callSign, ensName, ordinalDetails } = + const { displayName, callSign, ensName, ordinalDetails, countryFlag, ageEmoji, genderEmoji } = useUserDisplay(address); // Only show a badge if the author has ENS, Ordinal, or Call Sign @@ -21,7 +21,12 @@ export function AuthorDisplay({ return (
- {displayName} + + {displayName} + {countryFlag && {countryFlag}} + {ageEmoji && {ageEmoji}} + {genderEmoji && {genderEmoji}} + {shouldShowBadge && ( '🇺🇸' + return countryCode + .toUpperCase() + .replace(/./g, char => + String.fromCodePoint(127397 + char.charCodeAt(0)) + ); + } + return displayInfo; } diff --git a/src/lib/services/UserIdentityService.ts b/src/lib/services/UserIdentityService.ts index 663d512..0d07b31 100644 --- a/src/lib/services/UserIdentityService.ts +++ b/src/lib/services/UserIdentityService.ts @@ -9,6 +9,7 @@ import { MessageService } from './MessageService'; import messageManager from '@/lib/waku'; import { localDatabase } from '@/lib/database/LocalDatabase'; import { WalletManager } from '@/lib/wallet'; +import { getVerification } from '../zkPassport'; export interface UserIdentity { address: string; @@ -393,6 +394,7 @@ export class UserIdentityService { displayPreference, lastUpdated: timestamp, verificationStatus: EVerificationStatus.WALLET_UNCONNECTED, + identityProviders: [] }; } @@ -711,4 +713,187 @@ export class UserIdentityService { return `${address.slice(0, 6)}...${address.slice(-4)}`; } + + /** + * Get ZKPassport claims with multi-layer cache support + */ + async getZKPassportClaims(address: string): Promise { + // 1. Check in-memory cache first (fastest) + if (this.userIdentityCache[address]?.identityProviders) { + const zkPassportProvider = this.userIdentityCache[address].identityProviders?.find(p => p.type === 'zkpassport'); + if (zkPassportProvider) { + return zkPassportProvider.claims; + } + } + + // 2. Check LocalDatabase persistence (warm start) + const persisted = localDatabase.cache.userIdentities[address]; + if (persisted?.identityProviders) { + const zkPassportProvider = persisted.identityProviders.find(p => p.type === 'zkpassport'); + if (zkPassportProvider) { + // Restore in memory cache + this.userIdentityCache[address] = { + ...persisted, + identityProviders: persisted.identityProviders + }; + return zkPassportProvider.claims; + } + } + + // 3. Check Waku message cache (network cache) + const cacheServiceData = messageManager.messageCache.userIdentities[address]; + if (cacheServiceData?.identityProviders) { + const zkPassportProvider = cacheServiceData.identityProviders.find(p => p.type === 'zkpassport'); + if (zkPassportProvider) { + // Store in internal cache for future use + this.userIdentityCache[address] = { + ...cacheServiceData, + identityProviders: cacheServiceData.identityProviders + }; + return zkPassportProvider.claims; + } + } + + // 4. Fetch from blockchain (source of truth) + return this.resolveZKPassportClaims(address); + } + + /** + * Force fresh resolution of ZKPassport claims (bypass caches) + */ + async getZKPassportClaimsFresh(address: string): Promise { + return this.resolveZKPassportClaims(address); + } + + /** + * Resolve ZKPassport claims from blockchain contract with TTL caching + */ + private async resolveZKPassportClaims(address: string): Promise { + try { + // Check if we have a recent cached version + const cached = this.userIdentityCache[address]?.identityProviders?.find(p => p.type === 'zkpassport'); + const now = Date.now(); + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes TTL + + // If we have a recent cache and it's still valid, return it + if (cached && cached.verifiedAt && (now - cached.verifiedAt) < CACHE_TTL) { + return cached.claims; + } + + const claimsData = await getVerification(address); + if (!claimsData) return null; + + const claims: Claim[] = []; + + // Process adult claim + if (claimsData.adult !== undefined) { + claims.push({ + key: 'adult', + value: claimsData.adult, + verified: true + }); + } + + // Process country claim + if (claimsData.country) { + claims.push({ + key: 'country', + value: claimsData.country, + verified: true + }); + } + + // Process gender claim + if (claimsData.gender) { + claims.push({ + key: 'gender', + value: claimsData.gender, + verified: true + }); + } + + // Update identity with claims (this updates all cache layers) + this.updateUserIdentityWithZKPassportClaims(address, claims); + + return claims; + } catch (error) { + console.error('Failed to resolve ZKPassport claims:', error); + return null; + } + } + + /** + * Update user identity with ZKPassport claims (updates all cache layers) + */ + updateUserIdentityWithZKPassportClaims(address: string, claims: Claim[]): void { + const timestamp = Date.now(); + + // Initialize identity if it doesn't exist + if (!this.userIdentityCache[address]) { + this.userIdentityCache[address] = { + ensName: undefined, + ordinalDetails: undefined, + callSign: undefined, + displayPreference: EDisplayPreference.WALLET_ADDRESS, + lastUpdated: timestamp, + verificationStatus: EVerificationStatus.WALLET_CONNECTED, + identityProviders: [] + }; + } + + // Create or update ZKPassport provider with TTL + const zkPassportProvider: IdentityProvider = { + type: 'zkpassport', + verifiedAt: timestamp, + expiresAt: timestamp + 24 * 60 * 60 * 1000, // 24 hours validity + claims: [...claims] + }; + + // Replace or add provider + const existingProviderIndex = this.userIdentityCache[address].identityProviders!.findIndex( + p => p.type === 'zkpassport' + ); + + if (existingProviderIndex >= 0) { + this.userIdentityCache[address].identityProviders![existingProviderIndex] = zkPassportProvider; + } else { + this.userIdentityCache[address].identityProviders!.push(zkPassportProvider); + } + + // Update last updated timestamp + this.userIdentityCache[address].lastUpdated = timestamp; + + // Update verification status if user has verified claims + if (claims.some(c => c.verified)) { + this.userIdentityCache[address].verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED; + } + + // Notify listeners that the user identity has been updated + this.notifyRefreshListeners(address); + + // Persist to IndexedDB + localDatabase.upsertUserIdentity(address, { + identityProviders: this.userIdentityCache[address].identityProviders, + lastUpdated: timestamp + }); + } + + /** + * Batch resolve multiple user identities for post processing + */ + async resolveMultipleUsers(addresses: string[]): Promise> { + const result = new Map(); + + // Process all resolutions in parallel + await Promise.all( + addresses.map(async (address) => { + const identity = await this.getUserIdentity(address); + if (identity) { + result.set(address, identity); + } + }) + ); + + return result; + } } diff --git a/src/lib/zkPassport.ts b/src/lib/zkPassport.ts index 44ab718..1c8f4b7 100644 --- a/src/lib/zkPassport.ts +++ b/src/lib/zkPassport.ts @@ -1,7 +1,7 @@ import { BrowserProvider, Contract } from 'ethers'; import { config } from '@/lib/wallet/config'; // Contract configuration - these should be moved to environment variables in production -export const CONTRACT_ADDRESS = "0xaA649E71A6d7347742e3642AAe209d580913f021"; // Hardhat default deploy address +export const CONTRACT_ADDRESS = "0x1753dbd9f4bb6473ee2905b2db183760B95be475"; // Hardhat default deploy address const CONTRACT_ABI = [ { "inputs": [ @@ -57,17 +57,22 @@ const CONTRACT_ABI = [ "outputs": [ { "internalType": "bool", - "name": "", + "name": "initialized", + "type": "bool" + }, + { + "internalType": "bool", + "name": "adult", "type": "bool" }, { "internalType": "string", - "name": "", + "name": "country", "type": "string" }, { "internalType": "string", - "name": "", + "name": "gender", "type": "string" } ], @@ -151,21 +156,77 @@ const CONTRACT_ABI = [ }, { "inputs": [ - { - "internalType": "bytes32", - "name": "", - "type": "bytes32" - } - ], - "name": "usedUniqueIdentifiers", - "outputs": [ { "internalType": "bool", - "name": "", + "name": "adult", "type": "bool" + }, + { + "internalType": "string", + "name": "country", + "type": "string" + }, + { + "internalType": "string", + "name": "gender", + "type": "string" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "vkeyHash", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "proof", + "type": "bytes" + }, + { + "internalType": "bytes32[]", + "name": "publicInputs", + "type": "bytes32[]" + }, + { + "internalType": "bytes", + "name": "committedInputs", + "type": "bytes" + }, + { + "internalType": "uint256[]", + "name": "committedInputCounts", + "type": "uint256[]" + }, + { + "internalType": "uint256", + "name": "validityPeriodInSeconds", + "type": "uint256" + }, + { + "internalType": "string", + "name": "domain", + "type": "string" + }, + { + "internalType": "string", + "name": "scope", + "type": "string" + }, + { + "internalType": "bool", + "name": "devMode", + "type": "bool" + } + ], + "internalType": "struct ProofVerificationParams", + "name": "params", + "type": "tuple" } ], - "stateMutability": "view", + "name": "updateVerification", + "outputs": [], + "stateMutability": "nonpayable", "type": "function" }, { @@ -197,6 +258,30 @@ const CONTRACT_ABI = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "walletUniqueIdentifiers", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "zkVerifier", @@ -449,6 +534,7 @@ export const submitVerificationToContract = async ( try { const signer = await getSigner(); + console.log(signer) if (!signer) { setProgress('Failed to connect to wallet'); return null; @@ -496,13 +582,130 @@ export const getVerification = async (address: string): Promise<{ adult: boolean try { const provider = new BrowserProvider(window.ethereum as any); const contract = new Contract(CONTRACT_ADDRESS, CONTRACT_ABI, provider) as unknown as { - getVerification: (address: string) => Promise<[boolean, string, string]>; + getVerification: (address: string) => Promise<[boolean, boolean, string, string]>; }; - const [adult, country, gender] = await contract.getVerification(address); + const [initialized, adult, country, gender] = await contract.getVerification(address); + if (!initialized) { + return null; // No verification data set for this user + } return { adult, country, gender }; } catch (error) { console.error('Error fetching verification data:', error); return null; } +}; + +/** + * Update verification data for a user without requiring a new proof + * @param adult Whether the user is 18+ + * @param country The user's country of nationality + * @param gender The user's gender + * @param setProgress Function to update progress status + * @returns Promise resolving to transaction hash on success, null on failure + */ +export const updateVerification = async ( + adult: boolean, + country: string, + gender: string, + proof: ProofResult, + setProgress: (status: string) => void +): Promise => { + setProgress('Initializing blockchain connection...'); + const zkPassport = new ZKPassport(); + + // Get verification parameters + const verifierParams = zkPassport.getSolidityVerifierParameters({ + proof: proof, + // Use the same scope as the one you specified with the request function + scope: "identity", + // Enable dev mode if you want to use mock passports, otherwise keep it false + devMode: true, + }); + + + try { + const signer = await getSigner(); + if (!signer) { + setProgress('Failed to connect to wallet'); + return null; + } + + setProgress('Connecting to contract...'); + if (!signer) { + setProgress('Failed to get signer'); + return null; + } + const contract = new Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer) as unknown as { + updateVerification: (adult: boolean, country: string, gender: string, verifierParams: SolidityVerifierParameters) => Promise; + }; + + setProgress('Updating verification data on blockchain...'); + const tx = await contract.updateVerification(adult, country, gender, verifierParams); + + setProgress('Waiting for blockchain confirmation...'); + const receipt = await tx.wait(); + + if (receipt && receipt.hash) { + setProgress('Verification successfully updated on blockchain!'); + return receipt.hash; + } else { + setProgress('Transaction completed but no hash received'); + return null; + } + } catch (error: any) { + console.error('Error updating verification:', error); + if (error.message) { + setProgress(`Error: ${error.message}`); + } else { + setProgress('Failed to update verification on contract'); + } + return null; + } +}; + +/** + * Fetch ZKPassport claims for a user with proper typing + * @param address The wallet address of the user to fetch claims for + * @returns Promise resolving to claims array or null if not found + */ +export const fetchZKPassportClaims = async (address: string): Promise => { + try { + const claimsData = await getVerification(address); + if (!claimsData) return null; + + const claims: Claim[] = []; + + // Process adult claim + if (claimsData.adult !== undefined) { + claims.push({ + key: 'adult', + value: claimsData.adult, + verified: true + }); + } + + // Process country claim + if (claimsData.country) { + claims.push({ + key: 'country', + value: claimsData.country, + verified: true + }); + } + + // Process gender claim + if (claimsData.gender) { + claims.push({ + key: 'gender', + value: claimsData.gender, + verified: true + }); + } + + return claims.length > 0 ? claims : null; + } catch (error) { + console.error('Error fetching ZKPassport claims:', error); + return null; + } }; \ No newline at end of file diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index bdd01c8..485004d 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ContractVerificationButton } from '@/components/ui/contract-verification-button'; -import { CONTRACT_ADDRESS, getVerification, submitVerificationToContract } from '@/lib/zkPassport'; +import { CONTRACT_ADDRESS, getVerification, submitVerificationToContract, updateVerification } from '@/lib/zkPassport'; import { verifyWithZKPassport, ZKPassportVerificationOptions } from '@/lib/zkPassport'; import { UserIdentityService } from '@/lib/services/UserIdentityService'; import { useForum } from '@/contexts/useForum'; @@ -795,22 +795,43 @@ export default function ProfilePage() { const countryClaim = userInfo.identityProviders?.flatMap(p => p.claims).find(c => c.key === 'country'); const genderClaim = userInfo.identityProviders?.flatMap(p => p.claims).find(c => c.key === 'gender'); - - const tx = await submitVerificationToContract( + // Check if verification already exists + const existingVerification = await getVerification(address!); + const hasExistingVerification = existingVerification && ( + existingVerification.adult !== undefined || + existingVerification.country !== '' || + existingVerification.gender !== '' + ); + console.log('Existing verification:', existingVerification, hasExistingVerification); + + let tx; + if (hasExistingVerification) { + // Use updateVerification for existing verifications + tx = await updateVerification( adulthoodClaim?.value as boolean || false, countryClaim?.value as string || '', genderClaim?.value as string || '', proof, setProgress ); - - if (tx) { - toast({ - title: 'Verification Submitted', - description: 'Your verification has been submitted to the contract.', - }); - } - return tx; + } else { + // Use submitVerificationToContract for new verifications + tx = await submitVerificationToContract( + adulthoodClaim?.value as boolean || false, + countryClaim?.value as string || '', + genderClaim?.value as string || '', + proof, + setProgress + ); + } + + if (tx) { + toast({ + title: 'Verification Submitted', + description: 'Your verification has been submitted to the contract.', + }); + } + return tx; }} isVerifying={isVerifying} verificationType="adult"