mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
feat: signatures + time-based key delegation for interactions
This commit is contained in:
parent
8129c78e1c
commit
150710b4c7
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,7 @@
|
||||
.cursorrules
|
||||
comparison.md
|
||||
.giga/
|
||||
|
||||
README-task-master.md
|
||||
.cursor
|
||||
scripts
|
||||
|
||||
25
package-lock.json
generated
25
package-lock.json
generated
@ -9,6 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@noble/ed25519": "^2.2.3",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
@ -1374,7 +1376,7 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz",
|
||||
"integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==",
|
||||
@ -1386,6 +1388,27 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ed25519": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.2.3.tgz",
|
||||
"integrity": "sha512-iHV8eI2mRcUmOx159QNrU8vTpQ/Xm70yJ2cTk3Trc86++02usfqFoNl6x0p3JN81ZDS/1gx6xiK0OwrgqCT43g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/secp256k1": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz",
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@noble/ed25519": "^2.2.3",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
|
||||
@ -4,11 +4,21 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useForum } from '@/contexts/ForumContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ShieldCheck, LogOut, Terminal, Wifi, WifiOff, Eye, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { ShieldCheck, LogOut, Terminal, Wifi, WifiOff, Eye, MessageSquare, RefreshCw, Key } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
const Header = () => {
|
||||
const { currentUser, isAuthenticated, verificationStatus, connectWallet, disconnectWallet, verifyOrdinal } = useAuth();
|
||||
const {
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
verificationStatus,
|
||||
connectWallet,
|
||||
disconnectWallet,
|
||||
verifyOrdinal,
|
||||
delegateKey,
|
||||
isDelegationValid,
|
||||
delegationTimeRemaining
|
||||
} = useAuth();
|
||||
const { isNetworkConnected, isRefreshing } = useForum();
|
||||
|
||||
const handleConnect = async () => {
|
||||
@ -23,6 +33,56 @@ const Header = () => {
|
||||
await verifyOrdinal();
|
||||
};
|
||||
|
||||
const handleDelegateKey = async () => {
|
||||
await delegateKey();
|
||||
};
|
||||
|
||||
// Format delegation time remaining for display
|
||||
const formatDelegationTime = () => {
|
||||
if (!isDelegationValid()) return null;
|
||||
|
||||
const timeRemaining = delegationTimeRemaining();
|
||||
const hours = Math.floor(timeRemaining / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((timeRemaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
return `${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
const renderDelegationButton = () => {
|
||||
// Only show delegation button for verified Ordinal owners
|
||||
if (verificationStatus !== 'verified-owner') return null;
|
||||
|
||||
const hasValidDelegation = isDelegationValid();
|
||||
const timeRemaining = formatDelegationTime();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={hasValidDelegation ? "outline" : "default"}
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
onClick={handleDelegateKey}
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
{hasValidDelegation
|
||||
? <span>Key Delegated ({timeRemaining})</span>
|
||||
: <span>Delegate Key</span>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[260px]">
|
||||
{hasValidDelegation ? (
|
||||
<p>You have a delegated browser key active for {timeRemaining}.
|
||||
You won't need to sign messages with your wallet for most actions.</p>
|
||||
) : (
|
||||
<p>Delegate a browser key to avoid signing every action with your wallet.
|
||||
Improves UX by reducing wallet popups for 24 hours.</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAccessBadge = () => {
|
||||
if (verificationStatus === 'unverified') {
|
||||
return (
|
||||
@ -182,6 +242,7 @@ const Header = () => {
|
||||
) : (
|
||||
<>
|
||||
{renderAccessBadge()}
|
||||
{renderDelegationButton()}
|
||||
<span className="hidden md:flex items-center text-sm text-cyber-neutral px-3">
|
||||
{currentUser.address.slice(0, 6)}...{currentUser.address.slice(-4)}
|
||||
</span>
|
||||
|
||||
@ -27,10 +27,17 @@ export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Badge.displayName = "Badge"
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
67
src/components/ui/wallet-dialog.tsx
Normal file
67
src/components/ui/wallet-dialog.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WalletConnectionStatus } from "@/lib/identity/wallets/phantom";
|
||||
|
||||
interface WalletDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConnectPhantom: () => void;
|
||||
onInstallPhantom: () => void;
|
||||
status: WalletConnectionStatus;
|
||||
isAuthenticating: boolean;
|
||||
}
|
||||
|
||||
export function WalletConnectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConnectPhantom,
|
||||
onInstallPhantom,
|
||||
status,
|
||||
isAuthenticating,
|
||||
}: WalletDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md border-neutral-800 bg-black text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Connect Wallet</DialogTitle>
|
||||
<DialogDescription className="text-neutral-400">
|
||||
{status === WalletConnectionStatus.NotDetected
|
||||
? "Phantom wallet not detected. Please install it to continue."
|
||||
: "Choose a wallet connection method to continue"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{status !== WalletConnectionStatus.NotDetected ? (
|
||||
<Button
|
||||
onClick={onConnectPhantom}
|
||||
disabled={isAuthenticating}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700"
|
||||
>
|
||||
{isAuthenticating ? "Connecting..." : "Connect Phantom Wallet"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onInstallPhantom}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700"
|
||||
>
|
||||
Install Phantom Wallet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between text-xs text-neutral-500">
|
||||
<p>Phantom wallet is required to use OpChan's features</p>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { User } from '@/types';
|
||||
import { OrdinalAPI } from '@/lib/identity/ordinal';
|
||||
import { KeyDelegation } from '@/lib/identity/signatures/key-delegation';
|
||||
import { PhantomWalletAdapter } from '@/lib/identity/wallets/phantom';
|
||||
import { MessageSigning } from '@/lib/identity/signatures/message-signing';
|
||||
|
||||
export type VerificationStatus = 'unverified' | 'verified-none' | 'verified-owner' | 'verifying';
|
||||
|
||||
@ -13,6 +16,10 @@ interface AuthContextType {
|
||||
connectWallet: () => Promise<void>;
|
||||
disconnectWallet: () => void;
|
||||
verifyOrdinal: () => Promise<boolean>;
|
||||
delegateKey: () => Promise<boolean>;
|
||||
isDelegationValid: () => boolean;
|
||||
delegationTimeRemaining: () => number;
|
||||
messageSigning: MessageSigning;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
@ -24,6 +31,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { toast } = useToast();
|
||||
const ordinalApi = new OrdinalAPI();
|
||||
|
||||
// Create refs for our services so they persist between renders
|
||||
const phantomWalletRef = useRef(new PhantomWalletAdapter());
|
||||
const keyDelegationRef = useRef(new KeyDelegation());
|
||||
const messageSigningRef = useRef(new MessageSigning(keyDelegationRef.current));
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('opchan-user');
|
||||
if (storedUser) {
|
||||
@ -52,16 +64,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Mock wallet connection for development
|
||||
const connectWallet = async () => {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
//TODO: replace with actual wallet connection
|
||||
const mockAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
|
||||
// Check if Phantom wallet is installed
|
||||
if (!phantomWalletRef.current.isInstalled()) {
|
||||
toast({
|
||||
title: "Wallet Not Found",
|
||||
description: "Please install Phantom wallet to continue.",
|
||||
variant: "destructive",
|
||||
});
|
||||
throw new Error("Phantom wallet not installed");
|
||||
}
|
||||
|
||||
// Connect to wallet
|
||||
const address = await phantomWalletRef.current.connect();
|
||||
|
||||
// Create a new user object
|
||||
const newUser: User = {
|
||||
address: mockAddress,
|
||||
address,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
|
||||
@ -72,10 +93,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
toast({
|
||||
title: "Wallet Connected",
|
||||
description: `Connected with address ${mockAddress.slice(0, 6)}...${mockAddress.slice(-4)}`,
|
||||
description: `Connected with address ${address.slice(0, 6)}...${address.slice(-4)}`,
|
||||
});
|
||||
|
||||
// Prompt the user to verify ordinal ownership and delegate key
|
||||
toast({
|
||||
title: "Action Required",
|
||||
description: "Please verify your Ordinal ownership and delegate a signing key for better UX.",
|
||||
});
|
||||
|
||||
// Don't return the address anymore to match the Promise<void> return type
|
||||
} catch (error) {
|
||||
console.error("Error connecting wallet:", error);
|
||||
toast({
|
||||
@ -90,9 +116,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
const disconnectWallet = () => {
|
||||
// Disconnect from Phantom wallet
|
||||
phantomWalletRef.current.disconnect();
|
||||
|
||||
// Clear user data and delegation
|
||||
setCurrentUser(null);
|
||||
localStorage.removeItem('opchan-user');
|
||||
keyDelegationRef.current.clearDelegation();
|
||||
setVerificationStatus('unverified');
|
||||
|
||||
toast({
|
||||
title: "Disconnected",
|
||||
description: "Your wallet has been disconnected.",
|
||||
@ -136,7 +168,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (hasOperators) {
|
||||
toast({
|
||||
title: "Ordinal Verified",
|
||||
description: "You now have full access to post and interact with the forum.",
|
||||
description: "You now have full access. We recommend delegating a key for better UX.",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
@ -168,6 +200,109 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a key delegation by generating a browser keypair, having the
|
||||
* wallet sign a delegation message, and storing the delegation
|
||||
*/
|
||||
const delegateKey = async (): Promise<boolean> => {
|
||||
if (!currentUser || !currentUser.address) {
|
||||
toast({
|
||||
title: "Not Connected",
|
||||
description: "Please connect your wallet first.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
toast({
|
||||
title: "Delegating Key",
|
||||
description: "Generating a browser keypair for quick signing...",
|
||||
});
|
||||
|
||||
// Generate a browser keypair
|
||||
const keypair = await keyDelegationRef.current.generateKeypair();
|
||||
|
||||
// Calculate expiry time (24 hours from now)
|
||||
const expiryHours = 24;
|
||||
const expiryTimestamp = Date.now() + (expiryHours * 60 * 60 * 1000);
|
||||
|
||||
// Create delegation message
|
||||
const delegationMessage = keyDelegationRef.current.createDelegationMessage(
|
||||
keypair.publicKey,
|
||||
currentUser.address,
|
||||
expiryTimestamp
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Signing Required",
|
||||
description: "Please sign the delegation message with your wallet...",
|
||||
});
|
||||
|
||||
const signature = await phantomWalletRef.current.signMessage(delegationMessage);
|
||||
|
||||
const delegationInfo = keyDelegationRef.current.createDelegation(
|
||||
currentUser.address,
|
||||
signature,
|
||||
keypair.publicKey,
|
||||
keypair.privateKey,
|
||||
expiryHours
|
||||
);
|
||||
|
||||
keyDelegationRef.current.storeDelegation(delegationInfo);
|
||||
|
||||
const updatedUser = {
|
||||
...currentUser,
|
||||
browserPubKey: keypair.publicKey,
|
||||
delegationSignature: signature,
|
||||
delegationExpiry: expiryTimestamp,
|
||||
};
|
||||
|
||||
setCurrentUser(updatedUser);
|
||||
localStorage.setItem('opchan-user', JSON.stringify(updatedUser));
|
||||
|
||||
toast({
|
||||
title: "Key Delegated",
|
||||
description: `You won't need to sign every action for the next ${expiryHours} hours.`,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error delegating key:", error);
|
||||
|
||||
let errorMessage = "Failed to delegate key. Please try again.";
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Delegation Error",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current delegation is valid
|
||||
*/
|
||||
const isDelegationValid = (): boolean => {
|
||||
return keyDelegationRef.current.isDelegationValid();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the time remaining on the current delegation in milliseconds
|
||||
*/
|
||||
const delegationTimeRemaining = (): number => {
|
||||
return keyDelegationRef.current.getDelegationTimeRemaining();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
@ -178,6 +313,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
connectWallet,
|
||||
disconnectWallet,
|
||||
verifyOrdinal,
|
||||
delegateKey,
|
||||
isDelegationValid,
|
||||
delegationTimeRemaining,
|
||||
messageSigning: messageSigningRef.current,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -1,19 +1,45 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Cell, Post, Comment } from '@/types';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import {
|
||||
getDataFromCache,
|
||||
createPost,
|
||||
createComment,
|
||||
vote,
|
||||
createCell,
|
||||
refreshData as refreshNetworkData,
|
||||
initializeNetwork,
|
||||
createCell
|
||||
} from './forum/actions';
|
||||
import {
|
||||
setupPeriodicQueries,
|
||||
monitorNetworkHealth,
|
||||
ForumContextType
|
||||
} from './forum';
|
||||
monitorNetworkHealth,
|
||||
initializeNetwork
|
||||
} from './forum/network';
|
||||
import messageManager from '@/lib/waku';
|
||||
import { transformCell, transformComment, transformPost } from './forum/transformers';
|
||||
|
||||
interface ForumContextType {
|
||||
cells: Cell[];
|
||||
posts: Post[];
|
||||
comments: Comment[];
|
||||
// Granular loading states
|
||||
isInitialLoading: boolean;
|
||||
isPostingCell: boolean;
|
||||
isPostingPost: boolean;
|
||||
isPostingComment: boolean;
|
||||
isVoting: boolean;
|
||||
isRefreshing: boolean;
|
||||
// Network status
|
||||
isNetworkConnected: boolean;
|
||||
error: string | null;
|
||||
getCellById: (id: string) => Cell | undefined;
|
||||
getPostsByCell: (cellId: string) => Post[];
|
||||
getCommentsByPost: (postId: string) => Comment[];
|
||||
createPost: (cellId: string, title: string, content: string) => Promise<Post | null>;
|
||||
createComment: (postId: string, content: string) => Promise<Comment | null>;
|
||||
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
|
||||
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
|
||||
createCell: (name: string, description: string, icon: string) => Promise<Cell | null>;
|
||||
refreshData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ForumContext = createContext<ForumContextType | undefined>(undefined);
|
||||
|
||||
@ -21,35 +47,58 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
const [cells, setCells] = useState<Cell[]>([]);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
|
||||
// Loading states
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [isPostingCell, setIsPostingCell] = useState(false);
|
||||
const [isPostingPost, setIsPostingPost] = useState(false);
|
||||
const [isPostingComment, setIsPostingComment] = useState(false);
|
||||
const [isVoting, setIsVoting] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Network connection status
|
||||
const [isNetworkConnected, setIsNetworkConnected] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { currentUser, isAuthenticated } = useAuth();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Function to update UI state from message cache
|
||||
const { currentUser, isAuthenticated, messageSigning } = useAuth();
|
||||
|
||||
// Transform message cache data to the expected types
|
||||
const updateStateFromCache = () => {
|
||||
const data = getDataFromCache();
|
||||
setCells(data.cells);
|
||||
setPosts(data.posts);
|
||||
setComments(data.comments);
|
||||
// Transform cells
|
||||
setCells(
|
||||
Object.values(messageManager.messageCache.cells).map(cell =>
|
||||
transformCell(cell)
|
||||
)
|
||||
);
|
||||
|
||||
// Transform posts
|
||||
setPosts(
|
||||
Object.values(messageManager.messageCache.posts).map(post =>
|
||||
transformPost(post)
|
||||
)
|
||||
);
|
||||
|
||||
// Transform comments
|
||||
setComments(
|
||||
Object.values(messageManager.messageCache.comments).map(comment =>
|
||||
transformComment(comment)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Function to refresh data from the network
|
||||
|
||||
const handleRefreshData = async () => {
|
||||
setIsRefreshing(true);
|
||||
await refreshNetworkData(isNetworkConnected, toast, updateStateFromCache, setError);
|
||||
setIsRefreshing(false);
|
||||
try {
|
||||
// Manually query the network for updates
|
||||
await messageManager.queryStore();
|
||||
updateStateFromCache();
|
||||
} catch (error) {
|
||||
console.error("Error refreshing data:", error);
|
||||
toast({
|
||||
title: "Refresh Failed",
|
||||
description: "Could not fetch the latest data. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Monitor network connection status
|
||||
@ -89,35 +138,77 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const handleCreatePost = async (cellId: string, title: string, content: string): Promise<Post | null> => {
|
||||
setIsPostingPost(true);
|
||||
const result = await createPost(cellId, title, content, currentUser, isAuthenticated, toast, updateStateFromCache);
|
||||
const result = await createPost(
|
||||
cellId,
|
||||
title,
|
||||
content,
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
toast,
|
||||
updateStateFromCache,
|
||||
messageSigning
|
||||
);
|
||||
setIsPostingPost(false);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleCreateComment = async (postId: string, content: string): Promise<Comment | null> => {
|
||||
setIsPostingComment(true);
|
||||
const result = await createComment(postId, content, currentUser, isAuthenticated, toast, updateStateFromCache);
|
||||
const result = await createComment(
|
||||
postId,
|
||||
content,
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
toast,
|
||||
updateStateFromCache,
|
||||
messageSigning
|
||||
);
|
||||
setIsPostingComment(false);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleVotePost = async (postId: string, isUpvote: boolean): Promise<boolean> => {
|
||||
setIsVoting(true);
|
||||
const result = await vote(postId, isUpvote, currentUser, isAuthenticated, toast, updateStateFromCache);
|
||||
const result = await vote(
|
||||
postId,
|
||||
isUpvote,
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
toast,
|
||||
updateStateFromCache,
|
||||
messageSigning
|
||||
);
|
||||
setIsVoting(false);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleVoteComment = async (commentId: string, isUpvote: boolean): Promise<boolean> => {
|
||||
setIsVoting(true);
|
||||
const result = await vote(commentId, isUpvote, currentUser, isAuthenticated, toast, updateStateFromCache);
|
||||
const result = await vote(
|
||||
commentId,
|
||||
isUpvote,
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
toast,
|
||||
updateStateFromCache,
|
||||
messageSigning
|
||||
);
|
||||
setIsVoting(false);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleCreateCell = async (name: string, description: string, icon: string): Promise<Cell | null> => {
|
||||
setIsPostingCell(true);
|
||||
const result = await createCell(name, description, icon, currentUser, isAuthenticated, toast, updateStateFromCache);
|
||||
const result = await createCell(
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
toast,
|
||||
updateStateFromCache,
|
||||
messageSigning
|
||||
);
|
||||
setIsPostingCell(false);
|
||||
return result;
|
||||
};
|
||||
@ -155,7 +246,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
export const useForum = () => {
|
||||
const context = useContext(ForumContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useForum must be used within a ForumProvider');
|
||||
throw new Error("useForum must be used within a ForumProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@ import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } fr
|
||||
import messageManager from '@/lib/waku';
|
||||
import { Cell, Comment, Post, User } from '@/types';
|
||||
import { transformCell, transformComment, transformPost } from './transformers';
|
||||
import { MessageSigning } from '@/lib/identity/signatures/message-signing';
|
||||
|
||||
type ToastFunction = (props: {
|
||||
title: string;
|
||||
@ -10,7 +11,52 @@ type ToastFunction = (props: {
|
||||
variant?: "default" | "destructive";
|
||||
}) => void;
|
||||
|
||||
// Create a post
|
||||
async function signAndSendMessage<T extends PostMessage | CommentMessage | VoteMessage | CellMessage>(
|
||||
message: T,
|
||||
currentUser: User | null,
|
||||
messageSigning: MessageSigning,
|
||||
toast: ToastFunction
|
||||
): Promise<T | null> {
|
||||
if (!currentUser) {
|
||||
toast({
|
||||
title: "Authentication Required",
|
||||
description: "You need to be authenticated to perform this action.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let signedMessage: T | null = null;
|
||||
|
||||
if (messageSigning) {
|
||||
signedMessage = await messageSigning.signMessage(message);
|
||||
|
||||
if (!signedMessage) {
|
||||
toast({
|
||||
title: "Key Delegation Required",
|
||||
description: "Please delegate a signing key for better UX.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
signedMessage = message;
|
||||
}
|
||||
|
||||
await messageManager.sendMessage(signedMessage);
|
||||
return signedMessage;
|
||||
} catch (error) {
|
||||
console.error("Error signing and sending message:", error);
|
||||
toast({
|
||||
title: "Message Error",
|
||||
description: "Failed to sign and send message. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const createPost = async (
|
||||
cellId: string,
|
||||
title: string,
|
||||
@ -18,7 +64,8 @@ export const createPost = async (
|
||||
currentUser: User | null,
|
||||
isAuthenticated: boolean,
|
||||
toast: ToastFunction,
|
||||
updateStateFromCache: () => void
|
||||
updateStateFromCache: () => void,
|
||||
messageSigning?: MessageSigning
|
||||
): Promise<Post | null> => {
|
||||
if (!isAuthenticated || !currentUser) {
|
||||
toast({
|
||||
@ -47,10 +94,15 @@ export const createPost = async (
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
// Send the message to the network
|
||||
await messageManager.sendMessage(postMessage);
|
||||
const sentMessage = await signAndSendMessage(
|
||||
postMessage,
|
||||
currentUser,
|
||||
messageSigning!,
|
||||
toast
|
||||
);
|
||||
|
||||
if (!sentMessage) return null;
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
@ -58,8 +110,7 @@ export const createPost = async (
|
||||
description: "Your post has been published successfully.",
|
||||
});
|
||||
|
||||
// Return the transformed post
|
||||
return transformPost(postMessage);
|
||||
return transformPost(sentMessage);
|
||||
} catch (error) {
|
||||
console.error("Error creating post:", error);
|
||||
toast({
|
||||
@ -71,14 +122,14 @@ export const createPost = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Create a comment
|
||||
export const createComment = async (
|
||||
postId: string,
|
||||
content: string,
|
||||
currentUser: User | null,
|
||||
isAuthenticated: boolean,
|
||||
toast: ToastFunction,
|
||||
updateStateFromCache: () => void
|
||||
updateStateFromCache: () => void,
|
||||
messageSigning?: MessageSigning
|
||||
): Promise<Comment | null> => {
|
||||
if (!isAuthenticated || !currentUser) {
|
||||
toast({
|
||||
@ -106,10 +157,15 @@ export const createComment = async (
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
// Send the message to the network
|
||||
await messageManager.sendMessage(commentMessage);
|
||||
const sentMessage = await signAndSendMessage(
|
||||
commentMessage,
|
||||
currentUser,
|
||||
messageSigning!,
|
||||
toast
|
||||
);
|
||||
|
||||
if (!sentMessage) return null;
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
@ -117,8 +173,7 @@ export const createComment = async (
|
||||
description: "Your comment has been published.",
|
||||
});
|
||||
|
||||
// Return the transformed comment
|
||||
return transformComment(commentMessage);
|
||||
return transformComment(sentMessage);
|
||||
} catch (error) {
|
||||
console.error("Error creating comment:", error);
|
||||
toast({
|
||||
@ -130,74 +185,15 @@ export const createComment = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Vote on a post or comment
|
||||
export const vote = async (
|
||||
targetId: string,
|
||||
isUpvote: boolean,
|
||||
currentUser: User | null,
|
||||
isAuthenticated: boolean,
|
||||
toast: ToastFunction,
|
||||
updateStateFromCache: () => void
|
||||
): Promise<boolean> => {
|
||||
if (!isAuthenticated || !currentUser) {
|
||||
toast({
|
||||
title: "Authentication Required",
|
||||
description: "You need to verify Ordinal ownership to vote.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const voteType = isUpvote ? "upvote" : "downvote";
|
||||
toast({
|
||||
title: `Sending ${voteType}`,
|
||||
description: "Recording your vote on the network...",
|
||||
});
|
||||
|
||||
const voteId = uuidv4();
|
||||
|
||||
const voteMessage: VoteMessage = {
|
||||
type: MessageType.VOTE,
|
||||
id: voteId,
|
||||
targetId,
|
||||
value: isUpvote ? 1 : -1,
|
||||
timestamp: Date.now(),
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
// Send the vote message to the network
|
||||
await messageManager.sendMessage(voteMessage);
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Vote Recorded",
|
||||
description: `Your ${voteType} has been registered.`,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error voting:", error);
|
||||
toast({
|
||||
title: "Vote Failed",
|
||||
description: "Failed to register your vote. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a cell
|
||||
export const createCell = async (
|
||||
name: string,
|
||||
description: string,
|
||||
name: string,
|
||||
description: string,
|
||||
icon: string,
|
||||
currentUser: User | null,
|
||||
isAuthenticated: boolean,
|
||||
toast: ToastFunction,
|
||||
updateStateFromCache: () => void
|
||||
updateStateFromCache: () => void,
|
||||
messageSigning?: MessageSigning
|
||||
): Promise<Cell | null> => {
|
||||
if (!isAuthenticated || !currentUser) {
|
||||
toast({
|
||||
@ -226,25 +222,94 @@ export const createCell = async (
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
// Send the cell message to the network
|
||||
await messageManager.sendMessage(cellMessage);
|
||||
const sentMessage = await signAndSendMessage(
|
||||
cellMessage,
|
||||
currentUser,
|
||||
messageSigning!,
|
||||
toast
|
||||
);
|
||||
|
||||
if (!sentMessage) return null;
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Cell Created",
|
||||
description: "Your cell has been created successfully.",
|
||||
description: "Your cell has been published.",
|
||||
});
|
||||
|
||||
return transformCell(cellMessage);
|
||||
return transformCell(sentMessage);
|
||||
} catch (error) {
|
||||
console.error("Error creating cell:", error);
|
||||
toast({
|
||||
title: "Cell Creation Failed",
|
||||
title: "Cell Failed",
|
||||
description: "Failed to create cell. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const vote = async (
|
||||
targetId: string,
|
||||
isUpvote: boolean,
|
||||
currentUser: User | null,
|
||||
isAuthenticated: boolean,
|
||||
toast: ToastFunction,
|
||||
updateStateFromCache: () => void,
|
||||
messageSigning?: MessageSigning
|
||||
): Promise<boolean> => {
|
||||
if (!isAuthenticated || !currentUser) {
|
||||
toast({
|
||||
title: "Authentication Required",
|
||||
description: "You need to verify Ordinal ownership to vote.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const voteType = isUpvote ? "upvote" : "downvote";
|
||||
toast({
|
||||
title: `Sending ${voteType}`,
|
||||
description: "Recording your vote on the network...",
|
||||
});
|
||||
|
||||
const voteId = uuidv4();
|
||||
|
||||
const voteMessage: VoteMessage = {
|
||||
type: MessageType.VOTE,
|
||||
id: voteId,
|
||||
targetId,
|
||||
value: isUpvote ? 1 : -1,
|
||||
timestamp: Date.now(),
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
const sentMessage = await signAndSendMessage(
|
||||
voteMessage,
|
||||
currentUser,
|
||||
messageSigning!,
|
||||
toast
|
||||
);
|
||||
|
||||
if (!sentMessage) return false;
|
||||
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Vote Recorded",
|
||||
description: `Your ${voteType} has been registered.`,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error voting:", error);
|
||||
toast({
|
||||
title: "Vote Failed",
|
||||
description: "Failed to register your vote. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
197
src/lib/identity/signatures/key-delegation.ts
Normal file
197
src/lib/identity/signatures/key-delegation.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Key delegation for Bitcoin wallets
|
||||
*
|
||||
* This module handles the creation of browser-based keypairs and
|
||||
* delegation of signing authority from Bitcoin wallets to these keypairs.
|
||||
*/
|
||||
|
||||
import * as ed from '@noble/ed25519';
|
||||
import { bytesToHex, hexToBytes } from '@/lib/utils';
|
||||
import { LOCAL_STORAGE_KEYS } from '@/lib/waku/constants';
|
||||
import { DelegationInfo } from './types';
|
||||
|
||||
|
||||
export class KeyDelegation {
|
||||
private static readonly DEFAULT_EXPIRY_HOURS = 24;
|
||||
private static readonly STORAGE_KEY = LOCAL_STORAGE_KEYS.KEY_DELEGATION;
|
||||
|
||||
/**
|
||||
* Generates a new browser-based keypair for signing messages
|
||||
* @returns Promise with keypair object containing hex-encoded public and private keys
|
||||
*/
|
||||
generateKeypair(): { publicKey: string; privateKey: string } {
|
||||
const privateKey = ed.utils.randomPrivateKey();
|
||||
const privateKeyHex = bytesToHex(privateKey);
|
||||
|
||||
const publicKey = ed.getPublicKey(privateKey);
|
||||
const publicKeyHex = bytesToHex(publicKey);
|
||||
|
||||
return {
|
||||
privateKey: privateKeyHex,
|
||||
publicKey: publicKeyHex
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a delegation message to be signed by the Bitcoin wallet
|
||||
* @param browserPublicKey The browser-generated public key
|
||||
* @param bitcoinAddress The user's Bitcoin address
|
||||
* @param expiryTimestamp When the delegation will expire
|
||||
* @returns The message to be signed
|
||||
*/
|
||||
createDelegationMessage(
|
||||
browserPublicKey: string,
|
||||
bitcoinAddress: string,
|
||||
expiryTimestamp: number
|
||||
): string {
|
||||
return `I, ${bitcoinAddress}, delegate authority to this pubkey: ${browserPublicKey} until ${expiryTimestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a delegation with the specified expiry time in hours
|
||||
* @param bitcoinAddress The Bitcoin wallet address
|
||||
* @param signature The signature from the Bitcoin wallet
|
||||
* @param browserPublicKey The browser public key
|
||||
* @param browserPrivateKey The browser private key
|
||||
* @param expiryHours How many hours the delegation should be valid (default: 24)
|
||||
* @returns The created delegation info
|
||||
*/
|
||||
createDelegation(
|
||||
bitcoinAddress: string,
|
||||
signature: string,
|
||||
browserPublicKey: string,
|
||||
browserPrivateKey: string,
|
||||
expiryHours: number = KeyDelegation.DEFAULT_EXPIRY_HOURS
|
||||
): DelegationInfo {
|
||||
const now = Date.now();
|
||||
const expiryTimestamp = now + (expiryHours * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
signature,
|
||||
expiryTimestamp,
|
||||
browserPublicKey,
|
||||
browserPrivateKey,
|
||||
bitcoinAddress
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores delegation information in local storage
|
||||
* @param delegationInfo The delegation information to store
|
||||
*/
|
||||
storeDelegation(delegationInfo: DelegationInfo): void {
|
||||
localStorage.setItem(KeyDelegation.STORAGE_KEY, JSON.stringify(delegationInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves delegation information from local storage
|
||||
* @returns The stored delegation information or null if not found
|
||||
*/
|
||||
retrieveDelegation(): DelegationInfo | null {
|
||||
const delegationJson = localStorage.getItem(KeyDelegation.STORAGE_KEY);
|
||||
if (!delegationJson) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(delegationJson);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse delegation information', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a delegation is valid (exists and not expired)
|
||||
* @returns boolean indicating if the delegation is valid
|
||||
*/
|
||||
isDelegationValid(): boolean {
|
||||
const delegation = this.retrieveDelegation();
|
||||
if (!delegation) return false;
|
||||
|
||||
const now = Date.now();
|
||||
return now < delegation.expiryTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a message using the browser-generated private key
|
||||
* @param message The message to sign
|
||||
* @returns Promise resolving to the signature as a hex string, or null if no valid delegation
|
||||
*/
|
||||
signMessage(message: string): string | null {
|
||||
const delegation = this.retrieveDelegation();
|
||||
if (!delegation || !this.isDelegationValid()) return null;
|
||||
|
||||
try {
|
||||
const privateKeyBytes = hexToBytes(delegation.browserPrivateKey);
|
||||
const messageBytes = new TextEncoder().encode(message);
|
||||
|
||||
const signature = ed.sign(messageBytes, privateKeyBytes);
|
||||
return bytesToHex(signature);
|
||||
} catch (error) {
|
||||
console.error('Error signing with browser key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a signature made with the browser key
|
||||
* @param message The original message
|
||||
* @param signature The signature to verify (hex string)
|
||||
* @param publicKey The public key to verify against (hex string)
|
||||
* @returns Promise resolving to a boolean indicating if the signature is valid
|
||||
*/
|
||||
verifySignature(
|
||||
message: string,
|
||||
signature: string,
|
||||
publicKey: string
|
||||
): boolean {
|
||||
try {
|
||||
const messageBytes = new TextEncoder().encode(message);
|
||||
const signatureBytes = hexToBytes(signature);
|
||||
const publicKeyBytes = hexToBytes(publicKey);
|
||||
|
||||
return ed.verify(signatureBytes, messageBytes, publicKeyBytes);
|
||||
} catch (error) {
|
||||
console.error('Error verifying signature:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current delegation's Bitcoin address, if available
|
||||
* @returns The Bitcoin address or null if no valid delegation exists
|
||||
*/
|
||||
getDelegatingAddress(): string | null {
|
||||
const delegation = this.retrieveDelegation();
|
||||
if (!delegation || !this.isDelegationValid()) return null;
|
||||
return delegation.bitcoinAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the browser public key from the current delegation
|
||||
* @returns The browser public key or null if no valid delegation exists
|
||||
*/
|
||||
getBrowserPublicKey(): string | null {
|
||||
const delegation = this.retrieveDelegation();
|
||||
if (!delegation) return null;
|
||||
return delegation.browserPublicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the stored delegation
|
||||
*/
|
||||
clearDelegation(): void {
|
||||
localStorage.removeItem(KeyDelegation.STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the time remaining on the current delegation
|
||||
* @returns Time remaining in milliseconds, or 0 if expired/no delegation
|
||||
*/
|
||||
getDelegationTimeRemaining(): number {
|
||||
const delegation = this.retrieveDelegation();
|
||||
if (!delegation) return 0;
|
||||
|
||||
const now = Date.now();
|
||||
return Math.max(0, delegation.expiryTimestamp - now);
|
||||
}
|
||||
}
|
||||
55
src/lib/identity/signatures/message-signing.ts
Normal file
55
src/lib/identity/signatures/message-signing.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { OpchanMessage } from '@/types';
|
||||
import { KeyDelegation } from './key-delegation';
|
||||
|
||||
|
||||
export class MessageSigning {
|
||||
private keyDelegation: KeyDelegation;
|
||||
|
||||
constructor(keyDelegation: KeyDelegation) {
|
||||
this.keyDelegation = keyDelegation;
|
||||
}
|
||||
|
||||
signMessage<T extends OpchanMessage>(message: T): T | null {
|
||||
if (!this.keyDelegation.isDelegationValid()) {
|
||||
console.error('No valid key delegation found. Cannot sign message.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const delegation = this.keyDelegation.retrieveDelegation();
|
||||
if (!delegation) return null;
|
||||
|
||||
const messageToSign = JSON.stringify({
|
||||
...message,
|
||||
signature: undefined,
|
||||
browserPubKey: undefined
|
||||
});
|
||||
|
||||
const signature = this.keyDelegation.signMessage(messageToSign);
|
||||
if (!signature) return null;
|
||||
|
||||
return {
|
||||
...message,
|
||||
signature,
|
||||
browserPubKey: delegation.browserPublicKey
|
||||
};
|
||||
}
|
||||
|
||||
verifyMessage(message: OpchanMessage): boolean {
|
||||
if (!message.signature || !message.browserPubKey) {
|
||||
console.warn('Message is missing signature information');
|
||||
return false;
|
||||
}
|
||||
|
||||
const signedContent = JSON.stringify({
|
||||
...message,
|
||||
signature: undefined,
|
||||
browserPubKey: undefined
|
||||
});
|
||||
|
||||
return this.keyDelegation.verifySignature(
|
||||
signedContent,
|
||||
message.signature,
|
||||
message.browserPubKey
|
||||
);
|
||||
}
|
||||
}
|
||||
10
src/lib/identity/signatures/types.ts
Normal file
10
src/lib/identity/signatures/types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface DelegationSignature {
|
||||
signature: string; // Signature from Bitcoin wallet
|
||||
expiryTimestamp: number; // When this delegation expires
|
||||
browserPublicKey: string; // Browser-generated public key that was delegated to
|
||||
bitcoinAddress: string; // Bitcoin address that signed the delegation
|
||||
}
|
||||
|
||||
export interface DelegationInfo extends DelegationSignature {
|
||||
browserPrivateKey: string;
|
||||
}
|
||||
172
src/lib/identity/wallets/index.ts
Normal file
172
src/lib/identity/wallets/index.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { PhantomWalletAdapter } from './phantom';
|
||||
import { KeyDelegation } from '../signatures/key-delegation';
|
||||
import { DelegationInfo } from '../signatures/types';
|
||||
|
||||
export type WalletType = 'phantom';
|
||||
|
||||
export interface WalletInfo {
|
||||
address: string;
|
||||
type: WalletType;
|
||||
delegated: boolean;
|
||||
delegationExpiry?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing wallet connections and key delegation
|
||||
*/
|
||||
export class WalletService {
|
||||
// Default delegation validity period: 24 hours
|
||||
private static readonly DEFAULT_DELEGATION_PERIOD = 24 * 60 * 60 * 1000;
|
||||
|
||||
private keyDelegation: KeyDelegation;
|
||||
private phantomAdapter: PhantomWalletAdapter;
|
||||
|
||||
constructor() {
|
||||
this.keyDelegation = new KeyDelegation();
|
||||
this.phantomAdapter = new PhantomWalletAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific wallet type is available in the browser
|
||||
*/
|
||||
public isWalletAvailable(type: WalletType): boolean {
|
||||
return this.phantomAdapter.isInstalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a specific wallet type
|
||||
* @param type The wallet type to connect to
|
||||
* @returns Promise resolving to the wallet's address
|
||||
*/
|
||||
public async connectWallet(type: WalletType = 'phantom'): Promise<string> {
|
||||
return await this.phantomAdapter.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the current wallet
|
||||
* @param type The wallet type to disconnect
|
||||
*/
|
||||
public async disconnectWallet(type: WalletType): Promise<void> {
|
||||
this.keyDelegation.clearDelegation(); // Clear any delegation
|
||||
await this.phantomAdapter.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current wallet information from local storage
|
||||
* @returns The current wallet info or null if not connected
|
||||
*/
|
||||
public getWalletInfo(): WalletInfo | null {
|
||||
const userJson = localStorage.getItem('opchan-user');
|
||||
if (!userJson) return null;
|
||||
|
||||
try {
|
||||
const user = JSON.parse(userJson);
|
||||
const delegation = this.keyDelegation.retrieveDelegation();
|
||||
|
||||
return {
|
||||
address: user.address,
|
||||
type: 'phantom',
|
||||
delegated: !!delegation && this.keyDelegation.isDelegationValid(),
|
||||
delegationExpiry: delegation?.expiryTimestamp
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user data', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up key delegation for the connected wallet
|
||||
* @param bitcoinAddress The Bitcoin address to delegate from
|
||||
* @param walletType The wallet type
|
||||
* @param validityPeriod Milliseconds the delegation should be valid for
|
||||
* @returns Promise resolving to the delegation info
|
||||
*/
|
||||
public async setupKeyDelegation(
|
||||
bitcoinAddress: string,
|
||||
walletType: WalletType,
|
||||
validityPeriod: number = WalletService.DEFAULT_DELEGATION_PERIOD
|
||||
): Promise<Omit<DelegationInfo, 'browserPrivateKey'>> {
|
||||
// Generate browser keypair
|
||||
const keypair = this.keyDelegation.generateKeypair();
|
||||
|
||||
// Calculate expiry in hours
|
||||
const expiryHours = validityPeriod / (60 * 60 * 1000);
|
||||
|
||||
// Create delegation message
|
||||
const delegationMessage = this.keyDelegation.createDelegationMessage(
|
||||
keypair.publicKey,
|
||||
bitcoinAddress,
|
||||
Date.now() + validityPeriod
|
||||
);
|
||||
|
||||
// Sign the delegation message with the Bitcoin wallet
|
||||
const signature = await this.phantomAdapter.signMessage(delegationMessage);
|
||||
|
||||
// Create and store the delegation
|
||||
const delegationInfo = this.keyDelegation.createDelegation(
|
||||
bitcoinAddress,
|
||||
signature,
|
||||
keypair.publicKey,
|
||||
keypair.privateKey,
|
||||
expiryHours
|
||||
);
|
||||
|
||||
this.keyDelegation.storeDelegation(delegationInfo);
|
||||
|
||||
// Return delegation info (excluding private key)
|
||||
return {
|
||||
signature,
|
||||
expiryTimestamp: delegationInfo.expiryTimestamp,
|
||||
browserPublicKey: keypair.publicKey,
|
||||
bitcoinAddress
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a message using the delegated browser key
|
||||
* @param message The message to sign
|
||||
* @returns Promise resolving to the signature or null if no valid delegation
|
||||
*/
|
||||
public async signMessage(message: string): Promise<string | null> {
|
||||
return this.keyDelegation.signMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a message signature against a public key
|
||||
* @param message The original message
|
||||
* @param signature The signature to verify
|
||||
* @param publicKey The public key to verify against
|
||||
* @returns Promise resolving to a boolean indicating if the signature is valid
|
||||
*/
|
||||
public async verifySignature(
|
||||
message: string,
|
||||
signature: string,
|
||||
publicKey: string
|
||||
): Promise<boolean> {
|
||||
return this.keyDelegation.verifySignature(message, signature, publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current key delegation is valid
|
||||
* @returns boolean indicating if the delegation is valid
|
||||
*/
|
||||
public isDelegationValid(): boolean {
|
||||
return this.keyDelegation.isDelegationValid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the time remaining on the current delegation
|
||||
* @returns Time remaining in milliseconds, or 0 if expired/no delegation
|
||||
*/
|
||||
public getDelegationTimeRemaining(): number {
|
||||
return this.keyDelegation.getDelegationTimeRemaining();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the stored delegation
|
||||
*/
|
||||
public clearDelegation(): void {
|
||||
this.keyDelegation.clearDelegation();
|
||||
}
|
||||
}
|
||||
138
src/lib/identity/wallets/phantom.ts
Normal file
138
src/lib/identity/wallets/phantom.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { sha512 } from '@noble/hashes/sha2';
|
||||
import * as ed from '@noble/ed25519';
|
||||
import { PhantomBitcoinProvider, PhantomWallet, WalletConnectionStatus } from './types';
|
||||
|
||||
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
|
||||
|
||||
/**
|
||||
* PhantomWalletAdapter provides methods for connecting to and interacting with
|
||||
* the Phantom wallet for Bitcoin operations.
|
||||
*/
|
||||
export class PhantomWalletAdapter {
|
||||
private provider: PhantomWallet | null = null;
|
||||
private btcProvider: PhantomBitcoinProvider | null = null;
|
||||
private connectionStatus: WalletConnectionStatus = WalletConnectionStatus.Disconnected;
|
||||
private currentAccount: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.checkWalletAvailability();
|
||||
}
|
||||
|
||||
|
||||
public getStatus(): WalletConnectionStatus {
|
||||
return this.connectionStatus;
|
||||
}
|
||||
|
||||
public isInstalled(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return !!(window?.phantom?.bitcoin?.isPhantom);
|
||||
}
|
||||
|
||||
async connect(): Promise<string> {
|
||||
this.connectionStatus = WalletConnectionStatus.Connecting;
|
||||
|
||||
try {
|
||||
if (!window?.phantom?.bitcoin) {
|
||||
this.connectionStatus = WalletConnectionStatus.NotDetected;
|
||||
return Promise.reject(new Error('Phantom wallet not detected. Please install Phantom wallet.'));
|
||||
}
|
||||
|
||||
this.provider = window.phantom;
|
||||
this.btcProvider = window.phantom.bitcoin;
|
||||
|
||||
if (this.btcProvider?.connect) {
|
||||
await this.btcProvider.connect();
|
||||
}
|
||||
|
||||
if (this.btcProvider?.requestAccounts) {
|
||||
const btcAccounts = await this.btcProvider.requestAccounts();
|
||||
|
||||
if (!btcAccounts || btcAccounts.length === 0) {
|
||||
this.connectionStatus = WalletConnectionStatus.Disconnected;
|
||||
throw new Error('No accounts found');
|
||||
}
|
||||
|
||||
const ordinalAccount = btcAccounts.find(acc => acc.purpose === 'ordinals');
|
||||
const account = ordinalAccount || btcAccounts[0];
|
||||
|
||||
this.currentAccount = account.address;
|
||||
this.connectionStatus = WalletConnectionStatus.Connected;
|
||||
return account.address;
|
||||
} else {
|
||||
throw new Error('requestAccounts method not available on wallet provider');
|
||||
}
|
||||
} catch (error) {
|
||||
this.connectionStatus = window?.phantom?.bitcoin
|
||||
? WalletConnectionStatus.Disconnected
|
||||
: WalletConnectionStatus.NotDetected;
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.btcProvider && this.btcProvider.disconnect) {
|
||||
try {
|
||||
await this.btcProvider.disconnect();
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting from Phantom wallet:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.provider = null;
|
||||
this.btcProvider = null;
|
||||
this.currentAccount = null;
|
||||
this.connectionStatus = WalletConnectionStatus.Disconnected;
|
||||
}
|
||||
|
||||
async signMessage(message: string): Promise<string> {
|
||||
if (!this.btcProvider) {
|
||||
throw new Error('Wallet is not connected');
|
||||
}
|
||||
|
||||
if (!this.currentAccount) {
|
||||
throw new Error('No active account to sign with');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.btcProvider.signMessage) {
|
||||
throw new Error('signMessage method not available on wallet provider');
|
||||
}
|
||||
|
||||
const messageBytes = new TextEncoder().encode(message);
|
||||
|
||||
const { signature } = await this.btcProvider.signMessage(
|
||||
this.currentAccount,
|
||||
messageBytes
|
||||
);
|
||||
|
||||
if (signature instanceof Uint8Array) {
|
||||
const binString = String.fromCodePoint(...signature);
|
||||
return btoa(binString);
|
||||
}
|
||||
|
||||
return signature as unknown as string;
|
||||
} catch (error) {
|
||||
console.error('Error signing message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private checkWalletAvailability(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
this.connectionStatus = WalletConnectionStatus.NotDetected;
|
||||
return;
|
||||
}
|
||||
|
||||
const isPhantomInstalled = window?.phantom?.bitcoin || window?.phantom;
|
||||
|
||||
if (!isPhantomInstalled) {
|
||||
this.connectionStatus = WalletConnectionStatus.NotDetected;
|
||||
} else {
|
||||
this.connectionStatus = WalletConnectionStatus.Disconnected;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
34
src/lib/identity/wallets/types.ts
Normal file
34
src/lib/identity/wallets/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export enum WalletConnectionStatus {
|
||||
Connected = 'connected',
|
||||
Disconnected = 'disconnected',
|
||||
NotDetected = 'not-detected',
|
||||
Connecting = 'connecting'
|
||||
}
|
||||
|
||||
export interface BtcAccount {
|
||||
address: string;
|
||||
addressType: "p2tr" | "p2wpkh" | "p2sh" | "p2pkh";
|
||||
publicKey: string;
|
||||
purpose: "payment" | "ordinals";
|
||||
}
|
||||
|
||||
export interface PhantomBitcoinProvider {
|
||||
isPhantom?: boolean;
|
||||
signMessage?: (address: string, message: Uint8Array) => Promise<{ signature: Uint8Array }>;
|
||||
connect?: () => Promise<{ publicKey: string }>;
|
||||
disconnect?: () => Promise<void>;
|
||||
on?: (event: string, callback: (arg: unknown) => void) => void;
|
||||
off?: (event: string, callback: (arg: unknown) => void) => void;
|
||||
publicKey?: string;
|
||||
requestAccounts?: () => Promise<BtcAccount[]>;
|
||||
}
|
||||
|
||||
export interface PhantomWallet {
|
||||
bitcoin?: PhantomBitcoinProvider;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
phantom?: PhantomWallet;
|
||||
}
|
||||
}
|
||||
@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@ -24,8 +24,11 @@ export const NETWORK_CONFIG: NetworkConfig = {
|
||||
export const BOOTSTRAP_NODES = {
|
||||
"42": [
|
||||
"/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ",
|
||||
"/dns4/node-01.do-ams3.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb",
|
||||
// "/dns4/waku.fryorcraken.xyz/tcp/8000/wss/p2p/16Uiu2HAmMRvhDHrtiHft1FTUYnn6cVA8AWVrTyLUayJJ3MWpUZDB",
|
||||
// "/dns4/vps-aaa00d52.vps.ovh.ca/tcp/8000/wss/p2p/16Uiu2HAm9PftGgHZwWE3wzdMde4m3kT2eYJFXLZfGoSED3gysofk"
|
||||
// "/dns4/node-01.do-ams3.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb",
|
||||
// "/dns4/vps-aaa00d52.vps.ovh.ca/tcp/8000/wss/p2p/16Uiu2HAm9PftGgHZwWE3wzdMde4m3kT2eYJFXLZfGoSED3gysofk"
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
"KEY_DELEGATION": "opchan-key-delegation",
|
||||
}
|
||||
@ -15,6 +15,8 @@ export interface BaseMessage {
|
||||
type: MessageType;
|
||||
timestamp: number;
|
||||
author: string;
|
||||
signature?: string; // Message signature for verification
|
||||
browserPubKey?: string; // Public key that signed the message
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -7,6 +7,9 @@ export interface User {
|
||||
ordinalOwnership?: boolean | { id: string; details: string };
|
||||
signature?: string;
|
||||
lastChecked?: number;
|
||||
browserPubKey?: string; // Browser-generated public key for key delegation
|
||||
delegationSignature?: string; // Signature from Bitcoin wallet for delegation
|
||||
delegationExpiry?: number; // When the delegation expires
|
||||
}
|
||||
|
||||
export interface Cell {
|
||||
@ -25,6 +28,8 @@ export interface Post {
|
||||
timestamp: number;
|
||||
upvotes: VoteMessage[];
|
||||
downvotes: VoteMessage[];
|
||||
signature?: string; // Message signature
|
||||
browserPubKey?: string; // Public key that signed the message
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
@ -35,4 +40,12 @@ export interface Comment {
|
||||
timestamp: number;
|
||||
upvotes: VoteMessage[];
|
||||
downvotes: VoteMessage[];
|
||||
signature?: string; // Message signature
|
||||
browserPubKey?: string; // Public key that signed the message
|
||||
}
|
||||
|
||||
// Extended message types for verification
|
||||
export interface SignedMessage {
|
||||
signature?: string; // Signature of the message
|
||||
browserPubKey?: string; // Public key that signed the message
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user