feat: view membership details

This commit is contained in:
Danish Arora 2025-04-08 02:31:15 +05:30
parent 692021ea0c
commit eadc45f57e
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
7 changed files with 917 additions and 287 deletions

723
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@waku/rln": "0.1.5-9901863.0",
"@waku/rln": "0.1.5-731214b.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.6.3",

View File

@ -0,0 +1,68 @@
import React from 'react';
import { Button } from './ui/button';
import { Copy } from 'lucide-react';
import { DecryptedCredentials } from '@waku/rln';
interface CredentialDetailsProps {
decryptedInfo: DecryptedCredentials;
copyToClipboard: (text: string) => void;
}
export function CredentialDetails({ decryptedInfo, copyToClipboard }: CredentialDetailsProps) {
return (
<div className="mt-3 space-y-2 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<div className="flex items-center mb-2">
<span className="text-primary font-mono font-medium mr-2">{">"}</span>
<h3 className="text-sm font-mono font-semibold text-primary">
Credential Details
</h3>
</div>
<div className="space-y-2 text-xs font-mono">
<div className="grid grid-cols-1 gap-2">
<div className="flex flex-col">
<span className="text-muted-foreground">ID Commitment:</span>
<div className="flex items-center mt-1">
<span className="break-all text-accent truncate">{decryptedInfo.identity.IDCommitment}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => copyToClipboard(decryptedInfo.identity.IDCommitment.toString())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground">ID Commitment BigInt:</span>
<div className="flex items-center mt-1">
<span className="break-all text-accent truncate">{decryptedInfo.identity.IDCommitmentBigInt}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => copyToClipboard(decryptedInfo.identity.IDCommitmentBigInt.toString())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex flex-col border-t border-terminal-border/20 pt-2">
<span className="text-muted-foreground">ID Nullifier:</span>
<div className="flex items-center mt-1">
<span className="break-all text-accent truncate">{decryptedInfo.identity.IDNullifier}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => copyToClipboard(decryptedInfo.identity.IDNullifier.toString())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,156 @@
import React from 'react';
import { Button } from './ui/button';
import { Copy } from 'lucide-react';
import { ethers } from 'ethers';
import { MembershipState } from '@waku/rln';
interface MembershipDetailsProps {
membershipInfo: {
address: string;
chainId: string;
treeIndex: number;
rateLimit: number;
idCommitment: string;
startBlock: number;
endBlock: number;
state: MembershipState;
depositAmount: ethers.BigNumber;
activeDuration: number;
gracePeriodDuration: number;
holder: string;
token: string;
};
copyToClipboard: (text: string) => void;
}
export function MembershipDetails({ membershipInfo, copyToClipboard }: MembershipDetailsProps) {
return (
<div className="mt-3 space-y-2 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<div className="flex items-center mb-2">
<span className="text-primary font-mono font-medium mr-2">{">"}</span>
<h3 className="text-sm font-mono font-semibold text-primary">
Membership Details
</h3>
</div>
<div className="space-y-2 text-xs font-mono">
<div className="grid grid-cols-2 gap-4">
{/* Membership State */}
<div>
<span className="text-muted-foreground text-xs">State:</span>
<div className="text-accent">{membershipInfo.state || 'N/A'}</div>
</div>
{/* Basic Info */}
<div>
<span className="text-muted-foreground text-xs">Chain ID:</span>
<div className="text-accent">{membershipInfo.chainId}</div>
</div>
<div>
<span className="text-muted-foreground text-xs">Rate Limit:</span>
<div className="text-accent">{membershipInfo.rateLimit} msg/epoch</div>
</div>
{/* Contract Info */}
<div>
<span className="text-muted-foreground text-xs">Contract Address:</span>
<div className="text-accent truncate hover:text-clip flex items-center">
{membershipInfo.address}
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => membershipInfo.address && copyToClipboard(membershipInfo.address)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
{/* Member Details */}
<div>
<span className="text-muted-foreground text-xs">Member Index:</span>
<div className="text-accent">{membershipInfo.treeIndex || 'N/A'}</div>
</div>
<div>
<span className="text-muted-foreground text-xs">ID Commitment:</span>
<div className="text-accent truncate hover:text-clip flex items-center">
{membershipInfo.idCommitment || 'N/A'}
{membershipInfo.idCommitment && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => membershipInfo.idCommitment && copyToClipboard(membershipInfo.idCommitment)}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* Block Information */}
<div>
<span className="text-muted-foreground text-xs">Start Block:</span>
<div className="text-accent">{membershipInfo.startBlock || 'N/A'}</div>
</div>
<div>
<span className="text-muted-foreground text-xs">End Block:</span>
<div className="text-accent">{membershipInfo.endBlock || 'N/A'}</div>
</div>
{/* Duration Information */}
<div>
<span className="text-muted-foreground text-xs">Active Duration:</span>
<div className="text-accent">{membershipInfo.activeDuration ? `${membershipInfo.activeDuration} blocks` : 'N/A'}</div>
</div>
<div>
<span className="text-muted-foreground text-xs">Grace Period:</span>
<div className="text-accent">{membershipInfo.gracePeriodDuration ? `${membershipInfo.gracePeriodDuration} blocks` : 'N/A'}</div>
</div>
{/* Token Information */}
<div>
<span className="text-muted-foreground text-xs">Token Address:</span>
<div className="text-accent truncate hover:text-clip flex items-center">
{membershipInfo.token || 'N/A'}
{membershipInfo.token && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => membershipInfo.token && copyToClipboard(membershipInfo.token)}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</div>
<div>
<span className="text-muted-foreground text-xs">Deposit Amount:</span>
<div className="text-accent">
{membershipInfo.depositAmount ? `${ethers.utils.formatEther(membershipInfo.depositAmount)} ETH` : 'N/A'}
</div>
</div>
{/* Holder Information */}
<div className="col-span-2">
<span className="text-muted-foreground text-xs">Holder Address:</span>
<div className="text-accent truncate hover:text-clip flex items-center">
{membershipInfo.holder || 'N/A'}
{membershipInfo.holder && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => membershipInfo.holder && copyToClipboard(membershipInfo.holder)}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,15 +1,36 @@
"use client";
import React, { useState } from 'react';
import { useKeystore } from '../../../contexts/keystore';
import { useKeystore } from '../../../contexts/keystore/KeystoreContext';
import { useRLN } from '../../../contexts/rln/RLNContext';
import { readKeystoreFromFile, saveKeystoreCredentialToFile } from '../../../utils/keystore';
import { DecryptedCredentials } from '@waku/rln';
import { DecryptedCredentials, MembershipInfo, MembershipState } from '@waku/rln';
import { useAppState } from '../../../contexts/AppStateContext';
import { TerminalWindow } from '../../ui/terminal-window';
import { Button } from '../../ui/button';
import { Copy, Eye, Download, Trash2, ArrowDownToLine } from 'lucide-react';
import { KeystoreExporter } from '../../KeystoreExporter';
import { keystoreManagement, type ContentSegment } from '../../../content/index';
import { ethers } from 'ethers';
import { toast } from 'sonner';
import { CredentialDetails } from '@/components/CredentialDetails';
import { MembershipDetails } from '@/components/MembershipDetails';
interface ExtendedMembershipInfo extends Omit<MembershipInfo, 'state'> {
address: string;
chainId: string;
treeIndex: number;
rateLimit: number;
idCommitment: string;
startBlock: number;
endBlock: number;
state: MembershipState;
depositAmount: ethers.BigNumber;
activeDuration: number;
gracePeriodDuration: number;
holder: string;
token: string;
}
export function KeystoreManagement() {
const {
@ -21,6 +42,11 @@ export function KeystoreManagement() {
removeCredential,
getDecryptedCredential
} = useKeystore();
const {
getMembershipInfo
} = useRLN();
const { setGlobalError } = useAppState();
const [exportPassword, setExportPassword] = useState<string>('');
const [selectedCredential, setSelectedCredential] = useState<string | null>(null);
@ -29,6 +55,7 @@ export function KeystoreManagement() {
const [decryptedInfo, setDecryptedInfo] = useState<DecryptedCredentials | null>(null);
const [isDecrypting, setIsDecrypting] = useState(false);
const [copiedHash, setCopiedHash] = useState<string | null>(null);
const [membershipInfo, setMembershipInfo] = useState<ExtendedMembershipInfo | null>(null);
React.useEffect(() => {
if (error) {
@ -73,11 +100,12 @@ export function KeystoreManagement() {
const handleViewCredential = async (hash: string) => {
if (!viewPassword) {
setGlobalError('Please enter your keystore password to view credential');
toast.error('Please enter your keystore password to view credential');
return;
}
setIsDecrypting(true);
setMembershipInfo(null);
try {
const credential = await getDecryptedCredential(hash, viewPassword);
@ -85,12 +113,14 @@ export function KeystoreManagement() {
if (credential) {
setDecryptedInfo(credential);
const info = await getMembershipInfo(hash, viewPassword);
setMembershipInfo(info as ExtendedMembershipInfo);
} else {
setGlobalError('Could not decrypt credential. Please check your password and try again.');
toast.error('Could not decrypt credential. Please check your password and try again.');
}
} catch (err) {
setIsDecrypting(false);
setGlobalError(err instanceof Error ? err.message : 'Failed to decrypt credential');
toast.error(err instanceof Error ? err.message : 'Failed to decrypt credential');
}
};
@ -293,66 +323,28 @@ export function KeystoreManagement() {
{/* Decrypted Information Display */}
{decryptedInfo && (
<div className="mt-3 space-y-2 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<div className="flex items-center mb-2">
<span className="text-primary font-mono font-medium mr-2">{">"}</span>
<h3 className="text-sm font-mono font-semibold text-primary">
Credential Details
</h3>
</div>
<div className="space-y-2 text-xs font-mono">
<div className="grid grid-cols-1 gap-2">
<div className="flex flex-col">
<span className="text-muted-foreground">ID Commitment:</span>
<div className="flex items-center mt-1">
<span className="break-all text-accent truncate">{decryptedInfo.identity.IDCommitment}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => copyToClipboard(decryptedInfo.identity.IDCommitment.toString())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex flex-col border-t border-terminal-border/20 pt-2">
<span className="text-muted-foreground">ID Nullifier:</span>
<div className="flex items-center mt-1">
<span className="break-all text-accent truncate">{decryptedInfo.identity.IDNullifier}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => copyToClipboard(decryptedInfo.identity.IDNullifier.toString())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex flex-col border-t border-terminal-border/20 pt-2">
<span className="text-muted-foreground">Membership Details:</span>
<div className="grid grid-cols-2 gap-4 mt-2">
<div>
<span className="text-muted-foreground text-xs">Chain ID:</span>
<div className="text-accent">{decryptedInfo.membership.chainId}</div>
</div>
<div>
<span className="text-muted-foreground text-xs">Rate Limit:</span>
<div className="text-accent">{decryptedInfo.membership.rateLimit}</div>
</div>
</div>
</div>
<div className="space-y-4">
<CredentialDetails
decryptedInfo={decryptedInfo}
copyToClipboard={copyToClipboard}
/>
{membershipInfo && (
<div className="flex flex-col border-t border-terminal-border/20 pt-2">
<MembershipDetails
membershipInfo={membershipInfo}
copyToClipboard={copyToClipboard}
/>
</div>
<Button
onClick={() => setDecryptedInfo(null)}
variant="ghost"
size="sm"
className="mt-2 text-xs text-muted-foreground hover:text-accent"
>
Hide Details
</Button>
</div>
)}
<Button
onClick={() => setDecryptedInfo(null)}
variant="ghost"
size="sm"
className="mt-2 text-xs text-muted-foreground hover:text-accent"
>
Hide Details
</Button>
</div>
)}
</div>

View File

@ -11,6 +11,8 @@ interface KeystoreContextType {
error: string | null;
hasStoredCredentials: boolean;
storedCredentialsHashes: string[];
decryptedCredentials: KeystoreEntity | null;
hideCredentials: () => void;
saveCredentials: (credentials: KeystoreEntity, password: string) => Promise<string>;
exportCredential: (hash: string, password: string) => Promise<Keystore>;
exportEntireKeystore: (password: string) => Promise<void>;
@ -26,6 +28,7 @@ export function KeystoreProvider({ children }: { children: ReactNode }) {
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<string | null>(null);
const [storedCredentialsHashes, setStoredCredentialsHashes] = useState<string[]>([]);
const [decryptedCredentials, setDecryptedCredentials] = useState<KeystoreEntity | null>(null);
// Initialize keystore
useEffect(() => {
@ -91,6 +94,9 @@ export function KeystoreProvider({ children }: { children: ReactNode }) {
try {
// Get the credential from the keystore
const credential = await keystore.readCredential(hash, password);
if (credential) {
setDecryptedCredentials(credential);
}
return credential || null;
} catch (err) {
console.error("Error reading credential:", err);

View File

@ -1,7 +1,7 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { KeystoreEntity, RLNCredentialsManager } from '@waku/rln';
import { KeystoreEntity, MembershipInfo, RLNCredentialsManager } from '@waku/rln';
import { createRLNImplementation } from './implementations';
import { useRLNImplementation } from './RLNImplementationContext';
import { ethers } from 'ethers';
@ -20,6 +20,15 @@ interface RLNContextType {
credentials?: KeystoreEntity;
keystoreHash?: string;
}>;
extendMembership: (hash: string, password: string) => Promise<{ success: boolean; error?: string }>;
eraseMembership: (hash: string, password: string) => Promise<{ success: boolean; error?: string }>;
withdrawDeposit: (hash: string, password: string) => Promise<{ success: boolean; error?: string }>;
getMembershipInfo: (hash: string, password: string) => Promise<MembershipInfo & {
address: string;
chainId: string;
treeIndex: number;
rateLimit: number;
}>;
rateMinLimit: number;
rateMaxLimit: number;
getCurrentRateLimit: () => Promise<number | null>;
@ -44,7 +53,7 @@ export function RLNProvider({ children }: { children: ReactNode }) {
const [rateMinLimit, setRateMinLimit] = useState<number>(0);
const [rateMaxLimit, setRateMaxLimit] = useState<number>(0);
const { saveCredentials: saveToKeystore } = useKeystore();
const { saveCredentials: saveToKeystore, getDecryptedCredential } = useKeystore();
// Listen for wallet connection
useEffect(() => {
@ -348,6 +357,118 @@ export function RLNProvider({ children }: { children: ReactNode }) {
}
};
const getMembershipInfo = async (hash: string, password: string) => {
if (!rln || !rln.contract) {
throw new Error('RLN not initialized or contract not available');
}
const credential = await getDecryptedCredential(hash, password);
if (!credential) {
throw new Error('Could not decrypt credential');
}
try {
const membershipInfo = await rln.contract.getMembershipInfo(credential.identity.IDCommitmentBigInt);
if (!membershipInfo) {
throw new Error('Could not fetch membership info');
}
return {
...membershipInfo,
address: rln.contract.address,
chainId: LINEA_SEPOLIA_CONFIG.chainId.toString(),
treeIndex: Number(membershipInfo.index.toString()),
rateLimit: Number(membershipInfo.rateLimit.toString())
}
} catch (error) {
console.log("error", error);
throw error;
}
};
const extendMembership = async (hash: string, password: string) => {
try {
if (!rln || !rln.contract) {
throw new Error('RLN not initialized or contract not available');
}
const credential = await getDecryptedCredential(hash, password);
if (!credential) {
throw new Error('Could not decrypt credential');
}
// Convert IDCommitment to hex string
const idCommitmentHex = ethers.utils.hexlify(credential.identity.IDCommitment);
await rln.contract.extendMembership(idCommitmentHex);
return { success: true };
} catch (err) {
console.error('Error extending membership:', err);
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to extend membership'
};
}
};
const eraseMembership = async (hash: string, password: string) => {
try {
if (!rln || !rln.contract) {
throw new Error('RLN not initialized or contract not available');
}
const credential = await getDecryptedCredential(hash, password);
if (!credential) {
throw new Error('Could not decrypt credential');
}
// Convert IDCommitment to hex string
const idCommitmentHex = ethers.utils.hexlify(credential.identity.IDCommitment);
await rln.contract.eraseMembership(idCommitmentHex);
return { success: true };
} catch (err) {
console.error('Error erasing membership:', err);
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to erase membership'
};
}
};
const withdrawDeposit = async (hash: string, password: string) => {
try {
if (!rln || !rln.contract) {
throw new Error('RLN not initialized or contract not available');
}
const credential = await getDecryptedCredential(hash, password);
if (!credential) {
throw new Error('Could not decrypt credential');
}
// Get token address from config
const tokenAddress = LINEA_SEPOLIA_CONFIG.tokenAddress;
const userAddress = await signer?.getAddress();
if (!userAddress) {
throw new Error('No signer available');
}
// Call withdraw with token address and holder
await rln.contract.withdraw(tokenAddress, userAddress);
return { success: true };
} catch (err) {
console.error('Error withdrawing deposit:', err);
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to withdraw deposit'
};
}
};
return (
<RLNContext.Provider
value={{
@ -357,6 +478,10 @@ export function RLNProvider({ children }: { children: ReactNode }) {
error,
initializeRLN,
registerMembership,
extendMembership,
eraseMembership,
withdrawDeposit,
getMembershipInfo,
rateMinLimit,
rateMaxLimit,
getCurrentRateLimit,