feat: signatures + time-based key delegation for interactions

This commit is contained in:
Danish Arora 2025-04-24 16:30:50 +05:30
parent 8129c78e1c
commit 150710b4c7
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
19 changed files with 1232 additions and 134 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
.cursorrules
comparison.md
.giga/
README-task-master.md
.cursor
scripts

25
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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 }

View 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>
);
}

View File

@ -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}

View File

@ -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;
};

View File

@ -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;
}
};

View 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);
}
}

View 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
);
}
}

View 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;
}

View 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();
}
}

View 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;
}
}
}

View 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;
}
}

View File

@ -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;
}

View File

@ -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",
}

View File

@ -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
}
/**

View File

@ -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
}