mirror of
https://github.com/logos-messaging/rln.waku.org.git
synced 2026-01-02 14:13:09 +00:00
chore: move webapp from lab.waku.org
This commit is contained in:
commit
9b97a2fb03
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
43
README.md
Normal 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
16
eslint.config.mjs
Normal 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
13
next.config.js
Normal 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
7
next.config.ts
Normal 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
9073
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal 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
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/window.svg
Normal 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
177
src/app/globals.css
Normal 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
74
src/app/layout.tsx
Normal 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
15
src/app/page.tsx
Normal 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
100
src/components/Footer.tsx
Normal 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
20
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/KeystoreExporter.tsx
Normal file
87
src/components/KeystoreExporter.tsx
Normal 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
70
src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/RLNImplementationToggle.tsx
Normal file
40
src/components/RLNImplementationToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/RLNinitButton.tsx
Normal file
47
src/components/RLNinitButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
397
src/components/Tabs/KeystoreTab/KeystoreManagement.tsx
Normal file
397
src/components/Tabs/KeystoreTab/KeystoreManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
278
src/components/Tabs/MembershipTab/MembershipRegistration.tsx
Normal file
278
src/components/Tabs/MembershipTab/MembershipRegistration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/WalletDropdown.tsx
Normal file
75
src/components/WalletDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/components/theme-provider.tsx
Normal file
10
src/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
63
src/components/ui/button.tsx
Normal file
63
src/components/ui/button.tsx
Normal 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 };
|
||||
77
src/components/ui/dropdown-menu.tsx
Normal file
77
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
28
src/components/ui/slider.tsx
Normal file
28
src/components/ui/slider.tsx
Normal 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 };
|
||||
59
src/components/ui/tabs.tsx
Normal file
59
src/components/ui/tabs.tsx
Normal 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 };
|
||||
58
src/components/ui/terminal-window.tsx
Normal file
58
src/components/ui/terminal-window.tsx
Normal 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 };
|
||||
28
src/components/ui/toast.tsx
Normal file
28
src/components/ui/toast.tsx
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/components/ui/toaster.tsx
Normal file
31
src/components/ui/toaster.tsx
Normal 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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
src/components/ui/toggle-group.tsx
Normal file
67
src/components/ui/toggle-group.tsx
Normal 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 };
|
||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal 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
98
src/content/index.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
62
src/contexts/AppStateContext.tsx
Normal file
62
src/contexts/AppStateContext.tsx
Normal 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
3
src/contexts/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { WalletProvider, useWallet } from './wallet';
|
||||
export { KeystoreProvider, useKeystore } from './keystore';
|
||||
export { RLNImplementationProvider, useRLNImplementation, type RLNImplementationType, RLNProvider, useRLN } from './rln';
|
||||
210
src/contexts/keystore/KeystoreContext.tsx
Normal file
210
src/contexts/keystore/KeystoreContext.tsx
Normal 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;
|
||||
}
|
||||
1
src/contexts/keystore/index.ts
Normal file
1
src/contexts/keystore/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { KeystoreProvider, useKeystore } from './KeystoreContext';
|
||||
379
src/contexts/rln/RLNContext.tsx
Normal file
379
src/contexts/rln/RLNContext.tsx
Normal 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;
|
||||
}
|
||||
30
src/contexts/rln/RLNImplementationContext.tsx
Normal file
30
src/contexts/rln/RLNImplementationContext.tsx
Normal 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;
|
||||
}
|
||||
11
src/contexts/rln/implementations/factory.tsx
Normal file
11
src/contexts/rln/implementations/factory.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
3
src/contexts/rln/implementations/index.ts
Normal file
3
src/contexts/rln/implementations/index.ts
Normal 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';
|
||||
218
src/contexts/rln/implementations/light.tsx
Normal file
218
src/contexts/rln/implementations/light.tsx
Normal 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;
|
||||
}
|
||||
206
src/contexts/rln/implementations/standard.tsx
Normal file
206
src/contexts/rln/implementations/standard.tsx
Normal 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;
|
||||
}
|
||||
12
src/contexts/rln/implementations/types.ts
Normal file
12
src/contexts/rln/implementations/types.ts
Normal 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;
|
||||
}
|
||||
2
src/contexts/rln/index.ts
Normal file
2
src/contexts/rln/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { RLNProvider, useRLN } from './RLNContext';
|
||||
export { RLNImplementationProvider, useRLNImplementation, type RLNImplementationType } from './RLNImplementationContext';
|
||||
140
src/contexts/wallet/WalletContext.tsx
Normal file
140
src/contexts/wallet/WalletContext.tsx
Normal 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;
|
||||
}
|
||||
|
||||
1
src/contexts/wallet/index.ts
Normal file
1
src/contexts/wallet/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { WalletProvider, useWallet } from './WalletContext';
|
||||
29
src/contexts/wallet/types.ts
Normal file
29
src/contexts/wallet/types.ts
Normal 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
17
src/lib/utils.ts
Normal 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
80
src/utils/keystore.ts
Normal 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
68
src/utils/network.ts
Normal 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
119
tailwind.config.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user