fix contract, add emojis to user display

This commit is contained in:
Václav Pavlín 2025-09-19 21:57:51 +02:00
parent 7a9e8ccd78
commit 539e118e30
No known key found for this signature in database
GPG Key ID: B378FB31BB6D89A5
6 changed files with 543 additions and 37 deletions

View File

@ -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);
}
}

View File

@ -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 (
<div className={`flex items-center gap-1.5 ${className}`}>
<span className="text-xs text-muted-foreground">{displayName}</span>
<span className="text-xs text-muted-foreground">
{displayName}
{countryFlag && <span className="ml-1">{countryFlag}</span>}
{ageEmoji && <span className="ml-1">{ageEmoji}</span>}
{genderEmoji && <span className="ml-1">{genderEmoji}</span>}
</span>
{shouldShowBadge && (
<Badge

View File

@ -12,6 +12,9 @@ export interface UserDisplayInfo {
isLoading: boolean;
error: string | null;
identityProviders: IdentityProvider[] | null;
countryFlag: string | null;
ageEmoji: string | null;
genderEmoji: string | null;
}
/**
@ -29,6 +32,9 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
isLoading: true,
error: null,
identityProviders: null,
countryFlag: null,
ageEmoji: null,
genderEmoji: null,
});
const [refreshTrigger, setRefreshTrigger] = useState(0);
@ -85,6 +91,10 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
displayPreference: null,
isLoading: false,
error: null,
identityProviders: null,
countryFlag: null,
ageEmoji: null,
genderEmoji: null,
});
return;
}
@ -95,6 +105,32 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
if (identity) {
const displayName = userIdentityService.getDisplayName(address);
// Extract country, adult, and gender claims from identity providers
let countryFlag = null;
let ageEmoji = null;
let genderEmoji = null;
if (identity.identityProviders) {
for (const provider of identity.identityProviders) {
if (provider.type === 'zkpassport') {
for (const claim of provider.claims) {
if (claim.key === 'country' && claim.verified && typeof claim.value === 'string') {
// Convert country code to flag emoji
countryFlag = getCountryFlag(claim.value);
}
if (claim.key === 'adult' && claim.verified && typeof claim.value === 'boolean') {
ageEmoji = claim.value ? '🧓' : '👶';
}
if (claim.key === 'gender' && claim.verified && typeof claim.value === 'string') {
// Map gender to emoji
genderEmoji = claim.value.toLowerCase() === 'm' ? '♂️' :
claim.value.toLowerCase() === 'f' ? '♀️' : '⚧️';
}
}
}
}
}
setDisplayInfo({
displayName,
callSign: identity.callSign || null,
@ -107,6 +143,9 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
isLoading: false,
error: null,
identityProviders: identity.identityProviders || null,
countryFlag,
ageEmoji,
genderEmoji,
});
} else {
setDisplayInfo({
@ -121,6 +160,9 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
isLoading: false,
error: null,
identityProviders: null,
countryFlag: null,
ageEmoji: null,
genderEmoji: null,
});
}
} catch (error) {
@ -137,6 +179,10 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
displayPreference: null,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
identityProviders: null,
countryFlag: null,
ageEmoji: null,
genderEmoji: null,
});
}
};
@ -161,6 +207,17 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
verificationInfo,
]);
// Helper function to convert country code to flag emoji
function getCountryFlag(countryCode: string): string {
// Convert country code to flag emoji using regional indicator symbols
// For example, 'US' -> '🇺🇸'
return countryCode
.toUpperCase()
.replace(/./g, char =>
String.fromCodePoint(127397 + char.charCodeAt(0))
);
}
return displayInfo;
}

View File

@ -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<Claim[] | null> {
// 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<Claim[] | null> {
return this.resolveZKPassportClaims(address);
}
/**
* Resolve ZKPassport claims from blockchain contract with TTL caching
*/
private async resolveZKPassportClaims(address: string): Promise<Claim[] | null> {
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<Map<string, UserIdentity>> {
const result = new Map<string, UserIdentity>();
// 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;
}
}

View File

@ -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<string | null> => {
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<any>;
};
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<Claim[] | null> => {
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;
}
};

View File

@ -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"