mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-05 22:33:07 +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
|
README-task-master.md
|
||||||
.cursor
|
.cursor
|
||||||
scripts
|
scripts
|
||||||
|
|||||||
25
package-lock.json
generated
25
package-lock.json
generated
@ -9,6 +9,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@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-accordion": "^1.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||||
@ -1374,7 +1376,7 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz",
|
||||||
"integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==",
|
"integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==",
|
||||||
@ -1386,6 +1388,27 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/@noble/secp256k1": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz",
|
||||||
|
|||||||
@ -12,6 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@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-accordion": "^1.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||||
|
|||||||
@ -4,11 +4,21 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { useForum } from '@/contexts/ForumContext';
|
import { useForum } from '@/contexts/ForumContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
const Header = () => {
|
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 { isNetworkConnected, isRefreshing } = useForum();
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
@ -23,6 +33,56 @@ const Header = () => {
|
|||||||
await verifyOrdinal();
|
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 = () => {
|
const renderAccessBadge = () => {
|
||||||
if (verificationStatus === 'unverified') {
|
if (verificationStatus === 'unverified') {
|
||||||
return (
|
return (
|
||||||
@ -182,6 +242,7 @@ const Header = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{renderAccessBadge()}
|
{renderAccessBadge()}
|
||||||
|
{renderDelegationButton()}
|
||||||
<span className="hidden md:flex items-center text-sm text-cyber-neutral px-3">
|
<span className="hidden md:flex items-center text-sm text-cyber-neutral px-3">
|
||||||
{currentUser.address.slice(0, 6)}...{currentUser.address.slice(-4)}
|
{currentUser.address.slice(0, 6)}...{currentUser.address.slice(-4)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -27,10 +27,17 @@ export interface BadgeProps
|
|||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||||
return (
|
({ className, variant, ...props }, ref) => {
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
return (
|
||||||
)
|
<div
|
||||||
}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Badge.displayName = "Badge"
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
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 { useToast } from '@/components/ui/use-toast';
|
||||||
import { User } from '@/types';
|
import { User } from '@/types';
|
||||||
import { OrdinalAPI } from '@/lib/identity/ordinal';
|
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';
|
export type VerificationStatus = 'unverified' | 'verified-none' | 'verified-owner' | 'verifying';
|
||||||
|
|
||||||
@ -13,6 +16,10 @@ interface AuthContextType {
|
|||||||
connectWallet: () => Promise<void>;
|
connectWallet: () => Promise<void>;
|
||||||
disconnectWallet: () => void;
|
disconnectWallet: () => void;
|
||||||
verifyOrdinal: () => Promise<boolean>;
|
verifyOrdinal: () => Promise<boolean>;
|
||||||
|
delegateKey: () => Promise<boolean>;
|
||||||
|
isDelegationValid: () => boolean;
|
||||||
|
delegationTimeRemaining: () => number;
|
||||||
|
messageSigning: MessageSigning;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
@ -24,6 +31,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const ordinalApi = new OrdinalAPI();
|
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(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem('opchan-user');
|
const storedUser = localStorage.getItem('opchan-user');
|
||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
@ -52,16 +64,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mock wallet connection for development
|
|
||||||
const connectWallet = async () => {
|
const connectWallet = async () => {
|
||||||
setIsAuthenticating(true);
|
setIsAuthenticating(true);
|
||||||
try {
|
try {
|
||||||
//TODO: replace with actual wallet connection
|
// Check if Phantom wallet is installed
|
||||||
const mockAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
|
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
|
// Create a new user object
|
||||||
const newUser: User = {
|
const newUser: User = {
|
||||||
address: mockAddress,
|
address,
|
||||||
lastChecked: Date.now(),
|
lastChecked: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,10 +93,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Wallet Connected",
|
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) {
|
} catch (error) {
|
||||||
console.error("Error connecting wallet:", error);
|
console.error("Error connecting wallet:", error);
|
||||||
toast({
|
toast({
|
||||||
@ -90,9 +116,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const disconnectWallet = () => {
|
const disconnectWallet = () => {
|
||||||
|
// Disconnect from Phantom wallet
|
||||||
|
phantomWalletRef.current.disconnect();
|
||||||
|
|
||||||
|
// Clear user data and delegation
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
localStorage.removeItem('opchan-user');
|
localStorage.removeItem('opchan-user');
|
||||||
|
keyDelegationRef.current.clearDelegation();
|
||||||
setVerificationStatus('unverified');
|
setVerificationStatus('unverified');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Disconnected",
|
title: "Disconnected",
|
||||||
description: "Your wallet has been disconnected.",
|
description: "Your wallet has been disconnected.",
|
||||||
@ -136,7 +168,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (hasOperators) {
|
if (hasOperators) {
|
||||||
toast({
|
toast({
|
||||||
title: "Ordinal Verified",
|
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 {
|
} else {
|
||||||
toast({
|
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 (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@ -178,6 +313,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
connectWallet,
|
connectWallet,
|
||||||
disconnectWallet,
|
disconnectWallet,
|
||||||
verifyOrdinal,
|
verifyOrdinal,
|
||||||
|
delegateKey,
|
||||||
|
isDelegationValid,
|
||||||
|
delegationTimeRemaining,
|
||||||
|
messageSigning: messageSigningRef.current,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,19 +1,45 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { Cell, Post, Comment } from '@/types';
|
import { Cell, Post, Comment } from '@/types';
|
||||||
import { useAuth } from './AuthContext';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import {
|
import {
|
||||||
getDataFromCache,
|
|
||||||
createPost,
|
createPost,
|
||||||
createComment,
|
createComment,
|
||||||
vote,
|
vote,
|
||||||
createCell,
|
createCell
|
||||||
refreshData as refreshNetworkData,
|
} from './forum/actions';
|
||||||
initializeNetwork,
|
import {
|
||||||
setupPeriodicQueries,
|
setupPeriodicQueries,
|
||||||
monitorNetworkHealth,
|
monitorNetworkHealth,
|
||||||
ForumContextType
|
initializeNetwork
|
||||||
} from './forum';
|
} 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);
|
const ForumContext = createContext<ForumContextType | undefined>(undefined);
|
||||||
|
|
||||||
@ -21,35 +47,58 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [cells, setCells] = useState<Cell[]>([]);
|
const [cells, setCells] = useState<Cell[]>([]);
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
|
||||||
// Loading states
|
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
const [isPostingCell, setIsPostingCell] = useState(false);
|
const [isPostingCell, setIsPostingCell] = useState(false);
|
||||||
const [isPostingPost, setIsPostingPost] = useState(false);
|
const [isPostingPost, setIsPostingPost] = useState(false);
|
||||||
const [isPostingComment, setIsPostingComment] = useState(false);
|
const [isPostingComment, setIsPostingComment] = useState(false);
|
||||||
const [isVoting, setIsVoting] = useState(false);
|
const [isVoting, setIsVoting] = useState(false);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
// Network connection status
|
|
||||||
const [isNetworkConnected, setIsNetworkConnected] = useState(false);
|
const [isNetworkConnected, setIsNetworkConnected] = useState(false);
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { currentUser, isAuthenticated } = useAuth();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { currentUser, isAuthenticated, messageSigning } = useAuth();
|
||||||
// Function to update UI state from message cache
|
|
||||||
|
// Transform message cache data to the expected types
|
||||||
const updateStateFromCache = () => {
|
const updateStateFromCache = () => {
|
||||||
const data = getDataFromCache();
|
// Transform cells
|
||||||
setCells(data.cells);
|
setCells(
|
||||||
setPosts(data.posts);
|
Object.values(messageManager.messageCache.cells).map(cell =>
|
||||||
setComments(data.comments);
|
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 () => {
|
const handleRefreshData = async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
await refreshNetworkData(isNetworkConnected, toast, updateStateFromCache, setError);
|
try {
|
||||||
setIsRefreshing(false);
|
// 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
|
// 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> => {
|
const handleCreatePost = async (cellId: string, title: string, content: string): Promise<Post | null> => {
|
||||||
setIsPostingPost(true);
|
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);
|
setIsPostingPost(false);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateComment = async (postId: string, content: string): Promise<Comment | null> => {
|
const handleCreateComment = async (postId: string, content: string): Promise<Comment | null> => {
|
||||||
setIsPostingComment(true);
|
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);
|
setIsPostingComment(false);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVotePost = async (postId: string, isUpvote: boolean): Promise<boolean> => {
|
const handleVotePost = async (postId: string, isUpvote: boolean): Promise<boolean> => {
|
||||||
setIsVoting(true);
|
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);
|
setIsVoting(false);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVoteComment = async (commentId: string, isUpvote: boolean): Promise<boolean> => {
|
const handleVoteComment = async (commentId: string, isUpvote: boolean): Promise<boolean> => {
|
||||||
setIsVoting(true);
|
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);
|
setIsVoting(false);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateCell = async (name: string, description: string, icon: string): Promise<Cell | null> => {
|
const handleCreateCell = async (name: string, description: string, icon: string): Promise<Cell | null> => {
|
||||||
setIsPostingCell(true);
|
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);
|
setIsPostingCell(false);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@ -155,7 +246,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
export const useForum = () => {
|
export const useForum = () => {
|
||||||
const context = useContext(ForumContext);
|
const context = useContext(ForumContext);
|
||||||
if (context === undefined) {
|
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;
|
return context;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } fr
|
|||||||
import messageManager from '@/lib/waku';
|
import messageManager from '@/lib/waku';
|
||||||
import { Cell, Comment, Post, User } from '@/types';
|
import { Cell, Comment, Post, User } from '@/types';
|
||||||
import { transformCell, transformComment, transformPost } from './transformers';
|
import { transformCell, transformComment, transformPost } from './transformers';
|
||||||
|
import { MessageSigning } from '@/lib/identity/signatures/message-signing';
|
||||||
|
|
||||||
type ToastFunction = (props: {
|
type ToastFunction = (props: {
|
||||||
title: string;
|
title: string;
|
||||||
@ -10,7 +11,52 @@ type ToastFunction = (props: {
|
|||||||
variant?: "default" | "destructive";
|
variant?: "default" | "destructive";
|
||||||
}) => void;
|
}) => 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 (
|
export const createPost = async (
|
||||||
cellId: string,
|
cellId: string,
|
||||||
title: string,
|
title: string,
|
||||||
@ -18,7 +64,8 @@ export const createPost = async (
|
|||||||
currentUser: User | null,
|
currentUser: User | null,
|
||||||
isAuthenticated: boolean,
|
isAuthenticated: boolean,
|
||||||
toast: ToastFunction,
|
toast: ToastFunction,
|
||||||
updateStateFromCache: () => void
|
updateStateFromCache: () => void,
|
||||||
|
messageSigning?: MessageSigning
|
||||||
): Promise<Post | null> => {
|
): Promise<Post | null> => {
|
||||||
if (!isAuthenticated || !currentUser) {
|
if (!isAuthenticated || !currentUser) {
|
||||||
toast({
|
toast({
|
||||||
@ -47,10 +94,15 @@ export const createPost = async (
|
|||||||
author: currentUser.address
|
author: currentUser.address
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send the message to the network
|
const sentMessage = await signAndSendMessage(
|
||||||
await messageManager.sendMessage(postMessage);
|
postMessage,
|
||||||
|
currentUser,
|
||||||
|
messageSigning!,
|
||||||
|
toast
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sentMessage) return null;
|
||||||
|
|
||||||
// Update UI (the cache is already updated in sendMessage)
|
|
||||||
updateStateFromCache();
|
updateStateFromCache();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -58,8 +110,7 @@ export const createPost = async (
|
|||||||
description: "Your post has been published successfully.",
|
description: "Your post has been published successfully.",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the transformed post
|
return transformPost(sentMessage);
|
||||||
return transformPost(postMessage);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating post:", error);
|
console.error("Error creating post:", error);
|
||||||
toast({
|
toast({
|
||||||
@ -71,14 +122,14 @@ export const createPost = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a comment
|
|
||||||
export const createComment = async (
|
export const createComment = async (
|
||||||
postId: string,
|
postId: string,
|
||||||
content: string,
|
content: string,
|
||||||
currentUser: User | null,
|
currentUser: User | null,
|
||||||
isAuthenticated: boolean,
|
isAuthenticated: boolean,
|
||||||
toast: ToastFunction,
|
toast: ToastFunction,
|
||||||
updateStateFromCache: () => void
|
updateStateFromCache: () => void,
|
||||||
|
messageSigning?: MessageSigning
|
||||||
): Promise<Comment | null> => {
|
): Promise<Comment | null> => {
|
||||||
if (!isAuthenticated || !currentUser) {
|
if (!isAuthenticated || !currentUser) {
|
||||||
toast({
|
toast({
|
||||||
@ -106,10 +157,15 @@ export const createComment = async (
|
|||||||
author: currentUser.address
|
author: currentUser.address
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send the message to the network
|
const sentMessage = await signAndSendMessage(
|
||||||
await messageManager.sendMessage(commentMessage);
|
commentMessage,
|
||||||
|
currentUser,
|
||||||
|
messageSigning!,
|
||||||
|
toast
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sentMessage) return null;
|
||||||
|
|
||||||
// Update UI (the cache is already updated in sendMessage)
|
|
||||||
updateStateFromCache();
|
updateStateFromCache();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -117,8 +173,7 @@ export const createComment = async (
|
|||||||
description: "Your comment has been published.",
|
description: "Your comment has been published.",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the transformed comment
|
return transformComment(sentMessage);
|
||||||
return transformComment(commentMessage);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating comment:", error);
|
console.error("Error creating comment:", error);
|
||||||
toast({
|
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 (
|
export const createCell = async (
|
||||||
name: string,
|
name: string,
|
||||||
description: string,
|
description: string,
|
||||||
icon: string,
|
icon: string,
|
||||||
currentUser: User | null,
|
currentUser: User | null,
|
||||||
isAuthenticated: boolean,
|
isAuthenticated: boolean,
|
||||||
toast: ToastFunction,
|
toast: ToastFunction,
|
||||||
updateStateFromCache: () => void
|
updateStateFromCache: () => void,
|
||||||
|
messageSigning?: MessageSigning
|
||||||
): Promise<Cell | null> => {
|
): Promise<Cell | null> => {
|
||||||
if (!isAuthenticated || !currentUser) {
|
if (!isAuthenticated || !currentUser) {
|
||||||
toast({
|
toast({
|
||||||
@ -226,25 +222,94 @@ export const createCell = async (
|
|||||||
author: currentUser.address
|
author: currentUser.address
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send the cell message to the network
|
const sentMessage = await signAndSendMessage(
|
||||||
await messageManager.sendMessage(cellMessage);
|
cellMessage,
|
||||||
|
currentUser,
|
||||||
|
messageSigning!,
|
||||||
|
toast
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sentMessage) return null;
|
||||||
|
|
||||||
// Update UI (the cache is already updated in sendMessage)
|
|
||||||
updateStateFromCache();
|
updateStateFromCache();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Cell Created",
|
title: "Cell Created",
|
||||||
description: "Your cell has been created successfully.",
|
description: "Your cell has been published.",
|
||||||
});
|
});
|
||||||
|
|
||||||
return transformCell(cellMessage);
|
return transformCell(sentMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating cell:", error);
|
console.error("Error creating cell:", error);
|
||||||
toast({
|
toast({
|
||||||
title: "Cell Creation Failed",
|
title: "Cell Failed",
|
||||||
description: "Failed to create cell. Please try again.",
|
description: "Failed to create cell. Please try again.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return null;
|
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[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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 = {
|
export const BOOTSTRAP_NODES = {
|
||||||
"42": [
|
"42": [
|
||||||
"/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ",
|
"/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ",
|
||||||
"/dns4/node-01.do-ams3.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb",
|
// "/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/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;
|
type: MessageType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
author: string;
|
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 };
|
ordinalOwnership?: boolean | { id: string; details: string };
|
||||||
signature?: string;
|
signature?: string;
|
||||||
lastChecked?: number;
|
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 {
|
export interface Cell {
|
||||||
@ -25,6 +28,8 @@ export interface Post {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
upvotes: VoteMessage[];
|
upvotes: VoteMessage[];
|
||||||
downvotes: VoteMessage[];
|
downvotes: VoteMessage[];
|
||||||
|
signature?: string; // Message signature
|
||||||
|
browserPubKey?: string; // Public key that signed the message
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
@ -35,4 +40,12 @@ export interface Comment {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
upvotes: VoteMessage[];
|
upvotes: VoteMessage[];
|
||||||
downvotes: 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