chore: move webapp from lab.waku.org

This commit is contained in:
Danish Arora 2025-04-04 17:29:57 +05:30
commit 9b97a2fb03
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
54 changed files with 12796 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

43
README.md Normal file
View File

@ -0,0 +1,43 @@
# Waku Keystore Management
Application to manage Waku RLN keystores.
## Overview
This application provides an interface for managing keystores for Waku's rate-limiting nullifier (RLN) functionality. It integrates with MetaMask for wallet connectivity.
## Features
- Connect to MetaMask wallet with dropdown menu for account details
- Terminal-inspired UI with cyberpunk styling
- View wallet information including address, network, and balance
- Support for Linea Sepolia testnet only
- Keystore management with copy, view, export, and remove functionality
- Token approval for RLN membership registration
- Light/standard RLN implementation toggle
## Linea Sepolia Network
This application is configured to use ONLY the Linea Sepolia testnet. If you don't have Linea Sepolia configured in your MetaMask, the application will help you add it with the following details:
- **Network Name**: Linea Sepolia Testnet
- **RPC URL**: https://rpc.sepolia.linea.build
- **Chain ID**: 59141
- **Currency Symbol**: ETH
- **Block Explorer URL**: https://sepolia.lineascan.build
You can get Linea Sepolia testnet ETH from the [Linea Faucet](https://faucet.goerli.linea.build/).
## RLN Membership Registration
When registering for RLN membership, you'll need to complete two transactions:
1. **Token Approval**: First, you'll need to approve the RLN contract to spend tokens on your behalf. This is a one-time approval.
2. **Membership Registration**: After approval, the actual membership registration transaction will be submitted.
If you encounter an "ERC20: insufficient allowance" error, it means the token approval transaction was not completed successfully. Please try again and make sure to approve the token spending in your wallet.
## TODO
- [ ] add info about using with nwaku/nwaku-compose/waku-simulator
- [ ] fix rate limit fetch

16
eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

13
next.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: [
'waku.org',
'logos.co',
'contributors.free.technology'
],
},
reactStrictMode: true,
}
module.exports = nextConfig

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

9073
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "waku-keystore-management",
"version": "0.1.0",
"private": true,
"description": "A simple application to manage Waku RLN keystores",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.5",
"@next/font": "^14.2.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.6.3",
"lucide-react": "^0.487.0",
"next": "15.1.7",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.1.7",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

177
src/app/globals.css Normal file
View File

@ -0,0 +1,177 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--font-sans: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@layer utilities {
.animate-in {
animation: animateIn 0.3s ease-in-out forwards;
}
.fade-in-50 {
opacity: 0;
animation-name: fadeIn50;
}
@keyframes fadeIn50 {
from { opacity: 0; }
to { opacity: 1; }
}
.duration-300 {
animation-duration: 300ms;
}
.typing-effect {
overflow: hidden;
white-space: nowrap;
border-right: 2px solid theme('colors.primary.DEFAULT');
width: 0;
animation: typing 3s steps(40, end) forwards, blink-caret 1s step-end infinite;
}
@keyframes typing {
from { width: 0 }
to { width: 100% }
}
@keyframes blink-caret {
from, to { border-color: transparent }
50% { border-color: theme('colors.primary.DEFAULT') }
}
}
@layer components {
.terminal-window {
@apply bg-terminal-background border border-terminal-border relative rounded-md overflow-hidden;
box-shadow: 0 0 5px theme('colors.terminal.border'), inset 0 0 5px rgba(0, 0, 0, 0.5);
}
.terminal-header {
@apply bg-muted px-4 py-2 flex items-center justify-between border-b border-terminal-border;
}
.terminal-content {
@apply p-4 font-mono text-sm relative;
}
.terminal-content::before {
content: '';
@apply absolute top-0 left-0 right-0 bottom-0 pointer-events-none;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.1),
rgba(0, 0, 0, 0.1) 1px,
transparent 1px,
transparent 2px
);
}
.terminal-text {
@apply text-terminal-text;
}
.scan-line {
@apply absolute top-0 left-0 w-full h-[2px] bg-white/10 opacity-75 pointer-events-none;
animation: scan-line 6s linear infinite;
}
.cursor-blink::after {
content: '|';
@apply animate-blink ml-[1px];
}
.glow-text {
text-shadow: 0 0 5px currentColor;
}
.button-glow {
@apply transition-all duration-300;
box-shadow: 0 0 5px theme('colors.primary.DEFAULT');
}
.button-glow:hover {
box-shadow: 0 0 10px theme('colors.primary.DEFAULT'), 0 0 20px theme('colors.primary.DEFAULT');
}
.hexagon-bg {
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 5.66987L55 20.8349V51.1651L30 66.3301L5 51.1651V20.8349L30 5.66987ZM30 0L0 17.3205V51.9616L30 69.2821L60 51.9616V17.3205L30 0Z' fill='%2320202A' fill-opacity='0.3'/%3E%3C/svg%3E");
background-size: 60px 60px;
}
.circuit-bg {
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 10 H90 V90 H10 Z' fill='none' stroke='%2320202A' stroke-width='1'/%3E%3Cpath d='M30 30 H70 V70 H30 Z' fill='none' stroke='%2320202A' stroke-width='1'/%3E%3Cpath d='M50 10 V30 M50 70 V90 M10 50 H30 M70 50 H90' stroke='%2320202A' stroke-width='1'/%3E%3C/svg%3E");
background-size: 100px 100px;
}
.glitch {
position: relative;
animation: glitch-animation 2s infinite;
}
@keyframes glitch-animation {
0% {
transform: none;
opacity: 1;
}
7% {
transform: skew(-0.5deg, -0.9deg);
opacity: 0.75;
}
10% {
transform: none;
opacity: 1;
}
27% {
transform: none;
opacity: 1;
}
30% {
transform: skew(0.8deg, -0.1deg);
opacity: 0.75;
}
35% {
transform: none;
opacity: 1;
}
52% {
transform: none;
opacity: 1;
}
55% {
transform: skew(-1deg, 0.2deg);
opacity: 0.75;
}
50% {
transform: none;
opacity: 1;
}
72% {
transform: none;
opacity: 1;
}
75% {
transform: skew(0.4deg, 1deg);
opacity: 0.75;
}
80% {
transform: none;
opacity: 1;
}
100% {
transform: none;
opacity: 1;
}
}
}

74
src/app/layout.tsx Normal file
View File

@ -0,0 +1,74 @@
import "./globals.css";
import { Inter as FontSans, JetBrains_Mono as FontMono } from "next/font/google";
import { cn } from "../lib/utils";
import { ThemeProvider } from "../components/theme-provider";
import { Toaster } from "../components/ui/toaster";
import "@fontsource-variable/inter";
import "@fontsource-variable/jetbrains-mono";
import { WalletProvider, RLNImplementationProvider, KeystoreProvider, RLNProvider } from "../contexts/index";
import { Header } from "../components/Header";
import { AppStateProvider } from "../contexts/AppStateContext";
import { Footer } from "@/components/Footer";
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export const fontMono = FontMono({
subsets: ["latin"],
variable: "--font-mono",
});
export const metadata = {
title: "Waku Keystore Management",
description: "A simple application to manage Waku RLN keystores",
};
interface RootLayoutProps {
children: React.ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en" suppressHydrationWarning>
<head />
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
fontMono.variable,
"circuit-bg"
)}
>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
storageKey="waku-keystore-theme"
>
<AppStateProvider>
<WalletProvider>
<RLNImplementationProvider>
<KeystoreProvider>
<RLNProvider>
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1 container mx-auto py-8">
{children}
</main>
<Footer />
</div>
<Toaster />
</RLNProvider>
</KeystoreProvider>
</RLNImplementationProvider>
</WalletProvider>
</AppStateProvider>
</ThemeProvider>
</body>
</html>
);
}

15
src/app/page.tsx Normal file
View File

@ -0,0 +1,15 @@
"use client";
import React from 'react';
import { Layout } from '../components/Layout';
import { MembershipRegistration } from '../components/Tabs/MembershipTab/MembershipRegistration';
import { KeystoreManagement } from '../components/Tabs/KeystoreTab/KeystoreManagement';
export default function Home() {
return (
<Layout>
<MembershipRegistration />
<KeystoreManagement />
</Layout>
);
}

100
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,100 @@
import Link from "next/link";
import { Github } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function Footer() {
return (
<footer className="w-full border-t border-terminal-border bg-terminal-background/80 backdrop-blur-sm mt-auto">
<div className="container mx-auto py-8">
<div className="flex flex-col items-center justify-center space-y-8">
{/* Waku Section */}
<div className="flex flex-col items-center space-y-4">
<Link
href="https://waku.org"
target="_blank"
className="flex items-center space-x-3 px-4 py-2 rounded-md transition-all duration-300 hover:bg-terminal-background/50"
>
<span className="text-primary font-mono text-xl font-bold glow-text">
Waku
</span>
</Link>
{/* Community Links */}
<div className="flex items-center space-x-6">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="https://discord.waku.org"
target="_blank"
className="text-muted-foreground hover:text-primary transition-colors duration-200"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026 13.83 13.83 0 0 0 1.226-1.963.074.074 0 0 0-.041-.104 13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028zM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38z"/>
</svg>
</Link>
</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">Join our Discord community for support & feedback</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Link
href="https://github.com/waku-org"
target="_blank"
className="text-muted-foreground hover:text-primary transition-colors duration-200"
>
<Github className="w-5 h-5" />
</Link>
<Link
href="https://docs.waku.org"
target="_blank"
className="text-muted-foreground hover:text-primary transition-colors duration-200 font-mono text-sm"
>
Docs
</Link>
<Link
href="https://github.com/waku-org/specs"
target="_blank"
className="text-muted-foreground hover:text-primary transition-colors duration-200 font-mono text-sm"
>
Specs
</Link>
</div>
</div>
{/* Organizational Structure */}
<div className="flex items-center justify-center space-x-8 text-muted-foreground">
<Link
href="https://logos.co"
target="_blank"
className="font-mono text-sm opacity-60 hover:opacity-100 transition-opacity duration-300"
>
Logos
</Link>
<span className="text-muted-foreground/40"></span>
<Link
href="https://free.technology"
target="_blank"
className="font-mono text-sm opacity-60 hover:opacity-100 transition-opacity duration-300"
>
IFT
</Link>
</div>
</div>
</div>
</footer>
);
}

20
src/components/Header.tsx Normal file
View File

@ -0,0 +1,20 @@
"use client";
import { WalletDropdown } from "./WalletDropdown";
export function Header() {
return (
<header className="sticky top-0 z-50 w-full border-b border-terminal-border bg-terminal-background/80 backdrop-blur-sm">
<div className="container mx-auto flex h-16 items-center justify-between py-4">
<div className="font-mono text-lg font-bold">
<span className="text-primary glitch">Waku Keystore</span>{" "}
<span className="text-foreground opacity-80">Management</span>
</div>
<div className="flex items-center space-x-4">
<WalletDropdown />
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,87 @@
import { useState } from "react";
import { useKeystore } from "@/contexts/keystore";
import { toast } from "sonner";
import { ArrowUpToLine } from "lucide-react";
import { Button } from "./ui/button";
export function KeystoreExporter() {
const [showPasswordInput, setShowPasswordInput] = useState(false);
const [password, setPassword] = useState("");
const [isExporting, setIsExporting] = useState(false);
const { exportEntireKeystore, hasStoredCredentials } = useKeystore();
const handleExport = async () => {
if (!password) {
toast.error("Password is required");
return;
}
try {
setIsExporting(true);
await exportEntireKeystore(password);
toast.success("Keystore exported successfully");
setPassword("");
setShowPasswordInput(false);
setIsExporting(false);
} catch (error) {
setIsExporting(false);
toast.error("Failed to export keystore: " + (error instanceof Error ? error.message : String(error)));
}
};
return (
<div className="space-y-3">
{!showPasswordInput ? (
<Button
onClick={() => setShowPasswordInput(true)}
variant="terminal"
className="group relative overflow-hidden"
disabled={!hasStoredCredentials}
>
<span className="relative z-10 flex items-center">
<ArrowUpToLine className="w-4 h-4 mr-2" />
Export Keystore
</span>
<span className="absolute inset-0 bg-primary/10 transform translate-y-full group-hover:translate-y-0 transition-transform duration-200"></span>
</Button>
) : (
<div className="animate-in fade-in-50 duration-300 space-y-3">
<div className="flex flex-col space-y-3 sm:flex-row sm:space-y-0 sm:space-x-3">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter keystore password"
className="flex-1 rounded-sm border border-terminal-border bg-background/80 px-3 py-2 font-mono text-xs text-terminal-text placeholder:text-terminal-text/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
disabled={isExporting}
autoFocus
/>
<div className="flex space-x-2">
<Button
onClick={handleExport}
variant="terminal"
disabled={!password || isExporting}
className="whitespace-nowrap"
>
{isExporting ? "Exporting..." : "Export"}
</Button>
<Button
onClick={() => {
setShowPasswordInput(false);
setPassword("");
}}
variant="outline"
className="text-muted-foreground hover:text-muted-foreground"
disabled={isExporting}
>
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
);
}

70
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,70 @@
"use client";
import React, { Children, isValidElement } from 'react';
import { useAppState } from '../contexts/AppStateContext';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
const tabs = [
{
id: 'membership',
label: 'Membership Registration',
},
{
id: 'keystore',
label: 'Keystore Management',
},
];
const componentToTabId: Record<string, string> = {
MembershipRegistration: 'membership',
KeystoreManagement: 'keystore',
};
export function Layout({ children }: { children: React.ReactNode }) {
const { activeTab, setActiveTab } = useAppState();
const childrenArray = Children.toArray(children);
const getTabContent = (tabId: string) => {
return childrenArray.find((child) => {
if (isValidElement(child) && typeof child.type === 'function') {
const componentName = child.type.name;
return componentToTabId[componentName] === tabId;
}
return false;
});
};
return (
<div className="w-full">
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-6">
<div className="col-span-1">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="w-full justify-start max-w-md">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="px-6"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-6">
{getTabContent(tab.id)}
</TabsContent>
))}
</Tabs>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
"use client";
import { useRLNImplementation } from '../contexts';
import React from 'react';
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
export function RLNImplementationToggle() {
const { implementation, setImplementation } = useRLNImplementation();
return (
<div className="flex flex-col space-y-3">
<label className="text-sm font-mono text-muted-foreground">
RLN Implementation
</label>
<ToggleGroup
type="single"
value={implementation}
onValueChange={(value) => {
if (value) setImplementation(value as 'light' | 'standard');
}}
className="w-full max-w-md"
>
<ToggleGroupItem value="light" className="flex-1">
Light
</ToggleGroupItem>
<ToggleGroupItem value="standard" className="flex-1">
Standard
</ToggleGroupItem>
</ToggleGroup>
<p className="text-xs font-mono text-muted-foreground opacity-80">
{implementation === 'light'
? 'Light implementation, without Zerokit. Instant initialisation.'
: 'Standard implementation, with Zerokit. Initialisation takes 10-15 seconds for WASM module.'
}
</p>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { useRLN } from '../contexts';
import React from 'react';
import { Button } from './ui/button';
export function RLNInitButton() {
const { initializeRLN, isInitialized, isStarted, error, isLoading } = useRLN();
const handleInitialize = async () => {
try {
await initializeRLN();
} catch (err) {
console.error('Error initializing RLN:', err);
}
};
return (
<div className="flex flex-col gap-2 font-mono">
<Button
onClick={handleInitialize}
disabled={isLoading || (isInitialized && isStarted)}
variant={isInitialized && isStarted ? "default" : "terminal"}
className="w-full sm:w-auto font-mono"
>
{isLoading && (
<svg className="mr-2 h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{isInitialized && isStarted && (
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{isLoading ? 'Initializing...' : (isInitialized && isStarted) ? 'RLN Initialized' : 'Initialize RLN'}
</Button>
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,397 @@
"use client";
import React, { useState } from 'react';
import { useKeystore } from '../../../contexts/keystore';
import { readKeystoreFromFile, saveKeystoreCredentialToFile } from '../../../utils/keystore';
import { DecryptedCredentials } 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';
export function KeystoreManagement() {
const {
hasStoredCredentials,
storedCredentialsHashes,
error,
exportCredential,
importKeystore,
removeCredential,
getDecryptedCredential
} = useKeystore();
const { setGlobalError } = useAppState();
const [exportPassword, setExportPassword] = useState<string>('');
const [selectedCredential, setSelectedCredential] = useState<string | null>(null);
const [viewPassword, setViewPassword] = useState<string>('');
const [viewingCredential, setViewingCredential] = useState<string | null>(null);
const [decryptedInfo, setDecryptedInfo] = useState<DecryptedCredentials | null>(null);
const [isDecrypting, setIsDecrypting] = useState(false);
const [copiedHash, setCopiedHash] = useState<string | null>(null);
React.useEffect(() => {
if (error) {
setGlobalError(error);
}
}, [error, setGlobalError]);
const handleExportKeystoreCredential = async (hash: string) => {
try {
if (!exportPassword) {
setGlobalError('Please enter your keystore password to export');
return;
}
const keystore = await exportCredential(hash, exportPassword);
saveKeystoreCredentialToFile(keystore);
setExportPassword('');
setSelectedCredential(null);
} catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to export credential');
}
};
const handleImportKeystore = async () => {
try {
const keystore = await readKeystoreFromFile();
const success = importKeystore(keystore);
if (!success) {
setGlobalError('Failed to import keystore');
}
} catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to import keystore');
}
};
const handleRemoveCredential = (hash: string) => {
try {
removeCredential(hash);
} catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to remove credential');
}
};
const handleViewCredential = async (hash: string) => {
if (!viewPassword) {
setGlobalError('Please enter your keystore password to view credential');
return;
}
setIsDecrypting(true);
try {
const credential = await getDecryptedCredential(hash, viewPassword);
setIsDecrypting(false);
if (credential) {
setDecryptedInfo(credential);
} else {
setGlobalError('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');
}
};
// Reset view state when changing credentials
React.useEffect(() => {
if (viewingCredential !== selectedCredential) {
setDecryptedInfo(null);
}
}, [viewingCredential, selectedCredential]);
// Add a function to copy text to clipboard with visual feedback
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
.then(() => {
setCopiedHash(text);
setTimeout(() => setCopiedHash(null), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return (
<div className="space-y-6">
<TerminalWindow className="w-full">
<h2 className="text-lg font-mono font-medium text-primary mb-4 cursor-blink">
{keystoreManagement.title}
</h2>
<div className="space-y-6">
<div className="flex flex-wrap gap-3">
<Button
onClick={handleImportKeystore}
variant="terminal"
className="group relative overflow-hidden"
>
<span className="relative z-10 flex items-center">
<ArrowDownToLine className="w-4 h-4 mr-2" />
{keystoreManagement.buttons.import}
</span>
<span className="absolute inset-0 bg-primary/10 transform translate-y-full group-hover:translate-y-0 transition-transform duration-200"></span>
</Button>
<KeystoreExporter />
</div>
{/* Warning - RLN not initialized */}
{!hasStoredCredentials && (
<div className="my-4 p-3 border border-warning-DEFAULT/20 bg-warning-DEFAULT/5 rounded">
<p className="text-sm text-warning-DEFAULT font-mono flex items-center">
<span className="mr-2"></span>
{keystoreManagement.noCredentialsWarning}
</p>
</div>
)}
{/* About Section */}
<div className="border-t border-terminal-border pt-4 mt-4">
<div className="flex items-center mb-3">
<span className="text-primary font-mono font-medium mr-2">{">"}</span>
<h3 className="text-md font-mono font-semibold text-primary">
{keystoreManagement.infoHeader}
</h3>
</div>
<div className="space-y-3">
{keystoreManagement.about.map((paragraph: ContentSegment[], i: number) => (
<p key={i} className="text-sm text-foreground mb-2 opacity-90">
{paragraph.map((segment: ContentSegment, j: number) => (
segment.type === 'link' ? (
<a
key={j}
href={segment.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{segment.content}
</a>
) : (
<span key={j}>{segment.content}</span>
)
))}
</p>
))}
</div>
</div>
{/* Resources Section */}
<div className="border-t border-terminal-border pt-4">
<h3 className="text-md font-mono font-semibold text-primary mb-3">
{keystoreManagement.resources.title}
</h3>
<div className="flex flex-wrap gap-3">
{keystoreManagement.resources.links.map((link: { name: string; url: string }, i: number) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm"
>
{link.name}
</a>
))}
</div>
</div>
{/* Stored Credentials */}
<div className="border-t border-terminal-border pt-6">
<h3 className="text-sm font-mono font-medium text-muted-foreground mb-4">
{keystoreManagement.storedCredentialsTitle}
</h3>
{hasStoredCredentials ? (
<div className="space-y-4">
{storedCredentialsHashes.map((hash) => (
<div
key={hash}
className="p-4 rounded-md border border-terminal-border bg-terminal-background/30 hover:border-terminal-border/80 transition-colors"
>
<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<code className="text-sm text-muted-foreground font-mono">
{hash.slice(0, 10)}...{hash.slice(-6)}
</code>
<Button
variant="ghost"
size="sm"
className={`h-6 w-6 p-0 ${copiedHash === hash ? 'text-success-DEFAULT' : 'text-muted-foreground hover:text-primary'}`}
onClick={() => copyToClipboard(hash)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
{copiedHash === hash && (
<span className="text-xs text-success-DEFAULT">Copied!</span>
)}
</div>
<div className="flex items-center space-x-2">
<Button
onClick={() => {
setViewingCredential(hash === viewingCredential ? null : hash);
setSelectedCredential(null);
setViewPassword('');
setDecryptedInfo(null);
}}
variant="outline"
size="sm"
className="text-accent hover:text-accent hover:border-accent flex items-center gap-1 py-1"
>
<Eye className="w-3 h-3" />
<span>{keystoreManagement.buttons.view}</span>
</Button>
<Button
onClick={() => {
setSelectedCredential(hash === selectedCredential ? null : hash);
setViewingCredential(null);
setExportPassword('');
}}
variant="outline"
size="sm"
className="text-primary hover:text-primary hover:border-primary flex items-center gap-1 py-1"
>
<Download className="w-3 h-3" />
<span>Export</span>
</Button>
<Button
onClick={() => handleRemoveCredential(hash)}
variant="outline"
size="sm"
className="text-destructive hover:text-destructive hover:border-destructive flex items-center gap-1 py-1"
>
<Trash2 className="w-3 h-3" />
<span>Remove</span>
</Button>
</div>
</div>
{/* View Credential Section */}
{viewingCredential === hash && (
<div className="mt-3 space-y-3 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<div className="flex gap-2">
<input
type="password"
value={viewPassword}
onChange={(e) => setViewPassword(e.target.value)}
placeholder="Enter credential password"
className="flex-1 px-3 py-2 border border-terminal-border rounded-md bg-terminal-background text-foreground font-mono focus:ring-1 focus:ring-accent focus:border-accent text-sm"
disabled={isDecrypting}
/>
<Button
onClick={() => handleViewCredential(hash)}
disabled={!viewPassword || isDecrypting}
variant="terminal"
size="sm"
>
{isDecrypting ? 'Decrypting...' : 'Decrypt'}
</Button>
</div>
{/* 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>
<Button
onClick={() => setDecryptedInfo(null)}
variant="ghost"
size="sm"
className="mt-2 text-xs text-muted-foreground hover:text-accent"
>
Hide Details
</Button>
</div>
</div>
)}
</div>
)}
{/* Export Credential Section */}
{selectedCredential === hash && (
<div className="mt-3 space-y-3 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<input
type="password"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter keystore password"
className="w-full px-3 py-2 border border-terminal-border rounded-md bg-terminal-background text-foreground font-mono focus:ring-1 focus:ring-primary focus:border-primary text-sm"
/>
<Button
onClick={() => handleExportKeystoreCredential(hash)}
variant="default"
size="sm"
className="w-full font-mono"
disabled={!exportPassword}
>
<Download className="w-3 h-3 mr-1" />
Export Credential
</Button>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground font-mono bg-terminal-background/30 p-4 border border-terminal-border/50 rounded-md text-center">
No credentials stored
</div>
)}
</div>
</div>
</TerminalWindow>
</div>
);
}

View File

@ -0,0 +1,278 @@
"use client";
import React, { useState, useEffect } from 'react';
import { RLNImplementationToggle } from '../../RLNImplementationToggle';
import { KeystoreEntity } from '@waku/rln';
import { useAppState } from '../../../contexts/AppStateContext';
import { useRLN } from '../../../contexts/rln/RLNContext';
import { useWallet } from '../../../contexts/wallet';
import { RLNInitButton } from '../../RLNinitButton';
import { TerminalWindow } from '../../ui/terminal-window';
import { Slider } from '../../ui/slider';
import { Button } from '../../ui/button';
import { membershipRegistration, type ContentSegment } from '../../../content/index';
export function MembershipRegistration() {
const { setGlobalError } = useAppState();
const { registerMembership, isInitialized, isStarted, rateMinLimit, rateMaxLimit, error } = useRLN();
const { isConnected, chainId } = useWallet();
const [rateLimit, setRateLimit] = useState<number>(rateMinLimit);
const [isRegistering, setIsRegistering] = useState(false);
const [saveToKeystore, setSaveToKeystore] = useState(true);
const [keystorePassword, setKeystorePassword] = useState('');
const [registrationResult, setRegistrationResult] = useState<{
success?: boolean;
error?: string;
txHash?: string;
warning?: string;
credentials?: KeystoreEntity;
keystoreHash?: string;
}>({});
const isLineaSepolia = chainId === 59141;
useEffect(() => {
if (error) {
setGlobalError(error);
}
}, [error, setGlobalError]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isConnected) {
setRegistrationResult({ success: false, error: 'Please connect your wallet first' });
return;
}
if (!isInitialized || !isStarted) {
setRegistrationResult({ success: false, error: 'RLN is not initialized' });
return;
}
if (!isLineaSepolia) {
setRegistrationResult({ success: false, error: 'Please switch to Linea Sepolia network' });
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({});
try {
setRegistrationResult({
success: undefined,
warning: 'Please check your wallet to sign the registration message.'
});
// 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,
error: error instanceof Error ? error.message : 'Registration failed'
});
} finally {
setIsRegistering(false);
}
};
return (
<div className="space-y-6 max-w-full">
<TerminalWindow className="w-full">
<h2 className="text-lg font-mono font-medium text-primary mb-4 cursor-blink">
{membershipRegistration.title}
</h2>
<div className="space-y-6">
<div className="border-b border-terminal-border pb-6">
<RLNImplementationToggle />
</div>
{/* Network Warning */}
{isConnected && !isLineaSepolia && (
<div className="mb-4 p-3 border border-destructive/20 bg-destructive/5 rounded">
<p className="text-sm text-destructive font-mono flex items-center">
<span className="mr-2"></span>
<span>{membershipRegistration.networkWarning}</span>
</p>
</div>
)}
{/* Informational Box */}
<div className="border-t border-terminal-border pt-4 mt-4">
<div className="flex items-center mb-3">
<span className="text-primary font-mono font-medium mr-2">{">"}</span>
<h3 className="text-md font-mono font-semibold text-primary">
{membershipRegistration.infoHeader}
</h3>
</div>
<div className="space-y-3">
<h4 className="text-md font-mono font-semibold text-primary cursor-blink">
{membershipRegistration.aboutTitle}
</h4>
{membershipRegistration.about.map((paragraph: ContentSegment[], i: number) => (
<p key={i} className="text-sm text-foreground mb-2 opacity-90">
{paragraph.map((segment: ContentSegment, j: number) => (
segment.type === 'link' ? (
<a
key={j}
href={segment.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{segment.content}
</a>
) : (
<span key={j}>{segment.content}</span>
)
))}
</p>
))}
</div>
</div>
<div className="border-t border-terminal-border pt-6 mt-4">
<div className="flex items-center space-x-2">
<RLNInitButton />
</div>
{!isConnected ? (
<div className="text-warning-DEFAULT font-mono text-sm mt-4 flex items-center">
<span className="mr-2"></span>
{membershipRegistration.connectWalletPrompt}
</div>
) : !isInitialized || !isStarted ? (
<div className="text-warning-DEFAULT font-mono text-sm mt-4 flex items-center">
<span className="mr-2"></span>
{membershipRegistration.initializePrompt}
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label
htmlFor="rateLimit"
className="block text-sm font-mono text-muted-foreground mb-2"
>
{membershipRegistration.form.rateLimitLabel}
</label>
<div className="flex items-center space-x-4 py-2">
<Slider
id="rateLimit"
min={rateMinLimit}
max={rateMaxLimit}
value={[rateLimit]}
onValueChange={(value) => setRateLimit(value[0])}
className="w-full"
/>
<span className="text-sm text-muted-foreground w-12 font-mono">
{rateLimit}
</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center mb-2">
<input
type="checkbox"
id="saveToKeystore"
checked={saveToKeystore}
onChange={(e) => setSaveToKeystore(e.target.checked)}
className="h-4 w-4 rounded bg-terminal-background border-terminal-border text-primary focus:ring-primary"
/>
<label
htmlFor="saveToKeystore"
className="ml-2 text-sm font-mono text-foreground"
>
{membershipRegistration.form.saveToKeystoreLabel}
</label>
</div>
{saveToKeystore && (
<div>
<label
htmlFor="keystorePassword"
className="block text-sm font-mono text-muted-foreground mb-2"
>
{membershipRegistration.form.passwordLabel}
</label>
<input
type="password"
id="keystorePassword"
value={keystorePassword}
onChange={(e) => setKeystorePassword(e.target.value)}
placeholder={membershipRegistration.form.passwordPlaceholder}
className="w-full px-3 py-2 bg-terminal-background border border-terminal-border rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
)}
</div>
<Button
type="submit"
disabled={isRegistering}
className="w-full"
>
{isRegistering ? membershipRegistration.form.registeringButton : membershipRegistration.form.registerButton}
</Button>
</form>
)}
</div>
{/* Registration Result */}
{registrationResult.warning && (
<div className="mt-4 p-3 border border-warning-DEFAULT/20 bg-warning-DEFAULT/5 rounded">
<p className="text-sm text-warning-DEFAULT font-mono flex items-center">
<span className="mr-2"></span>
{registrationResult.warning}
</p>
</div>
)}
{registrationResult.error && (
<div className="mt-4 p-3 border border-destructive/20 bg-destructive/5 rounded">
<p className="text-sm text-destructive font-mono flex items-center">
<span className="mr-2"></span>
{registrationResult.error}
</p>
</div>
)}
{registrationResult.success && (
<div className="mt-4 p-3 border border-success-DEFAULT/20 bg-success-DEFAULT/5 rounded">
<p className="text-sm text-success-DEFAULT font-mono mb-2 flex items-center">
<span className="mr-2"></span>
Membership registered successfully!
</p>
{registrationResult.txHash && (
<p className="text-xs text-success-DEFAULT font-mono opacity-80 break-all">
Transaction Hash: {registrationResult.txHash}
</p>
)}
</div>
)}
</div>
</TerminalWindow>
</div>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import React from 'react';
import { useWallet } from '../contexts/wallet';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '../components/ui/dropdown-menu';
import { Button } from '../components/ui/button';
import { ChevronDown } from 'lucide-react';
export function WalletDropdown() {
const { isConnected, address, chainId, connectWallet, disconnectWallet, balance } = useWallet();
if (!isConnected || !address) {
return (
<Button
onClick={connectWallet}
variant="terminal"
size="sm"
className="text-xs"
>
Connect Wallet
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="font-mono text-xs border-terminal-border hover:border-primary hover:text-primary"
>
<span
className={`inline-block h-2 w-2 rounded-full mr-1.5 bg-success-DEFAULT`}
/>
<span className="cursor-blink">
{address.slice(0, 6)}...{address.slice(-4)}
</span>
<ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Wallet</DropdownMenuLabel>
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Address:</span>
<span className="text-primary truncate ml-2">{address}</span>
</DropdownMenuItem>
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Network:</span>
<span className="text-primary ml-2">{chainId === 59141 ? "Linea Sepolia" : `Unknown (${chainId})`}</span>
</DropdownMenuItem>
{balance && (
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Balance:</span>
<span className="text-primary ml-2">{parseFloat(balance).toFixed(4)} ETH</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={disconnectWallet}
className="text-destructive focus:text-destructive cursor-pointer"
>
Disconnect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,10 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
type ThemeProviderProps = Parameters<typeof NextThemesProvider>[0];
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,63 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center font-mono rounded-sm text-sm whitespace-nowrap transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-glow-sm shadow-primary hover:shadow-glow hover:shadow-primary border border-primary/30",
secondary:
"bg-secondary text-secondary-foreground shadow-glow-sm shadow-secondary hover:shadow-glow hover:shadow-secondary border border-secondary/30",
accent:
"bg-accent text-accent-foreground shadow-glow-sm shadow-accent hover:shadow-glow hover:shadow-accent border border-accent/30",
destructive:
"bg-destructive text-destructive-foreground shadow-glow-sm shadow-destructive hover:shadow-glow hover:shadow-destructive border border-destructive/30",
outline:
"border border-primary text-foreground bg-transparent hover:shadow-glow-sm hover:shadow-primary",
terminal:
"bg-terminal-background border border-terminal-border text-terminal-text hover:shadow-glow-sm hover:shadow-terminal-text relative overflow-hidden",
ghost:
"hover:bg-muted hover:text-foreground",
link:
"text-primary hover:text-primary/80 underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-12 px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "../../lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-terminal-border bg-terminal-background p-1 shadow-md terminal-window",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-mono outline-none focus:bg-primary/10 focus:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-xs font-mono text-muted-foreground", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-terminal-border", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
};

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "../../lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-sm border border-primary bg-terminal-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 shadow-glow-sm shadow-primary" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex items-center justify-center border-b border-muted p-1 font-mono text-muted-foreground mb-4",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap py-3 px-4 text-sm font-mono transition-all relative",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:text-primary data-[state=active]:after:content-[''] data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-primary",
"hover:text-primary/80",
"data-[state=active]:before:content-['>_'] data-[state=active]:before:mr-1 data-[state=active]:before:text-primary data-[state=active]:before:animate-blink",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import { cn } from "../../lib/utils";
interface TerminalWindowProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
variant?: "default" | "primary" | "success" | "warning" | "error";
children: React.ReactNode;
}
const TerminalWindow = React.forwardRef<HTMLDivElement, TerminalWindowProps>(
({ className, children, title, variant = "default", ...props }, ref) => {
const variantClasses = {
default: "border-terminal-border",
primary: "border-primary",
success: "border-success-DEFAULT",
warning: "border-warning-DEFAULT",
error: "border-destructive",
};
return (
<div
className={cn(
"terminal-window",
variantClasses[variant],
className
)}
ref={ref}
{...props}
>
{/* Terminal header */}
<div className="terminal-header">
<div className="flex items-center space-x-1">
<div className="h-3 w-3 rounded-full bg-destructive/80"></div>
<div className="h-3 w-3 rounded-full bg-warning/80"></div>
<div className="h-3 w-3 rounded-full bg-success/80"></div>
</div>
{title && (
<div className="text-xs font-mono text-muted-foreground truncate">
{title}
</div>
)}
<div className="w-4"></div> {/* Spacer for alignment */}
</div>
{/* Terminal content */}
<div className="terminal-content">
{children}
<div className="scan-line" />
</div>
</div>
);
}
);
TerminalWindow.displayName = "TerminalWindow";
export { TerminalWindow };

View File

@ -0,0 +1,28 @@
"use client";
import { toast as sonnerToast } from "sonner";
type ToastProps = {
title?: string;
message: string;
type?: "default" | "success" | "error" | "warning";
};
export function toast({ title, message, type = "default" }: ToastProps) {
const displayTitle = title || (
type === "success" ? "Operation Successful" :
type === "error" ? "Error Detected" :
type === "warning" ? "Warning" :
"System Notification"
);
if (type === "success") {
return sonnerToast.success(displayTitle, { description: message });
} else if (type === "error") {
return sonnerToast.error(displayTitle, { description: message });
} else if (type === "warning") {
return sonnerToast.warning(displayTitle, { description: message });
} else {
return sonnerToast(displayTitle, { description: message });
}
}

View File

@ -0,0 +1,31 @@
"use client";
import { Toaster as Sonner } from "sonner";
export function Toaster() {
return (
<Sonner
theme="dark"
position="top-right"
closeButton
richColors
className="font-mono"
toastOptions={{
classNames: {
toast:
"terminal-window group terminal-content border-terminal-border bg-terminal-background text-terminal-text text-sm relative",
title: "text-terminal-text font-mono",
description: "text-terminal-text/80 font-mono text-xs",
actionButton:
"bg-primary text-primary-foreground shadow-glow-sm shadow-primary hover:shadow-glow hover:shadow-primary border border-primary/30",
cancelButton:
"bg-muted text-muted-foreground border border-muted hover:bg-muted/80",
success:
"group-[]:border-success-DEFAULT group-[]:text-success-DEFAULT",
error: "group-[]:border-destructive group-[]:text-destructive-DEFAULT",
warning: "group-[]:border-warning-DEFAULT group-[]:text-warning-DEFAULT",
},
}}
/>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const toggleGroupVariants = cva(
"inline-flex bg-terminal-background border border-terminal-border rounded-md p-1 font-mono text-sm",
{
variants: {
variant: {
default: "bg-terminal-background",
outline: "bg-background",
},
},
defaultVariants: {
variant: "default",
},
}
);
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleGroupVariants>
>(({ className, variant, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(toggleGroupVariants({ variant }), className)}
{...props}
/>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const toggleGroupItemVariants = cva(
"inline-flex items-center justify-center px-3 py-2 rounded transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"data-[state=on]:bg-primary/20 data-[state=on]:text-primary data-[state=on]:shadow-[0_0_5px] data-[state=on]:shadow-primary hover:text-primary/80",
outline:
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-muted hover:text-primary",
},
},
defaultVariants: {
variant: "default",
},
}
);
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, variant, ...props }, ref) => (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(toggleGroupItemVariants({ variant }), className)}
{...props}
/>
));
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-terminal-background px-3 py-1.5 text-xs text-primary animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border border-terminal-border",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

98
src/content/index.ts Normal file
View File

@ -0,0 +1,98 @@
export type ContentSegment = {
type: 'text' | 'link';
content: string;
url?: string;
};
export type Paragraph = ContentSegment[];
// Content for RLN Membership Registration
export const membershipRegistration = {
title: "RLN Membership Registration",
aboutTitle: "About RLN Membership on Linea Sepolia",
about: [
[
{ type: 'text', content: 'RLN (' },
{ type: 'link', content: 'Rate Limiting Nullifier', url: 'https://github.com/Rate-Limiting-Nullifier' },
{ type: 'text', content: ') membership allows you to participate in ' },
{ type: 'link', content: 'Waku RLN Relay', url: 'https://blog.waku.org/explanation-series-rln-relay/' },
{ type: 'text', content: ' with rate limiting protection, without exposing your private keys on your node.' }
],
[
{ type: 'text', content: 'This application is configured to use the ' },
{ type: 'link', content: 'Linea Sepolia', url: 'https://sepolia.lineascan.build/address/0xb9cd878c90e49f797b4431fbf4fb333108cb90e6' },
{ type: 'text', content: ' testnet for RLN registrations.' }
],
[
{ type: 'text', content: 'When you register, your wallet will sign a message that will be used to generate a ' },
{ type: 'link', content: 'cryptographic identity', url: 'https://github.com/waku-org/specs/blob/master/standards/application/rln-keystore.md' },
{ type: 'text', content: ' for your membership. This allows your node to prove it has permission to send messages without revealing your identity.' }
]
] as Paragraph[],
infoHeader: "RLN Membership Info",
connectWalletPrompt: "Please connect your wallet to register a membership",
initializePrompt: "Please initialize RLN before registering a membership",
networkWarning: "You are not connected to Linea Sepolia network. Please switch networks to register.",
form: {
rateLimitLabel: "Rate Limit (messages per epoch)",
saveToKeystoreLabel: "Save credentials to keystore",
passwordLabel: "Keystore Password (min 8 characters)",
passwordPlaceholder: "Enter password to encrypt credentials",
registerButton: "Register Membership",
registeringButton: "Registering..."
}
};
// Content for Keystore Management
export const keystoreManagement = {
title: "Keystore Management",
buttons: {
import: "Import Keystore",
export: "Export Keystore",
view: "View",
decrypt: "Decrypt",
decrypting: "Decrypting...",
remove: "Remove"
},
about: [
[
{ type: 'text', content: 'Keystore management allows you to securely store, import, export and manage your ' },
{ type: 'link', content: 'RLN membership credentials', url: 'https://github.com/waku-org/specs/blob/master/standards/application/rln-keystore.md' },
{ type: 'text', content: '.' }
],
[
{ type: 'text', content: 'Credentials are encrypted with your password and can be used across different devices or applications. Learn more about ' },
{ type: 'link', content: 'keystore security', url: 'https://github.com/waku-org/specs/blob/master/standards/application/rln-keystore.md#security-considerations' },
{ type: 'text', content: '.' }
],
[
{ type: 'text', content: 'You can export your credentials as a file and import them on another device to use the same membership.' }
]
] as Paragraph[],
infoHeader: "About Keystore Management",
noCredentialsWarning: "Please initialize RLN before managing credentials",
storedCredentialsTitle: "Stored Credentials",
passwordPlaceholder: "Enter credential password",
credentialDetailsTitle: "Credential Details",
resources: {
title: "Resources",
links: [
{
name: "RLN Specs",
url: "https://specs.status.im/spec/waku/rln-v1"
},
{
name: "Waku GitHub",
url: "https://github.com/waku-org/waku-rln-contract"
},
{
name: "Keystore Documentation",
url: "https://github.com/waku-org/specs/blob/master/standards/application/rln-keystore.md"
}
]
}
};

View File

@ -0,0 +1,62 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface AppState {
isLoading: boolean;
globalError: string | null;
setIsLoading: (loading: boolean) => void;
setGlobalError: (error: string | null) => void;
activeTab: string;
setActiveTab: (tab: string) => void;
}
const AppStateContext = createContext<AppState | undefined>(undefined);
export function AppStateProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(false);
const [globalError, setGlobalError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('membership');
const value = {
isLoading,
setIsLoading,
globalError,
setGlobalError,
activeTab,
setActiveTab,
};
return (
<AppStateContext.Provider value={value}>
{children}
{isLoading && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 flex items-center space-x-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
<span className="text-gray-900 dark:text-white">Loading...</span>
</div>
</div>
)}
{globalError && (
<div className="fixed bottom-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50 flex items-center">
<span>{globalError}</span>
<button
onClick={() => setGlobalError(null)}
className="ml-3 text-red-700 hover:text-red-900"
>
</button>
</div>
)}
</AppStateContext.Provider>
);
}
export function useAppState() {
const context = useContext(AppStateContext);
if (context === undefined) {
throw new Error('useAppState must be used within an AppStateProvider');
}
return context;
}

3
src/contexts/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { WalletProvider, useWallet } from './wallet';
export { KeystoreProvider, useKeystore } from './keystore';
export { RLNImplementationProvider, useRLNImplementation, type RLNImplementationType, RLNProvider, useRLN } from './rln';

View File

@ -0,0 +1,210 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Keystore, KeystoreEntity } from '@waku/rln';
export const LOCAL_STORAGE_KEYSTORE_KEY = 'waku-rln-keystore';
interface KeystoreContextType {
keystore: Keystore | null;
isInitialized: boolean;
error: string | null;
hasStoredCredentials: boolean;
storedCredentialsHashes: string[];
saveCredentials: (credentials: KeystoreEntity, password: string) => Promise<string>;
exportCredential: (hash: string, password: string) => Promise<Keystore>;
exportEntireKeystore: (password: string) => Promise<void>;
importKeystore: (keystore: Keystore) => boolean;
removeCredential: (hash: string) => void;
getDecryptedCredential: (hash: string, password: string) => Promise<KeystoreEntity | null>;
}
const KeystoreContext = createContext<KeystoreContextType | undefined>(undefined);
export function KeystoreProvider({ children }: { children: ReactNode }) {
const [keystore, setKeystore] = useState<Keystore | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<string | null>(null);
const [storedCredentialsHashes, setStoredCredentialsHashes] = useState<string[]>([]);
// Initialize keystore
useEffect(() => {
try {
const storedKeystore = localStorage.getItem(LOCAL_STORAGE_KEYSTORE_KEY);
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(LOCAL_STORAGE_KEYSTORE_KEY, keystore.toString());
} catch (err) {
console.warn("Could not save keystore to localStorage:", err);
}
}
}, [keystore, isInitialized]);
const saveCredentials = async (credentials: KeystoreEntity, password: string): Promise<string> => {
if (!keystore) {
throw new Error("Keystore not initialized");
}
try {
const hash = await keystore.addCredential(credentials, password);
localStorage.setItem(LOCAL_STORAGE_KEYSTORE_KEY, keystore.toString());
setStoredCredentialsHashes(keystore.keys());
return hash;
} catch (err) {
console.error("Error saving credentials:", err);
throw err;
}
};
const getDecryptedCredential = async (hash: string, password: string): Promise<KeystoreEntity | null> => {
if (!keystore) {
throw new Error("Keystore not initialized");
}
try {
// Get the credential from the keystore
const credential = await keystore.readCredential(hash, password);
return credential || null;
} catch (err) {
console.error("Error reading credential:", err);
throw err;
}
};
const exportCredential = async (hash: string, password: string): Promise<Keystore> => {
if (!keystore) {
throw new Error("Keystore not initialized");
}
// Create a new keystore instance for the single credential
const singleCredentialKeystore = Keystore.create();
// Get the credential from the main keystore
const credential = await keystore.readCredential(hash, password);
if (!credential) {
throw new Error("Credential not found");
}
// Add the credential to the new keystore
await singleCredentialKeystore.addCredential(credential, password);
console.log("Single credential keystore:", singleCredentialKeystore.toString());
return singleCredentialKeystore;
};
const exportEntireKeystore = async (password: string): Promise<void> => {
if (!keystore) {
throw new Error("Keystore not initialized");
}
if (storedCredentialsHashes.length === 0) {
throw new Error("No credentials to export");
}
try {
// Verify the password works for at least one credential
const firstHash = storedCredentialsHashes[0];
const testCredential = await keystore.readCredential(firstHash, password);
if (!testCredential) {
throw new Error("Invalid password");
}
// If password is verified, export the entire keystore
const filename = 'waku-rln-keystore.json';
const blob = new Blob([keystore.toString()], { 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);
} catch (err) {
console.error("Error exporting keystore:", err);
throw err;
}
};
const importKeystore = (keystore: Keystore): boolean => {
try {
setKeystore(keystore);
setStoredCredentialsHashes(keystore.keys());
localStorage.setItem(LOCAL_STORAGE_KEYSTORE_KEY, keystore.toString());
return true;
} 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(LOCAL_STORAGE_KEYSTORE_KEY, keystore.toString());
};
const contextValue: KeystoreContextType = {
keystore,
isInitialized,
error,
hasStoredCredentials: storedCredentialsHashes.length > 0,
storedCredentialsHashes,
saveCredentials,
exportCredential,
exportEntireKeystore,
importKeystore,
removeCredential,
getDecryptedCredential
};
return (
<KeystoreContext.Provider value={contextValue}>
{children}
</KeystoreContext.Provider>
);
}
export function useKeystore() {
const context = useContext(KeystoreContext);
if (context === undefined) {
throw new Error('useKeystore must be used within a KeystoreProvider');
}
return context;
}

View File

@ -0,0 +1 @@
export { KeystoreProvider, useKeystore } from './KeystoreContext';

View File

@ -0,0 +1,379 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { KeystoreEntity, RLNCredentialsManager } from '@waku/rln';
import { createRLNImplementation } from './implementations';
import { useRLNImplementation } from './RLNImplementationContext';
import { ethers } from 'ethers';
import { useKeystore } from '../keystore';
import { ERC20_ABI, LINEA_SEPOLIA_CONFIG, ensureLineaSepoliaNetwork } from '../../utils/network';
interface RLNContextType {
rln: RLNCredentialsManager | null;
isInitialized: boolean;
isStarted: boolean;
error: string | null;
initializeRLN: () => Promise<void>;
registerMembership: (rateLimit: number, saveOptions?: { password: string }) => Promise<{
success: boolean;
error?: string;
credentials?: KeystoreEntity;
keystoreHash?: string;
}>;
rateMinLimit: number;
rateMaxLimit: number;
getCurrentRateLimit: () => Promise<number | null>;
getRateLimitsBounds: () => Promise<{ success: boolean; rateMinLimit: number; rateMaxLimit: number; error?: string }>;
saveCredentialsToKeystore: (credentials: KeystoreEntity, password: string) => Promise<string>;
isLoading: boolean;
}
const RLNContext = createContext<RLNContextType | undefined>(undefined);
export function RLNProvider({ children }: { children: ReactNode }) {
const { implementation } = useRLNImplementation();
const [rln, setRln] = useState<RLNCredentialsManager | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [isStarted, setIsStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Get the signer from window.ethereum
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [rateMinLimit, setRateMinLimit] = useState<number>(0);
const [rateMaxLimit, setRateMaxLimit] = useState<number>(0);
const { saveCredentials: saveToKeystore } = useKeystore();
// Listen for wallet connection
useEffect(() => {
const checkWallet = async () => {
try {
if (window.ethereum) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const accounts = await provider.listAccounts();
if (accounts.length > 0) {
const signer = provider.getSigner();
setSigner(signer);
setIsConnected(true);
return;
}
}
setSigner(null);
setIsConnected(false);
} catch (err) {
console.error("Error checking wallet:", err);
setSigner(null);
setIsConnected(false);
}
};
checkWallet();
// Listen for account changes
if (window.ethereum) {
window.ethereum.on('accountsChanged', checkWallet);
window.ethereum.on('chainChanged', checkWallet);
}
return () => {
if (window.ethereum) {
window.ethereum.removeListener('accountsChanged', checkWallet);
window.ethereum.removeListener('chainChanged', checkWallet);
}
};
}, []);
// Reset RLN state when implementation changes
useEffect(() => {
setRln(null);
setIsInitialized(false);
setIsStarted(false);
setError(null);
}, [implementation]);
const initializeRLN = useCallback(async () => {
console.log("InitializeRLN called. Connected:", isConnected, "Signer available:", !!signer);
try {
setError(null);
setIsLoading(true);
if (!rln) {
console.log(`Creating RLN ${implementation} instance...`);
try {
const rlnInstance = await createRLNImplementation(implementation);
console.log("RLN instance created successfully:", !!rlnInstance);
setRln(rlnInstance);
setIsInitialized(true);
console.log("isInitialized set to true");
} catch (createErr) {
console.error("Error creating RLN instance:", createErr);
throw createErr;
}
} else {
console.log("RLN instance already exists, skipping creation");
}
if (isConnected && signer && rln && !isStarted) {
console.log("Starting RLN with signer...");
try {
await rln.start({ signer });
setIsStarted(true);
console.log("RLN started successfully, isStarted set to true");
try {
const minLimit = await rln.contract?.getMinRateLimit();
const maxLimit = await rln.contract?.getMaxRateLimit();
if (minLimit !== undefined && maxLimit !== undefined) {
setRateMinLimit(minLimit);
setRateMaxLimit(maxLimit);
console.log("Rate limits fetched:", { min: minLimit, max: maxLimit });
} else {
throw new Error("Rate limits not available");
}
} catch (limitErr) {
console.warn("Could not fetch rate limits:", limitErr);
}
} catch (startErr) {
console.error("Error starting RLN:", startErr);
throw startErr;
}
} else {
console.log("Skipping RLN start because:", {
isConnected,
hasSigner: !!signer,
hasRln: !!rln,
isAlreadyStarted: isStarted
});
}
} catch (err) {
console.error('Error in initializeRLN:', err);
setError(err instanceof Error ? err.message : 'Failed to initialize RLN');
} finally {
setIsLoading(false);
}
}, [isConnected, signer, implementation, rln, isStarted]);
// Auto-initialize effect for Light implementation
useEffect(() => {
if (implementation === 'light' && isConnected && signer && !isInitialized && !isStarted && !isLoading) {
console.log('Auto-initializing Light RLN implementation...');
initializeRLN();
}
}, [implementation, isConnected, signer, isInitialized, isStarted, isLoading, initializeRLN]);
const getCurrentRateLimit = async (): Promise<number | null> => {
try {
if (!rln || !rln.contract || !isStarted) {
console.log("Cannot get rate limit: RLN not initialized or started");
return null;
}
const rateLimit = rln.contract.getRateLimit();
console.log("Current rate limit:", rateLimit);
return rateLimit;
} catch (err) {
console.error("Error getting current rate limit:", err);
return null;
}
};
const getRateLimitsBounds = async () => {
try {
if (!rln || !isStarted) {
return {
success: false,
rateMinLimit: 0,
rateMaxLimit: 0,
error: 'RLN not initialized or not started'
};
}
const minLimit = await rln.contract?.getMinRateLimit();
const maxLimit = await rln.contract?.getMaxRateLimit();
if (minLimit !== undefined && maxLimit !== undefined) {
// Update state
setRateMinLimit(minLimit);
setRateMaxLimit(maxLimit);
} else {
throw new Error("Rate limits not available");
}
return {
success: true,
rateMinLimit: minLimit,
rateMaxLimit: maxLimit
};
} catch (err) {
return {
success: false,
rateMinLimit: rateMinLimit,
rateMaxLimit: rateMaxLimit,
error: err instanceof Error ? err.message : 'Failed to get rate limits'
};
}
};
const saveCredentialsToKeystore = async (credentials: KeystoreEntity, password: string): Promise<string> => {
try {
return await saveToKeystore(credentials, password);
} catch (err) {
console.error("Error saving credentials to keystore:", err);
throw err;
}
};
const registerMembership = async (rateLimit: number, saveOptions?: { password: string }) => {
console.log("registerMembership called with rate limit:", rateLimit);
if (!rln || !isStarted) {
return { success: false, error: 'RLN not initialized or not started' };
}
if (!signer) {
return { success: false, error: 'No signer available' };
}
try {
// Validate rate limit
if (rateLimit < rateMinLimit || rateLimit > rateMaxLimit) {
return {
success: false,
error: `Rate limit must be between ${rateMinLimit} and ${rateMaxLimit}`
};
}
await rln.contract?.setRateLimit(rateLimit);
// Ensure we're on the correct network
const isOnLineaSepolia = await ensureLineaSepoliaNetwork(signer);
if (!isOnLineaSepolia) {
console.warn("Could not switch to Linea Sepolia network. Registration may fail.");
}
// Get user address and contract address
const userAddress = await signer.getAddress();
if (!rln.contract || !rln.contract.address) {
return { success: false, error: "RLN contract address not available. Cannot proceed with registration." };
}
const contractAddress = rln.contract.address;
const tokenAddress = LINEA_SEPOLIA_CONFIG.tokenAddress;
// Create token contract instance
const tokenContract = new ethers.Contract(
tokenAddress,
ERC20_ABI,
signer
);
// Check token balance
const tokenBalance = await tokenContract.balanceOf(userAddress);
if (tokenBalance.isZero()) {
return { success: false, error: "You need tokens to register a membership. Your token balance is zero." };
}
// Check and approve token allowance if needed
const currentAllowance = await tokenContract.allowance(userAddress, contractAddress);
if (currentAllowance.eq(0)) {
console.log("Requesting token approval...");
// Approve a large amount (max uint256)
const maxUint256 = ethers.constants.MaxUint256;
try {
const approveTx = await tokenContract.approve(contractAddress, maxUint256);
console.log("Approval transaction submitted:", approveTx.hash);
// Wait for the transaction to be mined
await approveTx.wait(1);
console.log("Token approval confirmed");
} catch (approvalErr) {
console.error("Error during token approval:", approvalErr);
return {
success: false,
error: `Failed to approve token: ${approvalErr instanceof Error ? approvalErr.message : String(approvalErr)}`
};
}
} else {
console.log("Token allowance already sufficient");
}
// Generate signature for identity
const timestamp = Date.now();
const message = `Sign this message to generate your RLN credentials ${timestamp}`;
const signature = await signer.signMessage(message);
// Register membership
console.log("Registering membership...");
const credentials = await rln.registerMembership({
signature: signature
});
console.log("Credentials:", credentials);
// If we have save options, save to keystore
let keystoreHash: string | undefined;
if (saveOptions && saveOptions.password && credentials) {
try {
const credentialsEntity = credentials as KeystoreEntity;
keystoreHash = await saveCredentialsToKeystore(credentialsEntity, saveOptions.password);
console.log("Credentials saved to keystore with hash:", keystoreHash);
} catch (saveErr) {
console.error("Error saving credentials to keystore:", saveErr);
// Continue without failing the overall registration
}
}
return {
success: true,
credentials: credentials as KeystoreEntity,
keystoreHash
};
} catch (err) {
console.error("Error registering membership:", err);
let errorMsg = "Failed to register membership";
if (err instanceof Error) {
errorMsg = err.message;
}
return { success: false, error: errorMsg };
}
};
return (
<RLNContext.Provider
value={{
rln,
isInitialized,
isStarted,
error,
initializeRLN,
registerMembership,
rateMinLimit,
rateMaxLimit,
getCurrentRateLimit,
getRateLimitsBounds,
saveCredentialsToKeystore: saveToKeystore,
isLoading
}}
>
{children}
</RLNContext.Provider>
);
}
export function useRLN() {
const context = useContext(RLNContext);
if (context === undefined) {
throw new Error('useRLN must be used within a RLNProvider');
}
return context;
}

View File

@ -0,0 +1,30 @@
"use client";
import { createContext, useContext, useState, ReactNode } from 'react';
export type RLNImplementationType = 'standard' | 'light';
interface RLNImplementationContextType {
implementation: RLNImplementationType;
setImplementation: (implementation: RLNImplementationType) => void;
}
const RLNImplementationContext = createContext<RLNImplementationContextType | undefined>(undefined);
export function RLNImplementationProvider({ children }: { children: ReactNode }) {
const [implementation, setImplementation] = useState<RLNImplementationType>('light');
return (
<RLNImplementationContext.Provider value={{ implementation, setImplementation }}>
{children}
</RLNImplementationContext.Provider>
);
}
export function useRLNImplementation() {
const context = useContext(RLNImplementationContext);
if (context === undefined) {
throw new Error('useRLNImplementation must be used within a RLNImplementationProvider');
}
return context;
}

View File

@ -0,0 +1,11 @@
"use client";
import { createRLN, RLNCredentialsManager } from '@waku/rln';
export async function createRLNImplementation(type: 'standard' | 'light' = 'light') {
if (type === 'standard') {
return await createRLN();
} else {
return new RLNCredentialsManager;
}
}

View File

@ -0,0 +1,3 @@
export { RLNProvider as StandardRLNProvider, useRLN as useStandardRLN } from './standard';
export { RLNProvider as LightRLNProvider, useRLN as useLightRLN } from './light';
export { createRLNImplementation } from './factory';

View File

@ -0,0 +1,218 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { RLNCredentialsManager } from '@waku/rln';
import { ethers } from 'ethers';
import { ensureLineaSepoliaNetwork, ERC20_ABI, SIGNATURE_MESSAGE } from '../../../utils/network';
import { useWallet } from '@/contexts';
import { RLNContextType } from './types';
const RLNContext = createContext<RLNContextType | undefined>(undefined);
export function RLNProvider({ children }: { children: ReactNode }) {
const { isConnected, signer } = useWallet();
const [rln, setRln] = useState<RLNCredentialsManager | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [isStarted, setIsStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rateMinLimit, setRateMinLimit] = useState(0);
const [rateMaxLimit, setRateMaxLimit] = useState(0);
const initializeRLN = useCallback(async () => {
console.log("InitializeRLN called. Connected:", isConnected, "Signer available:", !!signer);
try {
setError(null);
if (!rln) {
console.log("Creating RLN instance...");
try {
const rlnInstance = new RLNCredentialsManager();
console.log("RLN instance created successfully:", !!rlnInstance);
setRln(rlnInstance);
setIsInitialized(true);
console.log("isInitialized set to true");
} catch (createErr) {
console.error("Error creating RLN instance:", createErr);
throw createErr;
}
} else {
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 {
await rln.start({ signer });
setIsStarted(true);
console.log("RLN started successfully, isStarted set to true");
const minRate = await rln.contract?.getMinRateLimit();
const maxRate = await rln.contract?.getMaxRateLimit();
if (!minRate || !maxRate) {
throw new Error("Failed to get rate limits from contract");
}
setRateMinLimit(minRate);
setRateMaxLimit(maxRate);
console.log("Min rate:", minRate);
console.log("Max rate:", maxRate);
} catch (startErr) {
console.error("Error starting RLN:", startErr);
throw startErr;
}
} else {
console.log("Skipping RLN start because:", {
isConnected,
hasSigner: !!signer,
hasRln: !!rln,
isAlreadyStarted: isStarted
});
}
} catch (err) {
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);
if (!rln || !isStarted) {
return { success: false, error: 'RLN not initialized or not started' };
}
if (!signer) {
return { success: false, error: 'No signer available' };
}
try {
// Validate rate limit
if (rateLimit < rateMinLimit || rateLimit > rateMaxLimit) {
return {
success: false,
error: `Rate limit must be between ${rateMinLimit} and ${rateMaxLimit}`
};
}
await rln.contract?.setRateLimit(rateLimit);
// Ensure we're on the correct network
const isOnLineaSepolia = await ensureLineaSepoliaNetwork(signer);
if (!isOnLineaSepolia) {
console.warn("Could not switch to Linea Sepolia network. Registration may fail.");
}
// Get user address and contract address
const userAddress = await signer.getAddress();
if (!rln.contract || !rln.contract.address) {
return { success: false, error: "RLN contract address not available. Cannot proceed with registration." };
}
const contractAddress = rln.contract.address;
const tokenAddress = '0x185A0015aC462a0aECb81beCc0497b649a64B9ea'; // Linea Sepolia token address
// Create token contract instance
const tokenContract = new ethers.Contract(
tokenAddress,
ERC20_ABI,
signer
);
// Check token balance
const tokenBalance = await tokenContract.balanceOf(userAddress);
if (tokenBalance.isZero()) {
return { success: false, error: "You need tokens to register a membership. Your token balance is zero." };
}
// Check and approve token allowance if needed
const currentAllowance = await tokenContract.allowance(userAddress, contractAddress);
if (currentAllowance.eq(0)) {
console.log("Requesting token approval...");
// Approve a large amount (max uint256)
const maxUint256 = ethers.constants.MaxUint256;
try {
const approveTx = await tokenContract.approve(contractAddress, maxUint256);
console.log("Approval transaction submitted:", approveTx.hash);
// Wait for the transaction to be mined
await approveTx.wait(1);
console.log("Token approval confirmed");
} catch (approvalErr) {
console.error("Error during token approval:", approvalErr);
return {
success: false,
error: `Failed to approve token: ${approvalErr instanceof Error ? approvalErr.message : String(approvalErr)}`
};
}
} else {
console.log("Token allowance already sufficient");
}
// Generate signature for identity
const message = `${SIGNATURE_MESSAGE} ${Date.now()}`;
const signature = await signer.signMessage(message);
const _credentials = await rln.registerMembership({signature: signature});
if (!_credentials) {
throw new Error("Failed to register membership: No credentials returned");
}
if (!_credentials.identity) {
throw new Error("Failed to register membership: Missing identity information");
}
if (!_credentials.membership) {
throw new Error("Failed to register membership: Missing membership information");
}
return { success: true, credentials: _credentials };
} catch (err) {
let errorMsg = "Failed to register membership";
if (err instanceof Error) {
errorMsg = err.message;
}
return { success: false, error: errorMsg };
}
};
// Initialize RLN when wallet connects
useEffect(() => {
console.log("Wallet connection state changed:", { isConnected, hasSigner: !!signer });
if (isConnected && signer) {
console.log("Wallet connected, attempting to initialize RLN");
initializeRLN();
}
}, [isConnected, signer, initializeRLN]);
return (
<RLNContext.Provider
value={{
rln,
isInitialized,
isStarted,
error,
initializeRLN,
registerMembership,
rateMinLimit,
rateMaxLimit
}}
>
{children}
</RLNContext.Provider>
);
}
export function useRLN() {
const context = useContext(RLNContext);
if (context === undefined) {
throw new Error('useRLN must be used within a RLNProvider');
}
return context;
}

View File

@ -0,0 +1,206 @@
"use client";
import { createContext, useContext, useState, ReactNode } from 'react';
import { createRLN, LINEA_CONTRACT, RLNInstance } from '@waku/rln';
import { ethers } from 'ethers';
import { ensureLineaSepoliaNetwork, ERC20_ABI, SIGNATURE_MESSAGE } from '../../../utils/network';
import { RLNContextType } from './types';
import { useWallet } from '@/contexts';
const RLNContext = createContext<RLNContextType | undefined>(undefined);
export function RLNProvider({ children }: { children: ReactNode }) {
const { isConnected, signer } = useWallet();
const [rln, setRln] = useState<RLNInstance | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [isStarted, setIsStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rateMinLimit, setRateMinLimit] = useState(0);
const [rateMaxLimit, setRateMaxLimit] = useState(0);
const initializeRLN = async () => {
console.log("InitializeRLN called. Connected:", isConnected, "Signer available:", !!signer);
try {
setError(null);
if (!rln) {
console.log("Creating RLN instance...");
try {
const rlnInstance = await createRLN();
console.log("RLN instance created successfully:", !!rlnInstance);
setRln(rlnInstance);
setIsInitialized(true);
console.log("isInitialized set to true");
} catch (createErr) {
console.error("Error creating RLN instance:", createErr);
throw createErr;
}
} else {
console.log("RLN instance already exists, skipping creation");
}
if (isConnected && signer && rln && !isStarted) {
console.log("Starting RLN with signer...");
try {
await rln.start({ signer });
setIsStarted(true);
console.log("RLN started successfully, isStarted set to true");
const minRate = await rln.contract?.getMinRateLimit();
const maxRate = await rln.contract?.getMaxRateLimit();
setRateMinLimit(minRate || 0);
setRateMaxLimit(maxRate || 0);
console.log("Min rate:", minRate);
console.log("Max rate:", maxRate);
} catch (startErr) {
console.error("Error starting RLN:", startErr);
throw startErr;
}
} else {
console.log("Skipping RLN start because:", {
isConnected,
hasSigner: !!signer,
hasRln: !!rln,
isAlreadyStarted: isStarted
});
}
} catch (err) {
console.error('Error in initializeRLN:', err);
setError(err instanceof Error ? err.message : 'Failed to initialize RLN');
}
};
const registerMembership = async (rateLimit: number) => {
console.log("registerMembership called with rate limit:", rateLimit);
if (!rln || !isStarted) {
return { success: false, error: 'RLN not initialized or not started' };
}
if (!signer) {
return { success: false, error: 'No signer available' };
}
try {
// Validate rate limit
if (rateLimit < rateMinLimit || rateLimit > rateMaxLimit) {
return {
success: false,
error: `Rate limit must be between ${rateMinLimit} and ${rateMaxLimit}`
};
}
rln.contract?.setRateLimit(rateLimit);
// Ensure we're on the correct network
const isOnLineaSepolia = await ensureLineaSepoliaNetwork(signer);
if (!isOnLineaSepolia) {
console.warn("Could not switch to Linea Sepolia network. Registration may fail.");
}
// Get user address and contract address
const userAddress = await signer.getAddress();
if (!rln.contract || !rln.contract.address) {
return { success: false, error: "RLN contract address not available. Cannot proceed with registration." };
}
const contractAddress = rln.contract.address;
const tokenAddress = LINEA_CONTRACT.address;
// Create token contract instance
const tokenContract = new ethers.Contract(
tokenAddress,
ERC20_ABI,
signer
);
// Check token balance
const tokenBalance = await tokenContract.balanceOf(userAddress);
if (tokenBalance.isZero()) {
return { success: false, error: "You need tokens to register a membership. Your token balance is zero." };
}
// Check and approve token allowance if needed
const currentAllowance = await tokenContract.allowance(userAddress, contractAddress);
if (currentAllowance.eq(0)) {
console.log("Requesting token approval...");
// Approve a large amount (max uint256)
const maxUint256 = ethers.constants.MaxUint256;
try {
const approveTx = await tokenContract.approve(contractAddress, maxUint256);
console.log("Approval transaction submitted:", approveTx.hash);
// Wait for the transaction to be mined
await approveTx.wait(1);
console.log("Token approval confirmed");
} catch (approvalErr) {
console.error("Error during token approval:", approvalErr);
return {
success: false,
error: `Failed to approve token: ${approvalErr instanceof Error ? approvalErr.message : String(approvalErr)}`
};
}
} else {
console.log("Token allowance already sufficient");
}
// Generate signature for identity
const message = `${SIGNATURE_MESSAGE} ${Date.now()}`;
const signature = await signer.signMessage(message);
const _credentials = await rln.registerMembership({signature: signature});
if (!_credentials) {
throw new Error("Failed to register membership: No credentials returned");
}
if (!_credentials.identity) {
throw new Error("Failed to register membership: Missing identity information");
}
if (!_credentials.membership) {
throw new Error("Failed to register membership: Missing membership information");
}
return { success: true, credentials: _credentials };
} catch (err) {
let errorMsg = "Failed to register membership";
if (err instanceof Error) {
errorMsg = err.message;
}
return { success: false, error: errorMsg };
}
};
return (
<RLNContext.Provider
value={{
rln,
isInitialized,
isStarted,
error,
initializeRLN,
registerMembership,
rateMinLimit,
rateMaxLimit
}}
>
{children}
</RLNContext.Provider>
);
}
export function useRLN() {
const context = useContext(RLNContext);
if (context === undefined) {
throw new Error('useRLN must be used within a RLNProvider');
}
return context;
}

View File

@ -0,0 +1,12 @@
import { DecryptedCredentials, RLNCredentialsManager, RLNInstance } from "@waku/rln";
export interface RLNContextType {
rln: RLNInstance | RLNCredentialsManager | null;
isInitialized: boolean;
isStarted: boolean;
error: string | null;
initializeRLN: () => Promise<void>;
registerMembership: (rateLimit: number) => Promise<{ success: boolean; error?: string; credentials?: DecryptedCredentials }>;
rateMinLimit: number;
rateMaxLimit: number;
}

View File

@ -0,0 +1,2 @@
export { RLNProvider, useRLN } from './RLNContext';
export { RLNImplementationProvider, useRLNImplementation, type RLNImplementationType } from './RLNImplementationContext';

View File

@ -0,0 +1,140 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { extractMetaMaskSigner } from '@waku/rln';
import { ethers } from 'ethers';
import { WalletContextType } from './types';
const WalletContext = createContext<WalletContextType | undefined>(undefined);
export function WalletProvider({ children }: { children: ReactNode }) {
const [isConnected, setIsConnected] = useState(false);
const [address, setAddress] = useState<string | null>(null);
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [balance, setBalance] = useState<string | null>(null);
const [chainId, setChainId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Function to disconnect wallet - defined first to avoid reference issues
const disconnectWallet = useCallback(() => {
setSigner(null);
setAddress(null);
setBalance(null);
setChainId(null);
setIsConnected(false);
// Event listeners are removed in the cleanup function of useEffect
}, []);
// Function to connect wallet
const connectWallet = useCallback(async () => {
try {
setError(null);
const signer = await extractMetaMaskSigner();
setSigner(signer);
const address = await signer.getAddress();
setAddress(address);
const provider = signer.provider as ethers.providers.Web3Provider;
const network = await provider.getNetwork();
setChainId(network.chainId);
const balanceWei = await provider.getBalance(address);
const balanceEth = ethers.utils.formatEther(balanceWei);
setBalance(balanceEth);
setIsConnected(true);
} catch (err) {
console.error('Error connecting wallet:', err);
setError(err instanceof Error ? err.message : 'Failed to connect wallet');
disconnectWallet();
}
}, [disconnectWallet]);
// Handle account changes
const handleAccountsChanged = useCallback((accounts: string[]) => {
if (accounts.length === 0) {
disconnectWallet();
} else if (accounts[0] !== address) {
connectWallet();
}
}, [address, connectWallet, disconnectWallet]);
// Handle chain changes
const handleChainChanged = useCallback(() => {
connectWallet();
}, [connectWallet]);
// Setup and cleanup event listeners
useEffect(() => {
if (window.ethereum && isConnected) {
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged as (chainId: string) => void);
}
return () => {
if (window.ethereum) {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged as (chainId: string) => void);
}
};
}, [handleAccountsChanged, handleChainChanged, isConnected]);
// Check if wallet was previously connected
useEffect(() => {
const checkConnection = async () => {
try {
// Check if MetaMask is installed
if (!window.ethereum) {
console.log("MetaMask not installed");
return;
}
// Check if already connected
const accounts = await window.ethereum.request({
method: 'eth_accounts'
}) as string[];
if (accounts && accounts.length > 0) {
console.log("Found existing connection, reconnecting...");
connectWallet();
} else {
console.log("No existing connection found");
}
} catch (error) {
console.error("Error checking for existing connection:", error);
}
};
checkConnection();
}, [connectWallet]);
return (
<WalletContext.Provider
value={{
isConnected,
address,
signer,
balance,
chainId,
connectWallet,
disconnectWallet,
error
}}
>
{children}
</WalletContext.Provider>
);
}
export function useWallet() {
const context = useContext(WalletContext);
if (context === undefined) {
throw new Error('useWallet must be used within a WalletProvider');
}
return context;
}

View File

@ -0,0 +1 @@
export { WalletProvider, useWallet } from './WalletContext';

View File

@ -0,0 +1,29 @@
import { ethers } from 'ethers';
export interface WalletContextType {
isConnected: boolean;
address: string | null;
signer: ethers.Signer | null;
balance: string | null;
chainId: number | null;
connectWallet: () => Promise<void>;
disconnectWallet: () => void;
error: string | null;
}
declare global {
interface Window {
ethereum?: {
isMetaMask?: boolean;
isConnected?: boolean;
selectedAddress?: string;
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
on(event: 'accountsChanged', listener: (accounts: string[]) => void): void;
on(event: 'chainChanged', listener: (chainId: string) => void): void;
on(event: string, listener: (...args: unknown[]) => void): void;
removeListener(event: 'accountsChanged', listener: (accounts: string[]) => void): void;
removeListener(event: 'chainChanged', listener: (chainId: string) => void): void;
removeListener(event: string, listener: (...args: unknown[]) => void): void;
};
}
}

17
src/lib/utils.ts Normal file
View File

@ -0,0 +1,17 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Combines multiple class names into a single string, using clsx for conditional classes
* and twMerge to handle Tailwind CSS class conflicts
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Converts HSL color values to CSS variables
*/
export function hslToVar(hsl: string): string {
return `hsl(var(${hsl}))`;
}

80
src/utils/keystore.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* Utility functions for handling keystore file operations
*/
import { Keystore } from "@waku/rln";
/**
* 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 saveKeystoreCredentialToFile = (keystore: Keystore): void => {
let filename: string;
if (keystore.keys.length > 1) {
filename = 'waku-rln-keystore.json';
} else {
const slicedHash = keystore.keys()[0].slice(0,8)
filename = `waku-rln-credential-${slicedHash}.json`;
}
const blob = new Blob([keystore.toString()], { 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 it as a Keystore object
* @returns Promise resolving to a Keystore object
*/
export const readKeystoreFromFile = (): Promise<Keystore> => {
return new Promise<Keystore>((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.onchange = (event: Event) => {
const target = event.target as HTMLInputElement;
const file: File | undefined = target.files?.[0];
if (!file) {
reject(new Error('No file selected'));
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const content = reader.result as string;
const keystore = Keystore.fromString(content);
if (!keystore) {
reject(new Error('Failed to create keystore from file'));
return;
}
resolve(keystore);
} catch (e) {
reject(new Error(`Invalid keystore file format: ${e instanceof Error ? e.message : String(e)}`));
}
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsText(file);
};
input.click();
});
};

68
src/utils/network.ts Normal file
View File

@ -0,0 +1,68 @@
"use client";
import { ethers } from 'ethers';
// Linea Sepolia configuration
export const LINEA_SEPOLIA_CONFIG = {
chainId: 59141,
tokenAddress: '0x185A0015aC462a0aECb81beCc0497b649a64B9ea'
};
// Type for Ethereum provider in window
export interface EthereumProvider {
request: (args: {
method: string;
params?: unknown[]
}) => Promise<unknown>;
}
// Function to ensure the wallet is connected to Linea Sepolia network
export const ensureLineaSepoliaNetwork = async (signer?: ethers.Signer): Promise<boolean> => {
try {
console.log("Current network: unknown", await signer?.getChainId());
// Check if already on Linea Sepolia
if (await signer?.getChainId() === LINEA_SEPOLIA_CONFIG.chainId) {
console.log("Already on Linea Sepolia network");
return true;
}
// If not on Linea Sepolia, try to switch
console.log("Not on Linea Sepolia, attempting to switch...");
// Get the provider from window.ethereum
const provider = window.ethereum as EthereumProvider | undefined;
if (!provider) {
console.warn("No Ethereum provider found");
return false;
}
try {
// Request network switch
await provider.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: `0x${LINEA_SEPOLIA_CONFIG.chainId.toString(16)}` }],
});
console.log("Successfully switched to Linea Sepolia");
return true;
} catch (switchError: unknown) {
console.error("Error switching network:", switchError);
return false;
}
} catch (err) {
console.error("Error checking or switching network:", err);
return false;
}
};
// ERC20 ABI for token operations
export const ERC20_ABI = [
"function allowance(address owner, address spender) view returns (uint256)",
"function approve(address spender, uint256 amount) returns (bool)",
"function balanceOf(address account) view returns (uint256)"
];
// Message for signing to generate identity
export const SIGNATURE_MESSAGE = "Sign this message to generate your RLN credentials";

119
tailwind.config.ts Normal file
View File

@ -0,0 +1,119 @@
import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
import animatePlugin from "tailwindcss-animate";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: ["class"],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
mono: ["var(--font-mono)", ...fontFamily.mono],
},
colors: {
// Cypherpunk inspired color palette
background: "hsl(230, 25%, 7%)", // Deep space background
foreground: "hsl(213, 31%, 91%)",
border: "hsl(230, 15%, 20%)",
input: "hsl(230, 15%, 15%)",
// Base accent colors
primary: {
DEFAULT: "hsl(150, 100%, 54%)", // Neon green
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(150, 100%, 54%, 0.2)",
},
secondary: {
DEFAULT: "hsl(280, 100%, 60%)", // Electric purple
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(280, 100%, 60%, 0.2)",
},
accent: {
DEFAULT: "hsl(190, 100%, 50%)", // Digital blue
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(190, 100%, 50%, 0.2)",
},
// Status colors
success: {
DEFAULT: "hsl(142, 76%, 50%)",
muted: "hsla(142, 76%, 50%, 0.2)",
},
warning: {
DEFAULT: "hsl(38, 100%, 50%)",
muted: "hsla(38, 100%, 50%, 0.2)",
},
destructive: {
DEFAULT: "hsl(0, 100%, 55%)",
muted: "hsla(0, 100%, 55%, 0.2)",
foreground: "hsl(210, 40%, 98%)",
},
// UI component colors
muted: {
DEFAULT: "hsl(230, 15%, 25%)",
foreground: "hsl(213, 20%, 65%)",
},
card: {
DEFAULT: "hsl(229, 20%, 10%)",
foreground: "hsl(213, 31%, 91%)",
},
popover: {
DEFAULT: "hsl(229, 20%, 10%)",
foreground: "hsl(213, 31%, 91%)",
},
// Specialized elements
terminal: {
background: "hsl(230, 25%, 5%)",
border: "hsl(150, 100%, 54%, 0.3)",
text: "hsl(150, 100%, 54%)",
muted: "hsl(150, 100%, 25%)",
},
},
borderRadius: {
lg: "0.5rem",
md: "calc(0.5rem - 2px)",
sm: "calc(0.5rem - 4px)",
},
boxShadow: {
glow: "0 0 10px var(--tw-shadow-color), 0 0 20px var(--tw-shadow-color)",
"glow-sm": "0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color)",
},
keyframes: {
"scan-line": {
"0%": { transform: "translateY(0%)" },
"100%": { transform: "translateY(100%)" },
},
blink: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0" },
},
"terminal-typing": {
"0%": { width: "0" },
"100%": { width: "100%" },
},
flicker: {
"0%, 100%": { opacity: "1" },
"5%, 25%, 75%, 95%": { opacity: "0.8" },
"10%, 30%, 80%, 90%": { opacity: "0.9" },
"15%, 45%, 65%, 85%": { opacity: "0.85" },
"20%, 40%, 60%, 70%": { opacity: "0.75" },
},
},
animation: {
"scan-line": "scan-line 6s linear infinite",
blink: "blink 1s step-end infinite",
"terminal-typing": "terminal-typing 3s steps(40, end)",
flicker: "flicker 5s linear infinite",
},
},
},
plugins: [animatePlugin],
} satisfies Config;

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}