diff --git a/examples/keystore-management/src/app/layout.tsx b/examples/keystore-management/src/app/layout.tsx index 7c6a855..b9a21d5 100644 --- a/examples/keystore-management/src/app/layout.tsx +++ b/examples/keystore-management/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { WalletProvider } from "../contexts/WalletContext"; import { RLNUnifiedProvider } from "../contexts/RLNUnifiedContext2"; import { RLNImplementationProvider } from "../contexts/RLNImplementationContext"; +import { KeystoreProvider } from "../contexts/KeystoreContext"; import { Header } from "../components/Header"; const geistSans = Geist({ @@ -33,14 +34,16 @@ export default function RootLayout({ > - -
-
-
- {children} -
-
-
+ + +
+
+
+ {children} +
+
+
+
diff --git a/examples/keystore-management/src/app/page.tsx b/examples/keystore-management/src/app/page.tsx index c6eb0fa..d4b743f 100644 --- a/examples/keystore-management/src/app/page.tsx +++ b/examples/keystore-management/src/app/page.tsx @@ -1,6 +1,7 @@ import RLNMembershipRegistration from '../components/RLNMembershipRegistration'; import { WalletInfo } from '../components/WalletInfo'; import { RLNImplementationToggle } from '../components/RLNImplementationToggle'; +import KeystoreManager from '../components/KeystoreManager'; export default function Home() { return ( @@ -31,6 +32,16 @@ export default function Home() {

+ + {/* Keystore Management Section */} +
+

Keystore Management

+

+ Export your keystore credentials to use them with your Waku node or import existing credentials. + Keep your keystores safe as they contain your membership information. +

+ +
diff --git a/examples/keystore-management/src/components/KeystoreManager.tsx b/examples/keystore-management/src/components/KeystoreManager.tsx new file mode 100644 index 0000000..2ef1bc8 --- /dev/null +++ b/examples/keystore-management/src/components/KeystoreManager.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from 'react'; +import { useKeystore } from '../contexts/KeystoreContext'; +import { saveKeystoreToFile, readKeystoreFromFile } from '../utils/fileUtils'; + +export default function KeystoreManager() { + const { + isInitialized: isKeystoreInitialized, + hasStoredCredentials, + storedCredentialsHashes, + exportKeystore, + importKeystore + } = useKeystore(); + + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const handleExport = () => { + try { + const keystoreJson = exportKeystore(); + saveKeystoreToFile(keystoreJson); + setSuccessMessage('Keystore exported successfully'); + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to export keystore'); + setTimeout(() => setError(null), 3000); + } + }; + + const handleImport = async () => { + try { + const keystoreJson = await readKeystoreFromFile(); + const success = importKeystore(keystoreJson); + + if (success) { + setSuccessMessage('Keystore imported successfully'); + } else { + setError('Failed to import keystore'); + } + + setTimeout(() => { + setSuccessMessage(null); + setError(null); + }, 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import keystore'); + setTimeout(() => setError(null), 3000); + } + }; + + if (!isKeystoreInitialized) { + return ( +
+

Initializing keystore...

+
+ ); + } + + return ( +
+

Keystore Management

+ + {/* Status */} +
+

+ Status: {hasStoredCredentials ? 'Credentials found' : 'No credentials stored'} +

+ {hasStoredCredentials && ( +

+ Stored credentials: {storedCredentialsHashes.length} +

+ )} +
+ + {/* Notifications */} + {error && ( +
+

{error}

+
+ )} + + {successMessage && ( +
+

{successMessage}

+
+ )} + + {/* Import/Export Buttons */} +
+ {/* Export Keystore */} +
+ + {!hasStoredCredentials && ( +

+ No credentials to export +

+ )} +
+ + {/* Import Keystore */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/examples/keystore-management/src/components/RLNMembershipRegistration.tsx b/examples/keystore-management/src/components/RLNMembershipRegistration.tsx index 5aa86c6..57d2a15 100644 --- a/examples/keystore-management/src/components/RLNMembershipRegistration.tsx +++ b/examples/keystore-management/src/components/RLNMembershipRegistration.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useRLN } from '../contexts/RLNUnifiedContext2'; import { useWallet } from '../contexts/WalletContext'; -import { DecryptedCredentials } from '@waku/rln'; +import { KeystoreEntity } from '@waku/rln'; export default function RLNMembershipRegistration() { const { registerMembership, isInitialized, isStarted, rateMinLimit, rateMaxLimit, error, initializeRLN } = useRLN(); @@ -12,12 +12,15 @@ export default function RLNMembershipRegistration() { const [rateLimit, setRateLimit] = useState(rateMinLimit); const [isRegistering, setIsRegistering] = useState(false); const [isInitializing, setIsInitializing] = useState(false); + const [saveToKeystore, setSaveToKeystore] = useState(true); + const [keystorePassword, setKeystorePassword] = useState(''); const [registrationResult, setRegistrationResult] = useState<{ success?: boolean; error?: string; txHash?: string; warning?: string; - credentials?: DecryptedCredentials; + credentials?: KeystoreEntity; + keystoreHash?: string; }>({}); const isLineaSepolia = chainId === 59141; @@ -56,6 +59,15 @@ export default function RLNMembershipRegistration() { return; } + // Validate keystore password if saving to keystore + if (saveToKeystore && keystorePassword.length < 8) { + setRegistrationResult({ + success: false, + error: 'Keystore password must be at least 8 characters long' + }); + return; + } + setIsRegistering(true); setRegistrationResult({}); @@ -65,11 +77,20 @@ export default function RLNMembershipRegistration() { warning: 'Please check your wallet to sign the registration message.' }); - const result = await registerMembership(rateLimit); + // Pass save options if saving to keystore + const saveOptions = saveToKeystore ? { password: keystorePassword } : undefined; + + const result = await registerMembership(rateLimit, saveOptions); + setRegistrationResult({ ...result, credentials: result.credentials }); + + // Clear password field after successful registration + if (result.success) { + setKeystorePassword(''); + } } catch (error) { setRegistrationResult({ success: false, @@ -189,6 +210,50 @@ export default function RLNMembershipRegistration() {

+ {/* Keystore Options */} +
+
+ setSaveToKeystore(e.target.checked)} + className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" + /> + +
+ + {saveToKeystore && ( +
+ + setKeystorePassword(e.target.value)} + placeholder="Enter password to encrypt your keystore" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + minLength={8} + /> +

+ The password will be used to encrypt your RLN credentials in the keystore. + You will need this password to decrypt your credentials later. +

+
+ )} +
+ {address && (

Registration Details:

@@ -199,9 +264,9 @@ export default function RLNMembershipRegistration() {
)} diff --git a/examples/keystore-management/src/contexts/KeystoreContext.tsx b/examples/keystore-management/src/contexts/KeystoreContext.tsx new file mode 100644 index 0000000..98746e4 --- /dev/null +++ b/examples/keystore-management/src/contexts/KeystoreContext.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { Keystore, KeystoreEntity } from '@waku/rln'; + +// Define types for the context +interface KeystoreContextType { + keystore: Keystore | null; + isInitialized: boolean; + error: string | null; + hasStoredCredentials: boolean; + storedCredentialsHashes: string[]; + saveCredentials: (credentials: KeystoreEntity, password: string) => Promise; + loadCredential: (hash: string, password: string) => Promise; + exportKeystore: () => string; + importKeystore: (keystoreJson: string) => boolean; + removeCredential: (hash: string) => void; +} + +// Create the context +const KeystoreContext = createContext(undefined); + +// Provider component +export function KeystoreProvider({ children }: { children: ReactNode }) { + const [keystore, setKeystore] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(null); + const [storedCredentialsHashes, setStoredCredentialsHashes] = useState([]); + + // Initialize keystore + useEffect(() => { + try { + const storedKeystore = localStorage.getItem('waku-rln-keystore'); + let keystoreInstance: Keystore; + + if (storedKeystore) { + const loaded = Keystore.fromString(storedKeystore); + if (loaded) { + keystoreInstance = loaded; + } else { + keystoreInstance = Keystore.create(); + } + } else { + keystoreInstance = Keystore.create(); + } + + setKeystore(keystoreInstance); + setStoredCredentialsHashes(keystoreInstance.keys()); + setIsInitialized(true); + } catch (err) { + console.error("Error initializing keystore:", err); + setError(err instanceof Error ? err.message : "Failed to initialize keystore"); + } + }, []); + + // Save keystore to localStorage whenever it changes + useEffect(() => { + if (keystore && isInitialized) { + try { + localStorage.setItem('waku-rln-keystore', keystore.toString()); + } catch (err) { + console.warn("Could not save keystore to localStorage:", err); + } + } + }, [keystore, isInitialized]); + + const saveCredentials = async (credentials: KeystoreEntity, password: string): Promise => { + if (!keystore) { + throw new Error("Keystore not initialized"); + } + + try { + const hash = await keystore.addCredential(credentials, password); + + localStorage.setItem('waku-rln-keystore', keystore.toString()); + + setStoredCredentialsHashes(keystore.keys()); + + return hash; + } catch (err) { + console.error("Error saving credentials:", err); + throw err; + } + }; + + const loadCredential = async (hash: string, password: string): Promise => { + if (!keystore) { + throw new Error("Keystore not initialized"); + } + + try { + return await keystore.readCredential(hash, password); + } catch (err) { + console.error("Error loading credential:", err); + throw err; + } + }; + + const exportKeystore = (): string => { + if (!keystore) { + throw new Error("Keystore not initialized"); + } + + return keystore.toString(); + }; + + const importKeystore = (keystoreJson: string): boolean => { + try { + const imported = Keystore.fromString(keystoreJson); + if (imported) { + setKeystore(imported); + setStoredCredentialsHashes(imported.keys()); + localStorage.setItem('waku-rln-keystore', keystoreJson); + return true; + } + return false; + } catch (err) { + console.error("Error importing keystore:", err); + setError(err instanceof Error ? err.message : "Failed to import keystore"); + return false; + } + }; + + const removeCredential = (hash: string): void => { + if (!keystore) { + throw new Error("Keystore not initialized"); + } + + keystore.removeCredential(hash); + setStoredCredentialsHashes(keystore.keys()); + localStorage.setItem('waku-rln-keystore', keystore.toString()); + }; + + const contextValue: KeystoreContextType = { + keystore, + isInitialized, + error, + hasStoredCredentials: storedCredentialsHashes.length > 0, + storedCredentialsHashes, + saveCredentials, + loadCredential, + exportKeystore, + importKeystore, + removeCredential + }; + + return ( + + {children} + + ); +} + +export function useKeystore() { + const context = useContext(KeystoreContext); + if (context === undefined) { + throw new Error('useKeystore must be used within a KeystoreProvider'); + } + return context; +} \ No newline at end of file diff --git a/examples/keystore-management/src/contexts/RLNLightContext.tsx b/examples/keystore-management/src/contexts/RLNLightContext.tsx index a43db16..bd83846 100644 --- a/examples/keystore-management/src/contexts/RLNLightContext.tsx +++ b/examples/keystore-management/src/contexts/RLNLightContext.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import { DecryptedCredentials, RLNInstance, RLNLightInstance } from '@waku/rln'; import { useWallet } from './WalletContext'; import { ethers } from 'ethers'; @@ -88,7 +88,7 @@ export function RLNProvider({ children }: { children: ReactNode }) { } }; - const initializeRLN = async () => { + const initializeRLN = useCallback(async () => { console.log("InitializeRLN called. Connected:", isConnected, "Signer available:", !!signer); try { @@ -117,11 +117,6 @@ export function RLNProvider({ children }: { children: ReactNode }) { if (isConnected && signer && rln && !isStarted) { console.log("Starting RLN with signer..."); try { - // Initialize with localKeystore if available (just for reference in localStorage) - const localKeystore = localStorage.getItem("rln-keystore") || ""; - console.log("Local keystore available:", !!localKeystore); - - // Start RLN with signer await rln.start({ signer }); setIsStarted(true); @@ -153,7 +148,7 @@ export function RLNProvider({ children }: { children: ReactNode }) { console.error('Error in initializeRLN:', err); setError(err instanceof Error ? err.message : 'Failed to initialize RLN'); } - }; + }, [isConnected, signer, rln, isStarted]); const registerMembership = async (rateLimit: number) => { console.log("registerMembership called with rate limit:", rateLimit); diff --git a/examples/keystore-management/src/contexts/RLNUnifiedContext2.tsx b/examples/keystore-management/src/contexts/RLNUnifiedContext2.tsx index 46fe12a..c7e2141 100644 --- a/examples/keystore-management/src/contexts/RLNUnifiedContext2.tsx +++ b/examples/keystore-management/src/contexts/RLNUnifiedContext2.tsx @@ -6,6 +6,7 @@ import { UnifiedRLNInstance } from './RLNFactory'; import { useRLNImplementation } from './RLNImplementationContext'; import { createRLNImplementation } from './RLNFactory'; import { ethers } from 'ethers'; +import { useKeystore } from './KeystoreContext'; // Constants for RLN membership registration const ERC20_ABI = [ @@ -27,11 +28,17 @@ interface RLNContextType { isStarted: boolean; error: string | null; initializeRLN: () => Promise; - registerMembership: (rateLimit: number) => Promise<{ success: boolean; error?: string; credentials?: KeystoreEntity }>; + registerMembership: (rateLimit: number, saveOptions?: { password: string }) => Promise<{ + success: boolean; + error?: string; + credentials?: KeystoreEntity; + keystoreHash?: string; + }>; rateMinLimit: number; rateMaxLimit: number; getCurrentRateLimit: () => Promise; getRateLimitsBounds: () => Promise<{ success: boolean; rateMinLimit: number; rateMaxLimit: number; error?: string }>; + saveCredentialsToKeystore: (credentials: KeystoreEntity, password: string) => Promise; } // Create the context @@ -50,6 +57,8 @@ export function RLNUnifiedProvider({ children }: { children: ReactNode }) { const [isConnected, setIsConnected] = useState(false); const [rateMinLimit, setRateMinLimit] = useState(0); const [rateMaxLimit, setRateMaxLimit] = useState(0); + + const { saveCredentials: saveToKeystore } = useKeystore(); // Listen for wallet connection useEffect(() => { @@ -176,18 +185,12 @@ export function RLNUnifiedProvider({ children }: { children: ReactNode }) { // Start RLN if wallet is connected if (isConnected && signer && rln && !isStarted) { console.log("Starting RLN with signer..."); - try { - // Initialize with localKeystore if available (just for reference in localStorage) - const localKeystore = localStorage.getItem("rln-keystore") || ""; - console.log("Local keystore available:", !!localKeystore); - - // Start RLN with signer + try { await rln.start({ signer }); setIsStarted(true); console.log("RLN started successfully, isStarted set to true"); - // Fetch rate limits after RLN is started try { const minLimit = await rln.contract.getMinRateLimit(); const maxLimit = await rln.contract.getMaxRateLimit(); @@ -264,7 +267,23 @@ export function RLNUnifiedProvider({ children }: { children: ReactNode }) { } } - const registerMembership = async (rateLimit: number) => { + // Save credentials to keystore + const saveCredentialsToKeystore = async (credentials: KeystoreEntity, password: string): Promise => { + try { + return await saveToKeystore(credentials, password); + } catch (err) { + console.error("Error saving credentials to keystore:", err); + throw err; + } + }; + + // Update registerMembership to optionally save credentials to keystore + const registerMembership = async (rateLimit: number, saveOptions?: { password: string }): Promise<{ + success: boolean; + error?: string; + credentials?: KeystoreEntity; + keystoreHash?: string; + }> => { console.log("registerMembership called with rate limit:", rateLimit); if (!rln || !isStarted) { @@ -369,16 +388,21 @@ export function RLNUnifiedProvider({ children }: { children: ReactNode }) { console.log("Membership registered successfully"); - // Store credentials in localStorage for reference - try { - localStorage.setItem("rln-keystore", JSON.stringify(credentials)); - } catch (storageErr) { - console.warn("Could not store credentials in localStorage:", storageErr); + // If saveOptions provided, save to keystore + let keystoreHash: string | undefined; + if (saveOptions?.password) { + try { + keystoreHash = await saveCredentialsToKeystore(credentials, saveOptions.password); + console.log("Credentials saved to keystore with hash:", keystoreHash); + } catch (keystoreErr) { + console.warn("Could not save credentials to keystore:", keystoreErr); + } } return { success: true, - credentials: credentials + credentials, + keystoreHash }; } catch (registerErr) { console.error("Error registering membership:", registerErr); @@ -407,7 +431,8 @@ export function RLNUnifiedProvider({ children }: { children: ReactNode }) { getCurrentRateLimit, getRateLimitsBounds, rateMinLimit, - rateMaxLimit + rateMaxLimit, + saveCredentialsToKeystore, }; return ( diff --git a/examples/keystore-management/src/contexts/RLNZerokitContext.tsx b/examples/keystore-management/src/contexts/RLNZerokitContext.tsx index a018e87..fcec774 100644 --- a/examples/keystore-management/src/contexts/RLNZerokitContext.tsx +++ b/examples/keystore-management/src/contexts/RLNZerokitContext.tsx @@ -109,15 +109,9 @@ export function RLNProvider({ children }: { children: ReactNode }) { console.log("RLN instance already exists, skipping creation"); } - // Start RLN if wallet is connected if (isConnected && signer && rln && !isStarted) { console.log("Starting RLN with signer..."); try { - // Initialize with localKeystore if available (just for reference in localStorage) - const localKeystore = localStorage.getItem("rln-keystore") || ""; - console.log("Local keystore available:", !!localKeystore); - - // Start RLN with signer await rln.start({ signer }); setIsStarted(true); diff --git a/examples/keystore-management/src/utils/fileUtils.ts b/examples/keystore-management/src/utils/fileUtils.ts new file mode 100644 index 0000000..dc7d158 --- /dev/null +++ b/examples/keystore-management/src/utils/fileUtils.ts @@ -0,0 +1,62 @@ +/** + * Utility functions for handling keystore file operations + */ + +/** + * Save a keystore JSON string to a file + * @param keystoreJson The keystore JSON as a string + * @param filename Optional filename (defaults to 'waku-rln-keystore.json') + */ +export const saveKeystoreToFile = (keystoreJson: string, filename: string = 'waku-rln-keystore.json'): void => { + const blob = new Blob([keystoreJson], { type: 'application/json' }); + + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + + document.body.appendChild(link); + + link.click(); + + URL.revokeObjectURL(url); + document.body.removeChild(link); +}; + +/** + * Read a keystore file and return its content as a string + * @returns Promise resolving to the file content as a string + */ +export const readKeystoreFromFile = (): Promise => { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + + input.onchange = (event) => { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (!file) { + reject(new Error('No file selected')); + return; + } + + const reader = new FileReader(); + + reader.onload = () => { + const content = reader.result as string; + resolve(content); + }; + + reader.onerror = () => { + reject(new Error('Failed to read file')); + }; + + reader.readAsText(file); + }; + + input.click(); + }); +}; \ No newline at end of file