feat: multichain support (bitcoin + ethereum) (#13)

* chore: add reown

* wip: multichain wallet support

* chore: ens resolution

* chore: wizard flow for auth
This commit is contained in:
Danish Arora 2025-08-05 10:51:21 +05:30 committed by GitHub
parent 1155948d0d
commit 8b714b61a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 8274 additions and 911 deletions

4
.gitignore vendored
View File

@ -43,4 +43,6 @@ node_modules/
# OS specific
# Task files
tasks.json
tasks/
tasks/
IMPLEMENTATION_PLAN.md

6539
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,11 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.56.2",
"@reown/appkit": "^1.7.17",
"@reown/appkit-adapter-bitcoin": "^1.7.17",
"@reown/appkit-adapter-wagmi": "^1.7.17",
"@reown/appkit-wallet-button": "^1.7.17",
"@tanstack/react-query": "^5.84.1",
"@waku/sdk": "^0.0.30",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -65,6 +69,7 @@
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^0.9.3",
"wagmi": "^2.16.1",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@ -22,29 +22,40 @@ import CellPage from "./pages/CellPage";
import PostPage from "./pages/PostPage";
import NotFound from "./pages/NotFound";
import Dashboard from "./pages/Dashboard";
import { appkitConfig } from "./lib/identity/wallets/appkit";
import { createAppKit } from "@reown/appkit";
import { WagmiProvider } from "wagmi";
import { config } from "./lib/identity/wallets/appkit";
import { AppKitProvider } from "@reown/appkit/react";
// Create a client
const queryClient = new QueryClient();
createAppKit(appkitConfig);
const App = () => (
<QueryClientProvider client={queryClient}>
<Router>
<AuthProvider>
<ForumProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/cell/:cellId" element={<CellPage />} />
<Route path="/post/:postId" element={<PostPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</ForumProvider>
</AuthProvider>
</Router>
</QueryClientProvider>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<AppKitProvider {...appkitConfig}>
<Router>
<AuthProvider>
<ForumProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/cell/:cellId" element={<CellPage />} />
<Route path="/post/:postId" element={<PostPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</ForumProvider>
</AuthProvider>
</Router>
</AppKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
export default App;

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '@/contexts/useAuth';
import { useForum } from '@/contexts/useForum';
@ -7,15 +7,15 @@ import { Badge } from '@/components/ui/badge';
import { LogOut, Terminal, Wifi, WifiOff, AlertTriangle, CheckCircle, Key, RefreshCw, CircleSlash } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast';
import { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
import { WalletWizard } from '@/components/ui/wallet-wizard';
const Header = () => {
const {
currentUser,
isAuthenticated,
verificationStatus,
connectWallet,
disconnectWallet,
verifyOrdinal,
verifyOwnership,
delegateKey,
isDelegationValid,
delegationTimeRemaining,
@ -23,237 +23,194 @@ const Header = () => {
} = useAuth();
const { isNetworkConnected, isRefreshing } = useForum();
const { toast } = useToast();
// Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" });
const { disconnect } = useDisconnect();
// Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected;
const address = isConnected ? (isBitcoinConnected ? bitcoinAccount.address : ethereumAccount.address) : undefined;
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
const [hasShownWizard, setHasShownWizard] = useState(false);
// Auto-open wizard when wallet connects for the first time
React.useEffect(() => {
if (isConnected && !hasShownWizard) {
setWalletWizardOpen(true);
setHasShownWizard(true);
}
}, [isConnected, hasShownWizard]);
const handleConnect = async () => {
await connectWallet();
setWalletWizardOpen(true);
};
const handleDisconnect = () => {
disconnectWallet();
const handleDisconnect = async () => {
await disconnect();
setHasShownWizard(false); // Reset so wizard can show again on next connection
toast({
title: "Wallet Disconnected",
description: "Your wallet has been disconnected successfully.",
});
};
const handleVerify = async () => {
await verifyOrdinal();
};
const handleDelegateKey = async () => {
try {
if (!isWalletAvailable()) {
toast({
title: "Wallet Not Available",
description: "Phantom wallet is not installed or not available. Please install Phantom wallet and try again.",
variant: "destructive",
});
return;
}
await delegateKey();
} catch (error) {
console.error('Error in handleDelegateKey:', error);
const getAccountStatusText = () => {
switch (verificationStatus) {
case 'unverified':
return 'Setup Required';
case 'verifying':
return 'Verifying...';
case 'verified-none':
return 'Read-Only Access';
case 'verified-owner':
return isDelegationValid() ? 'Full Access' : 'Setup Key';
default:
return 'Setup Account';
}
};
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 getAccountStatusIcon = () => {
switch (verificationStatus) {
case 'unverified':
return <AlertTriangle className="w-3 h-3" />;
case 'verifying':
return <RefreshCw className="w-3 h-3 animate-spin" />;
case 'verified-none':
return <CircleSlash className="w-3 h-3" />;
case 'verified-owner':
return isDelegationValid() ? <CheckCircle className="w-3 h-3" /> : <Key className="w-3 h-3" />;
default:
return <AlertTriangle className="w-3 h-3" />;
}
};
const renderDelegationButton = () => {
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 text-xs px-2 h-7"
onClick={handleDelegateKey}
>
<Key className="w-3 h-3" />
{hasValidDelegation
? <span>KEY ACTIVE ({timeRemaining})</span>
: <span>DELEGATE KEY</span>}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-sm">
{hasValidDelegation ? (
<p>Browser key active for ~{timeRemaining}. Wallet signatures not needed for most actions.</p>
) : (
<p>Delegate a browser key for 24h to avoid constant wallet signing. If your wallet is disconnected, it will be reconnected automatically.</p>
)}
</TooltipContent>
</Tooltip>
);
};
const renderAccessBadge = () => {
if (verificationStatus === 'unverified') {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleVerify}
className="flex items-center gap-1 text-xs px-2 h-7 border-destructive text-destructive hover:bg-destructive/10"
>
<AlertTriangle className="w-3 h-3" />
<span>[UNVERIFIED] Verify</span>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-sm">
<p className="font-semibold mb-1">Action Required</p>
<p>Verify your Ordinal ownership to enable posting, commenting, and voting.</p>
</TooltipContent>
</Tooltip>
);
const getAccountStatusVariant = () => {
switch (verificationStatus) {
case 'unverified':
return 'destructive';
case 'verifying':
return 'outline';
case 'verified-none':
return 'secondary';
case 'verified-owner':
return isDelegationValid() ? 'default' : 'outline';
default:
return 'outline';
}
if (verificationStatus === 'verifying') {
return (
<Badge
variant="outline"
className="flex items-center gap-1 text-xs px-2 h-7"
>
<RefreshCw className="w-3 h-3 animate-spin" />
<span>[VERIFYING...]</span>
</Badge>
);
}
if (verificationStatus === 'verified-none') {
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className="flex items-center gap-1 cursor-help text-xs px-2 h-7"
>
<CircleSlash className="w-3 h-3" />
<span>[VERIFIED | READ-ONLY]</span>
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-sm">
<p className="font-semibold mb-1">Wallet Verified - No Ordinals</p>
<p>No Ordinal Operators found. Read-only access granted.</p>
<Button size="sm" variant="link" onClick={handleVerify} className="p-0 h-auto mt-1 text-xs">Verify Again?</Button>
</TooltipContent>
</Tooltip>
);
}
// Verified - Ordinal Owner
if (verificationStatus === 'verified-owner') {
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="default"
className="flex items-center gap-1 cursor-help text-xs px-2 h-7 bg-primary text-primary-foreground"
>
<CheckCircle className="w-3 h-3" />
<span>[OWNER ]</span>
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-sm">
<p className="font-semibold mb-1">Ordinal Owner Verified!</p>
<p>Full forum access granted.</p>
<Button size="sm" variant="link" onClick={handleVerify} className="p-0 h-auto mt-1 text-xs">Verify Again?</Button>
</TooltipContent>
</Tooltip>
);
}
return null;
};
return (
<header className="border-b border-cyber-muted bg-cyber-dark fixed top-0 left-0 right-0 z-50 h-16">
<div className="container mx-auto px-4 h-full flex justify-between items-center">
<div className="flex items-center gap-2">
<Terminal className="text-cyber-accent w-6 h-6" />
<Link to="/" className="text-xl font-bold text-glow text-cyber-accent">
OpChan
</Link>
</div>
<div className="flex gap-3 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={isNetworkConnected ? "default" : "destructive"}
className="flex items-center gap-1 text-xs px-2 h-7 cursor-help"
>
{isNetworkConnected ? (
<>
<Wifi className="w-3 h-3" />
<span>WAKU: Connected</span>
</>
) : (
<>
<WifiOff className="w-3 h-3" />
<span>WAKU: Offline</span>
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="text-sm">
<p>{isNetworkConnected ? "Waku network connection active." : "Waku network connection lost."}</p>
{isRefreshing && <p>Refreshing data...</p>}
</TooltipContent>
</Tooltip>
<>
<header className="border-b border-cyber-muted bg-cyber-dark fixed top-0 left-0 right-0 z-50 h-16">
<div className="container mx-auto px-4 h-full flex justify-between items-center">
<div className="flex items-center gap-2">
<Terminal className="text-cyber-accent w-6 h-6" />
<Link to="/" className="text-xl font-bold text-glow text-cyber-accent">
OpChan
</Link>
</div>
{!currentUser ? (
<Button
variant="outline"
size="sm"
onClick={handleConnect}
className="text-xs px-2 h-7"
>
Connect Wallet
</Button>
) : (
<div className="flex gap-2 items-center">
{renderAccessBadge()}
{renderDelegationButton()}
<Tooltip>
<TooltipTrigger asChild>
<span className="hidden md:flex items-center text-xs text-muted-foreground cursor-default px-2 h-7">
{currentUser.address.slice(0, 5)}...{currentUser.address.slice(-4)}
</span>
</TooltipTrigger>
<TooltipContent className="text-sm">
<p>{currentUser.address}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleDisconnect}
className="w-7 h-7"
>
<LogOut className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="text-sm">Disconnect Wallet</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex gap-3 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={isNetworkConnected ? "default" : "destructive"}
className="flex items-center gap-1 text-xs px-2 h-7 cursor-help"
>
{isNetworkConnected ? (
<>
<Wifi className="w-3 h-3" />
<span>WAKU: Connected</span>
</>
) : (
<>
<WifiOff className="w-3 h-3" />
<span>WAKU: Offline</span>
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="text-sm">
<p>{isNetworkConnected ? "Waku network connection active." : "Waku network connection lost."}</p>
{isRefreshing && <p>Refreshing data...</p>}
</TooltipContent>
</Tooltip>
{!isConnected ? (
<Button
variant="outline"
size="sm"
onClick={handleConnect}
className="text-xs px-2 h-7"
>
Connect Wallet
</Button>
) : (
<div className="flex gap-2 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={getAccountStatusVariant()}
size="sm"
onClick={() => setWalletWizardOpen(true)}
className="flex items-center gap-1 text-xs px-2 h-7"
>
{getAccountStatusIcon()}
<span>{getAccountStatusText()}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-sm">
<p className="font-semibold mb-1">Account Setup</p>
<p>Click to view and manage your wallet connection, verification status, and key delegation.</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="hidden md:flex items-center text-xs text-muted-foreground cursor-default px-2 h-7">
{currentUser?.ensName || `${address?.slice(0, 5)}...${address?.slice(-4)}`}
</span>
</TooltipTrigger>
<TooltipContent className="text-sm">
<p>{currentUser?.ensName ? `${currentUser.ensName} (${address})` : address}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleDisconnect}
className="w-7 h-7"
>
<LogOut className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="text-sm">Disconnect Wallet</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
</div>
</header>
</header>
<WalletWizard
open={walletWizardOpen}
onOpenChange={setWalletWizardOpen}
onComplete={() => {
toast({
title: "Setup Complete",
description: "You can now use all OpChan features!",
});
}}
/>
</>
);
};

View File

@ -0,0 +1,239 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Key, Clock, Shield, Loader2, CheckCircle, AlertCircle } from "lucide-react";
import { useAuth } from "@/contexts/useAuth";
interface DelegationStepProps {
onComplete: () => void;
onBack: () => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
}
export function DelegationStep({
onComplete,
onBack,
isLoading,
setIsLoading,
}: DelegationStepProps) {
const {
currentUser,
delegateKey,
isDelegationValid,
delegationTimeRemaining,
isAuthenticating
} = useAuth();
const [delegationResult, setDelegationResult] = React.useState<{
success: boolean;
message: string;
expiry?: string;
} | null>(null);
const handleDelegate = async () => {
if (!currentUser) return;
setIsLoading(true);
setDelegationResult(null);
try {
const success = await delegateKey();
if (success) {
const expiryDate = currentUser.delegationExpiry
? new Date(currentUser.delegationExpiry).toLocaleString()
: '24 hours from now';
setDelegationResult({
success: true,
message: "Key delegation successful! You can now interact with the forum without additional wallet approvals.",
expiry: expiryDate
});
} else {
setDelegationResult({
success: false,
message: "Key delegation failed. You can still use the forum but will need to approve each action."
});
}
} catch (error) {
setDelegationResult({
success: false,
message: "Delegation failed. Please try again."
});
} finally {
setIsLoading(false);
}
};
const handleComplete = () => {
onComplete();
};
const formatTimeRemaining = () => {
const remaining = delegationTimeRemaining();
if (remaining <= 0) return "Expired";
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m remaining`;
} else {
return `${minutes}m remaining`;
}
};
// Show delegation result
if (delegationResult) {
return (
<div className="space-y-4">
<div className={`p-4 rounded-lg border ${
delegationResult.success
? 'bg-green-900/20 border-green-500/30'
: 'bg-yellow-900/20 border-yellow-500/30'
}`}>
<div className="flex items-center gap-2 mb-2">
{delegationResult.success ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<AlertCircle className="h-5 w-5 text-yellow-500" />
)}
<span className={`font-medium ${
delegationResult.success ? 'text-green-400' : 'text-yellow-400'
}`}>
{delegationResult.success ? 'Delegation Complete' : 'Delegation Result'}
</span>
</div>
<p className="text-sm text-neutral-300 mb-2">
{delegationResult.message}
</p>
{delegationResult.expiry && (
<div className="text-xs text-neutral-400">
<p>Expires: {delegationResult.expiry}</p>
</div>
)}
</div>
<Button
onClick={handleComplete}
className="w-full bg-green-600 hover:bg-green-700 text-white"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Complete Setup"
)}
</Button>
</div>
);
}
// Show existing delegation status
if (isDelegationValid()) {
return (
<div className="space-y-4">
<div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-green-400 font-medium">Key Already Delegated</span>
</div>
<p className="text-sm text-neutral-300 mb-2">
You already have an active key delegation.
</p>
<div className="text-xs text-neutral-400">
<p>Time remaining: {formatTimeRemaining()}</p>
{currentUser?.delegationExpiry && (
<p>Expires: {new Date(currentUser.delegationExpiry).toLocaleString()}</p>
)}
</div>
</div>
<Button
onClick={handleComplete}
className="w-full bg-green-600 hover:bg-green-700 text-white"
disabled={isLoading}
>
Complete Setup
</Button>
</div>
);
}
// Show delegation form
return (
<div className="space-y-4">
<div className="text-center space-y-2">
<div className="flex justify-center">
<Key className="h-8 w-8 text-blue-500" />
</div>
<h3 className="text-lg font-semibold text-white">
Delegate Signing Key
</h3>
<p className="text-sm text-neutral-400">
Create a browser-based signing key for better user experience
</p>
</div>
<div className="p-4 bg-neutral-900/50 border border-neutral-700 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Shield className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-white">What is key delegation?</span>
</div>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Creates a browser-based signing key for 24 hours</li>
<li> Allows posting, commenting, and voting without wallet approval</li>
<li> Automatically expires for security</li>
<li> Can be renewed anytime</li>
</ul>
</div>
{currentUser?.browserPubKey && (
<div className="p-3 bg-neutral-900/30 border border-neutral-600 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Key className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-white">Browser Key Generated</span>
</div>
<p className="text-xs text-neutral-400 font-mono break-all">
{currentUser.browserPubKey.slice(0, 20)}...{currentUser.browserPubKey.slice(-20)}
</p>
</div>
)}
<div className="space-y-3">
<Button
onClick={handleDelegate}
disabled={isLoading || isAuthenticating}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{isLoading || isAuthenticating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Delegating Key...
</>
) : (
"Delegate Signing Key"
)}
</Button>
<Button
onClick={onBack}
variant="outline"
className="w-full border-neutral-600 text-neutral-400 hover:bg-neutral-800"
disabled={isLoading || isAuthenticating}
>
Back to Verification
</Button>
</div>
<div className="text-xs text-neutral-500 text-center space-y-1">
<p>Key delegation is optional but recommended for better UX</p>
<p>You can still use the forum without delegation</p>
</div>
</div>
);
}

View File

@ -0,0 +1,261 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Bitcoin, Coins, Shield, ShieldCheck, Loader2, AlertCircle } from "lucide-react";
import { useAuth } from "@/contexts/useAuth";
import { useAppKitAccount } from "@reown/appkit/react";
interface VerificationStepProps {
onComplete: () => void;
onBack: () => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
}
export function VerificationStep({
onComplete,
onBack,
isLoading,
setIsLoading,
}: VerificationStepProps) {
const {
currentUser,
verificationStatus,
verifyOwnership,
isAuthenticating
} = useAuth();
// Get account info to determine wallet type
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" });
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const walletType = isBitcoinConnected ? 'bitcoin' : 'ethereum';
const [verificationResult, setVerificationResult] = React.useState<{
success: boolean;
message: string;
details?: any;
} | null>(null);
const handleVerify = async () => {
if (!currentUser) return;
setIsLoading(true);
setVerificationResult(null);
try {
const success = await verifyOwnership();
if (success) {
setVerificationResult({
success: true,
message: walletType === 'bitcoin'
? "Ordinal ownership verified successfully!"
: "ENS ownership verified successfully!",
details: walletType === 'bitcoin'
? currentUser.ordinalOwnership
: { ensName: currentUser.ensName, ensAvatar: currentUser.ensAvatar }
});
} else {
setVerificationResult({
success: false,
message: walletType === 'bitcoin'
? "No Ordinal ownership found. You'll have read-only access."
: "No ENS ownership found. You'll have read-only access."
});
}
} catch (error) {
setVerificationResult({
success: false,
message: "Verification failed. Please try again."
});
} finally {
setIsLoading(false);
}
};
const handleNext = () => {
onComplete();
};
const getVerificationType = () => {
return walletType === 'bitcoin' ? 'Bitcoin Ordinal' : 'ENS Domain';
};
const getVerificationIcon = () => {
return walletType === 'bitcoin' ? Bitcoin : Coins;
};
const getVerificationColor = () => {
return walletType === 'bitcoin' ? 'text-orange-500' : 'text-blue-500';
};
const getVerificationDescription = () => {
if (walletType === 'bitcoin') {
return "Verify that you own Bitcoin Ordinals to get full posting and voting access.";
} else {
return "Verify that you own an ENS domain to get full posting and voting access.";
}
};
// Show verification result
if (verificationResult) {
return (
<div className="space-y-4">
<div className={`p-4 rounded-lg border ${
verificationResult.success
? 'bg-green-900/20 border-green-500/30'
: 'bg-yellow-900/20 border-yellow-500/30'
}`}>
<div className="flex items-center gap-2 mb-2">
{verificationResult.success ? (
<ShieldCheck className="h-5 w-5 text-green-500" />
) : (
<AlertCircle className="h-5 w-5 text-yellow-500" />
)}
<span className={`font-medium ${
verificationResult.success ? 'text-green-400' : 'text-yellow-400'
}`}>
{verificationResult.success ? 'Verification Complete' : 'Verification Result'}
</span>
</div>
<p className="text-sm text-neutral-300 mb-2">
{verificationResult.message}
</p>
{verificationResult.details && (
<div className="text-xs text-neutral-400">
{walletType === 'bitcoin' ? (
<p>Ordinal ID: {verificationResult.details.id}</p>
) : (
<p>ENS Name: {verificationResult.details.ensName}</p>
)}
</div>
)}
</div>
<Button
onClick={handleNext}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Continue to Key Delegation"
)}
</Button>
</div>
);
}
// Show verification status
if (verificationStatus === 'verified-owner') {
return (
<div className="space-y-4">
<div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<ShieldCheck className="h-5 w-5 text-green-500" />
<span className="text-green-400 font-medium">Already Verified</span>
</div>
<p className="text-sm text-neutral-300 mb-2">
Your {getVerificationType()} ownership has been verified.
</p>
{currentUser && (
<div className="text-xs text-neutral-400">
{walletType === 'bitcoin' && currentUser.ordinalOwnership && (
<p>Ordinal ID: {typeof currentUser.ordinalOwnership === 'object' ? currentUser.ordinalOwnership.id : 'Verified'}</p>
)}
{walletType === 'ethereum' && currentUser.ensName && (
<p>ENS Name: {currentUser.ensName}</p>
)}
</div>
)}
</div>
<Button
onClick={handleNext}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
disabled={isLoading}
>
Continue to Key Delegation
</Button>
</div>
);
}
// Show verification form
return (
<div className="space-y-4">
<div className="text-center space-y-2">
<div className="flex justify-center">
{React.createElement(getVerificationIcon(), {
className: `h-8 w-8 ${getVerificationColor()}`
})}
</div>
<h3 className="text-lg font-semibold text-white">
Verify {getVerificationType()} Ownership
</h3>
<p className="text-sm text-neutral-400">
{getVerificationDescription()}
</p>
</div>
<div className="p-4 bg-neutral-900/50 border border-neutral-700 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Shield className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-white">What happens during verification?</span>
</div>
<ul className="text-xs text-neutral-400 space-y-1">
{walletType === 'bitcoin' ? (
<>
<li> We'll check your wallet for Bitcoin Ordinal ownership</li>
<li> If found, you'll get full posting and voting access</li>
<li> If not found, you'll have read-only access</li>
</>
) : (
<>
<li> We'll check your wallet for ENS domain ownership</li>
<li> If found, you'll get full posting and voting access</li>
<li> If not found, you'll have read-only access</li>
</>
)}
</ul>
</div>
<div className="space-y-3">
<Button
onClick={handleVerify}
disabled={isLoading || isAuthenticating}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{isLoading || isAuthenticating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
`Verify ${getVerificationType()} Ownership`
)}
</Button>
<Button
onClick={onBack}
variant="outline"
className="w-full border-neutral-600 text-neutral-400 hover:bg-neutral-800"
disabled={isLoading || isAuthenticating}
>
Back to Wallet Connection
</Button>
</div>
<div className="text-xs text-neutral-500 text-center">
Verification is required to access posting and voting features
</div>
</div>
);
}

View File

@ -0,0 +1,215 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Bitcoin, Coins, Loader2 } from "lucide-react";
import {
useAppKit,
useAppKitAccount,
useAppKitState
} from "@reown/appkit/react";
import { useAuth } from "@/contexts/useAuth";
interface WalletConnectionStepProps {
onComplete: () => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
}
export function WalletConnectionStep({
onComplete,
isLoading,
setIsLoading,
}: WalletConnectionStepProps) {
const { initialized } = useAppKitState();
const appKit = useAppKit();
const { isAuthenticated } = useAuth();
// Get account info for different chains
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" });
// Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected;
// Get the active account info
const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount;
const activeAddress = activeAccount.address;
const activeChain = isBitcoinConnected ? "Bitcoin" : "Ethereum";
const handleBitcoinConnect = async () => {
if (!initialized || !appKit) {
console.error('AppKit not initialized');
return;
}
setIsLoading(true);
try {
await appKit.open({
view: "Connect",
namespace: "bip122"
});
// The wizard will automatically advance when connection is detected
} catch (error) {
console.error('Error connecting Bitcoin wallet:', error);
} finally {
setIsLoading(false);
}
};
const handleEthereumConnect = async () => {
if (!initialized || !appKit) {
console.error('AppKit not initialized');
return;
}
setIsLoading(true);
try {
await appKit.open({
view: "Connect",
namespace: "eip155"
});
// The wizard will automatically advance when connection is detected
} catch (error) {
console.error('Error connecting Ethereum wallet:', error);
} finally {
setIsLoading(false);
}
};
const handleNext = () => {
onComplete();
};
// Show loading state if AppKit is not initialized
if (!initialized) {
return (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-neutral-400 text-center">
Initializing wallet connection...
</p>
</div>
);
}
// Show connected state
if (isConnected) {
return (
<div className="space-y-4">
<div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-green-400 font-medium">Wallet Connected</span>
</div>
<p className="text-sm text-neutral-300 mb-2">
Connected to {activeChain} with {activeAddress?.slice(0, 6)}...{activeAddress?.slice(-4)}
</p>
<p className="text-xs text-neutral-400">
Proceeding to verification step...
</p>
</div>
<div className="flex justify-center">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
</div>
);
}
// Show connection options
return (
<div className="space-y-4">
<p className="text-sm text-neutral-400 text-center">
Choose a network and wallet to connect to OpChan
</p>
{/* Bitcoin Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Bitcoin className="h-5 w-5 text-orange-500" />
<h3 className="font-semibold text-white">Bitcoin</h3>
<Badge variant="secondary" className="text-xs">
Ordinal Verification Required
</Badge>
</div>
<Button
onClick={handleBitcoinConnect}
disabled={isLoading}
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white"
style={{
height: '44px',
borderRadius: '8px',
border: 'none',
fontSize: '14px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Connecting...
</>
) : (
"Connect Bitcoin Wallet"
)}
</Button>
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-neutral-700" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-black px-2 text-neutral-500">or</span>
</div>
</div>
{/* Ethereum Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Coins className="h-5 w-5 text-blue-500" />
<h3 className="font-semibold text-white">Ethereum</h3>
<Badge variant="secondary" className="text-xs">
ENS Ownership Required
</Badge>
</div>
<Button
onClick={handleEthereumConnect}
disabled={isLoading}
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white"
style={{
height: '44px',
borderRadius: '8px',
border: 'none',
fontSize: '14px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Connecting...
</>
) : (
"Connect Ethereum Wallet"
)}
</Button>
</div>
<div className="text-xs text-neutral-500 text-center pt-2">
Connect your wallet to use OpChan's features
</div>
</div>
);
}

View File

@ -8,60 +8,212 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { WalletConnectionStatus } from "@/lib/identity/wallets/phantom";
import { Badge } from "@/components/ui/badge";
import { Bitcoin, Coins } from "lucide-react";
import {
useAppKit,
useAppKitAccount,
useDisconnect,
useAppKitState
} from "@reown/appkit/react";
interface WalletDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConnectPhantom: () => void;
onInstallPhantom: () => void;
status: WalletConnectionStatus;
isAuthenticating: boolean;
onConnect: () => void;
}
export function WalletConnectionDialog({
open,
onOpenChange,
onConnectPhantom,
onInstallPhantom,
status,
isAuthenticating,
onConnect,
}: WalletDialogProps) {
// Always call hooks to follow React rules
const { initialized } = useAppKitState();
const appKit = useAppKit();
const { disconnect } = useDisconnect();
// Get account info for different chains
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" });
// Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected;
// Get the active account info
const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount;
const activeAddress = activeAccount.address;
const activeChain = isBitcoinConnected ? "Bitcoin" : "Ethereum";
const handleDisconnect = async () => {
await disconnect();
onOpenChange(false);
};
const handleBitcoinConnect = () => {
if (!initialized || !appKit) {
console.error('AppKit not initialized');
return;
}
appKit.open({
view: "Connect",
namespace: "bip122"
});
onConnect();
onOpenChange(false);
};
const handleEthereumConnect = () => {
if (!initialized || !appKit) {
console.error('AppKit not initialized');
return;
}
appKit.open({
view: "Connect",
namespace: "eip155"
});
onConnect();
onOpenChange(false);
};
// Show loading state if AppKit is not initialized
if (!initialized) {
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">
Initializing wallet connection...
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
</DialogContent>
</Dialog>
);
}
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"}
{isConnected
? `Connected to ${activeChain} with ${activeAddress?.slice(0, 6)}...${activeAddress?.slice(-4)}`
: "Choose a network and wallet to connect to OpChan"
}
</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>
{!isConnected ? (
<div className="space-y-4">
{/* Bitcoin Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Bitcoin className="h-5 w-5 text-orange-500" />
<h3 className="font-semibold text-white">Bitcoin</h3>
<Badge variant="secondary" className="text-xs">
Ordinal Verification Required
</Badge>
</div>
<div className="space-y-2">
<Button
onClick={handleBitcoinConnect}
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white"
style={{
height: '44px',
borderRadius: '8px',
border: 'none',
fontSize: '14px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
>
Connect Bitcoin Wallet
</Button>
</div>
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-neutral-700" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-black px-2 text-neutral-500">or</span>
</div>
</div>
{/* Ethereum Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Coins className="h-5 w-5 text-blue-500" />
<h3 className="font-semibold text-white">Ethereum</h3>
<Badge variant="secondary" className="text-xs">
ENS Ownership Required
</Badge>
</div>
<div className="space-y-2">
<Button
onClick={handleEthereumConnect}
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white"
style={{
height: '44px',
borderRadius: '8px',
border: 'none',
fontSize: '14px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
>
Connect Ethereum Wallet
</Button>
</div>
</div>
</div>
) : (
<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 className="space-y-3">
<div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700">
<p className="text-sm text-neutral-300 mb-2">Connected Network:</p>
<p className="text-sm font-semibold text-white mb-2">{activeChain}</p>
<p className="text-sm text-neutral-300 mb-2">Address:</p>
<p className="text-xs font-mono text-neutral-400 break-all">
{activeAddress}
</p>
</div>
<Button
onClick={handleDisconnect}
variant="outline"
className="w-full border-red-500 text-red-500 hover:bg-red-500/10"
>
Disconnect Wallet
</Button>
</div>
)}
</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>
<p>Connect your wallet to use OpChan's features</p>
{isConnected && (
<p className="text-green-400"> Wallet connected to {activeChain}</p>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}

View File

@ -0,0 +1,183 @@
import * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { CheckCircle, Circle, Loader2 } from "lucide-react";
import { useAuth } from "@/contexts/useAuth";
import { WalletConnectionStep } from "./wallet-connection-step";
import { VerificationStep } from "./verification-step";
import { DelegationStep } from "./delegation-step";
interface WalletWizardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: () => void;
}
type WizardStep = 1 | 2 | 3;
export function WalletWizard({
open,
onOpenChange,
onComplete,
}: WalletWizardProps) {
const [currentStep, setCurrentStep] = React.useState<WizardStep>(1);
const [isLoading, setIsLoading] = React.useState(false);
const { currentUser, isAuthenticated, verificationStatus } = useAuth();
// Reset wizard when opened and determine starting step
React.useEffect(() => {
if (open) {
// Only auto-advance from step 1 to 2 when wallet connects during the session
// Don't auto-advance to step 3 to allow manual navigation
if (isAuthenticated && verificationStatus !== 'verified-owner') {
setCurrentStep(2); // Start at verification step if authenticated but not verified
} else if (!isAuthenticated) {
setCurrentStep(1); // Start at connection step if not authenticated
} else {
setCurrentStep(1); // Default to step 1, let user navigate manually
}
setIsLoading(false);
}
}, [open, isAuthenticated, verificationStatus]);
// Auto-advance from step 1 to 2 only when wallet connects during the session
React.useEffect(() => {
if (open && currentStep === 1 && isAuthenticated) {
setCurrentStep(2);
}
}, [open, currentStep, isAuthenticated]);
const handleStepComplete = (step: WizardStep) => {
if (step < 3) {
setCurrentStep((step + 1) as WizardStep);
} else {
onComplete();
onOpenChange(false);
}
};
const handleClose = () => {
if (isLoading) return; // Prevent closing during operations
onOpenChange(false);
};
const getStepStatus = (step: WizardStep) => {
if (currentStep > step) return "completed";
if (currentStep === step) return "current";
return "pending";
};
const renderStepIcon = (step: WizardStep) => {
const status = getStepStatus(step);
if (status === "completed") {
return <CheckCircle className="h-5 w-5 text-green-500" />;
} else if (status === "current") {
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
} else {
return <Circle className="h-5 w-5 text-gray-400" />;
}
};
const getStepTitle = (step: WizardStep) => {
switch (step) {
case 1: return "Connect Wallet";
case 2: return "Verify Ownership";
case 3: return "Delegate Key";
default: return "";
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md border-neutral-800 bg-black text-white">
<DialogHeader>
<DialogTitle className="text-xl">Setup Your Account</DialogTitle>
<DialogDescription className="text-neutral-400">
Complete these steps to access all OpChan features
</DialogDescription>
</DialogHeader>
{/* Progress Indicator */}
<div className="flex items-center justify-between mb-6">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<div className="flex items-center gap-2">
{renderStepIcon(step as WizardStep)}
<span className={`text-sm ${
getStepStatus(step as WizardStep) === "current"
? "text-blue-500 font-medium"
: getStepStatus(step as WizardStep) === "completed"
? "text-green-500"
: "text-gray-400"
}`}>
{getStepTitle(step as WizardStep)}
</span>
</div>
{step < 3 && (
<div className={`w-8 h-px mx-2 ${
getStepStatus(step as WizardStep) === "completed"
? "bg-green-500"
: "bg-gray-600"
}`} />
)}
</div>
))}
</div>
{/* Step Content */}
<div className="min-h-[300px]">
{currentStep === 1 && (
<WalletConnectionStep
onComplete={() => handleStepComplete(1)}
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
)}
{currentStep === 2 && (
<VerificationStep
onComplete={() => handleStepComplete(2)}
onBack={() => setCurrentStep(1)}
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
)}
{currentStep === 3 && (
<DelegationStep
onComplete={() => handleStepComplete(3)}
onBack={() => setCurrentStep(2)}
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
)}
</div>
{/* Footer */}
<div className="flex justify-between items-center pt-4 border-t border-neutral-700">
<p className="text-xs text-neutral-500">
Step {currentStep} of 3
</p>
{currentStep > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentStep((currentStep - 1) as WizardStep)}
disabled={isLoading}
className="text-neutral-400 hover:text-white"
>
Back
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -3,6 +3,7 @@ import { useToast } from '@/components/ui/use-toast';
import { User } from '@/types';
import { AuthService, AuthResult } from '@/lib/identity/services/AuthService';
import { OpchanMessage } from '@/types';
import { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
export type VerificationStatus = 'unverified' | 'verified-none' | 'verified-owner' | 'verifying';
@ -11,9 +12,7 @@ interface AuthContextType {
isAuthenticated: boolean;
isAuthenticating: boolean;
verificationStatus: VerificationStatus;
connectWallet: () => Promise<void>;
disconnectWallet: () => void;
verifyOrdinal: () => Promise<boolean>;
verifyOwnership: () => Promise<boolean>;
delegateKey: () => Promise<boolean>;
isDelegationValid: () => boolean;
delegationTimeRemaining: () => number;
@ -34,78 +33,81 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [verificationStatus, setVerificationStatus] = useState<VerificationStatus>('unverified');
const { toast } = useToast();
// Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" });
// Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected;
// Get the active account info
const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount;
const address = activeAccount.address;
// Create ref for AuthService so it persists between renders
const authServiceRef = useRef(new AuthService());
// Set AppKit accounts in AuthService
useEffect(() => {
const storedUser = authServiceRef.current.loadStoredUser();
if (storedUser) {
setCurrentUser(storedUser);
authServiceRef.current.setAccounts(bitcoinAccount, ethereumAccount);
}, [bitcoinAccount, ethereumAccount]);
// Sync with AppKit wallet state
useEffect(() => {
if (isConnected && address) {
// Check if we have a stored user for this address
const storedUser = authServiceRef.current.loadStoredUser();
if ('ordinalOwnership' in storedUser) {
setVerificationStatus(storedUser.ordinalOwnership ? 'verified-owner' : 'verified-none');
if (storedUser && storedUser.address === address) {
// Use stored user data
setCurrentUser(storedUser);
setVerificationStatus(getVerificationStatus(storedUser));
} else {
// Create new user from AppKit wallet
const newUser: User = {
address,
walletType: isBitcoinConnected ? 'bitcoin' : 'ethereum',
verificationStatus: 'unverified',
lastChecked: Date.now(),
};
setCurrentUser(newUser);
setVerificationStatus('unverified');
}
}
}, []);
const connectWallet = async () => {
setIsAuthenticating(true);
try {
const result: AuthResult = await authServiceRef.current.connectWallet();
if (!result.success) {
authServiceRef.current.saveUser(newUser);
const chainName = isBitcoinConnected ? 'Bitcoin' : 'Ethereum';
const displayName = `${address.slice(0, 6)}...${address.slice(-4)}`;
toast({
title: "Connection Failed",
description: result.error || "Failed to connect to wallet. Please try again.",
variant: "destructive",
title: "Wallet Connected",
description: `Connected to ${chainName} with ${displayName}`,
});
const verificationType = isBitcoinConnected ? 'Ordinal ownership' : 'ENS ownership';
toast({
title: "Action Required",
description: `Please verify your ${verificationType} and delegate a signing key for better UX.`,
});
throw new Error(result.error);
}
const newUser = result.user!;
setCurrentUser(newUser);
authServiceRef.current.saveUser(newUser);
} else {
// Wallet disconnected
setCurrentUser(null);
setVerificationStatus('unverified');
toast({
title: "Wallet Connected",
description: `Connected with address ${newUser.address.slice(0, 6)}...${newUser.address.slice(-4)}`,
});
toast({
title: "Action Required",
description: "Please verify your Ordinal ownership and delegate a signing key for better UX.",
});
} catch (error) {
console.error("Error connecting wallet:", error);
toast({
title: "Connection Failed",
description: "Failed to connect to wallet. Please try again.",
variant: "destructive",
});
throw error;
} finally {
setIsAuthenticating(false);
}
}, [isConnected, address, isBitcoinConnected, isEthereumConnected, toast]);
const getVerificationStatus = (user: User): VerificationStatus => {
if (user.walletType === 'bitcoin') {
return user.ordinalOwnership ? 'verified-owner' : 'verified-none';
} else if (user.walletType === 'ethereum') {
return user.ensOwnership ? 'verified-owner' : 'verified-none';
}
return 'unverified';
};
const disconnectWallet = () => {
authServiceRef.current.disconnectWallet();
authServiceRef.current.clearStoredUser();
setCurrentUser(null);
setVerificationStatus('unverified');
toast({
title: "Disconnected",
description: "Your wallet has been disconnected.",
});
};
const verifyOrdinal = async (): Promise<boolean> => {
const verifyOwnership = async (): Promise<boolean> => {
if (!currentUser || !currentUser.address) {
toast({
title: "Not Connected",
@ -119,12 +121,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setVerificationStatus('verifying');
try {
const verificationType = currentUser.walletType === 'bitcoin' ? 'Ordinal' : 'ENS';
toast({
title: "Verifying Ordinal",
description: "Checking your wallet for Ordinal Operators..."
title: `Verifying ${verificationType}`,
description: `Checking your wallet for ${verificationType} ownership...`
});
const result: AuthResult = await authServiceRef.current.verifyOrdinal(currentUser);
const result: AuthResult = await authServiceRef.current.verifyOwnership(currentUser);
if (!result.success) {
throw new Error(result.error);
@ -135,27 +138,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
authServiceRef.current.saveUser(updatedUser);
// Update verification status
setVerificationStatus(updatedUser.ordinalOwnership ? 'verified-owner' : 'verified-none');
setVerificationStatus(getVerificationStatus(updatedUser));
if (updatedUser.ordinalOwnership) {
if (updatedUser.walletType === 'bitcoin' && updatedUser.ordinalOwnership) {
toast({
title: "Ordinal Verified",
description: "You now have full access. We recommend delegating a key for better UX.",
});
} else if (updatedUser.walletType === 'ethereum' && updatedUser.ensOwnership) {
toast({
title: "ENS Verified",
description: "You now have full access. We recommend delegating a key for better UX.",
});
} else {
const verificationType = updatedUser.walletType === 'bitcoin' ? 'Ordinal Operators' : 'ENS domain';
toast({
title: "Read-Only Access",
description: "No Ordinal Operators found. You have read-only access.",
description: `No ${verificationType} found. You have read-only access.`,
variant: "default",
});
}
return Boolean(updatedUser.ordinalOwnership);
return Boolean(
(updatedUser.walletType === 'bitcoin' && updatedUser.ordinalOwnership) ||
(updatedUser.walletType === 'ethereum' && updatedUser.ensOwnership)
);
} catch (error) {
console.error("Error verifying Ordinal:", error);
console.error("Error verifying ownership:", error);
setVerificationStatus('unverified');
let errorMessage = "Failed to verify Ordinal ownership. Please try again.";
let errorMessage = "Failed to verify ownership. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
}
@ -214,24 +226,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
console.error("Error delegating key:", error);
let errorMessage = "Failed to delegate key. Please try again.";
if (error instanceof Error) {
// Provide specific guidance based on error type
if (error.message.includes("rejected") || error.message.includes("declined") || error.message.includes("denied")) {
errorMessage = "You declined the signature request. Key delegation is optional but improves your experience.";
} else if (error.message.includes("timeout")) {
errorMessage = "Wallet request timed out. Please try again and approve the signature promptly.";
} else if (error.message.includes("Failed to connect wallet")) {
errorMessage = "Unable to connect to Phantom wallet. Please ensure it's installed and unlocked, then try again.";
} else if (error.message.includes("Wallet is not connected")) {
errorMessage = "Wallet connection was lost. Please reconnect your wallet and try again.";
} else {
errorMessage = error.message;
}
errorMessage = error.message;
}
toast({
title: "Delegation Failed",
title: "Delegation Error",
description: errorMessage,
variant: "destructive",
});
@ -241,39 +241,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setIsAuthenticating(false);
}
};
const isDelegationValid = (): boolean => {
return authServiceRef.current.isDelegationValid();
};
const delegationTimeRemaining = (): number => {
return authServiceRef.current.getDelegationTimeRemaining();
};
const isWalletAvailable = (): boolean => {
return authServiceRef.current.getWalletInfo()?.type === 'phantom';
return isConnected;
};
const messageSigning = {
signMessage: async (message: OpchanMessage): Promise<OpchanMessage | null> => {
return authServiceRef.current.signMessage(message);
},
verifyMessage: (message: OpchanMessage): boolean => {
return authServiceRef.current.verifyMessage(message);
}
};
const value: AuthContextType = {
currentUser,
isAuthenticated: Boolean(currentUser && isConnected),
isAuthenticating,
verificationStatus,
verifyOwnership,
delegateKey,
isDelegationValid,
delegationTimeRemaining,
isWalletAvailable,
messageSigning
};
return (
<AuthContext.Provider
value={{
currentUser,
isAuthenticated: !!currentUser?.ordinalOwnership,
isAuthenticating,
verificationStatus,
connectWallet,
disconnectWallet,
verifyOrdinal,
delegateKey,
isDelegationValid,
delegationTimeRemaining,
isWalletAvailable,
messageSigning: {
signMessage: (message: OpchanMessage) => authServiceRef.current.signMessage(message),
verifyMessage: (message: OpchanMessage) => authServiceRef.current.verifyMessage(message),
},
}}
>
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);

View File

@ -1,5 +1,5 @@
import { User } from '@/types';
import { WalletService } from '../wallets';
import { WalletService, AppKitAccount } from '../wallets/index';
import { OrdinalAPI } from '../ordinal';
import { MessageSigning } from '../signatures/message-signing';
import { OpchanMessage } from '@/types';
@ -18,7 +18,14 @@ export class AuthService {
constructor() {
this.walletService = new WalletService();
this.ordinalApi = new OrdinalAPI();
this.messageSigning = new MessageSigning(this.walletService['keyDelegation']);
this.messageSigning = new MessageSigning(this.walletService.getKeyDelegation());
}
/**
* Set AppKit accounts for wallet service
*/
setAccounts(bitcoinAccount: AppKitAccount, ethereumAccount: AppKitAccount) {
this.walletService.setAccounts(bitcoinAccount, ethereumAccount);
}
/**
@ -26,20 +33,27 @@ export class AuthService {
*/
async connectWallet(): Promise<AuthResult> {
try {
if (!this.walletService.isWalletAvailable('phantom')) {
const walletInfo = await this.walletService.getWalletInfo();
if (!walletInfo) {
return {
success: false,
error: 'Phantom wallet not installed'
error: 'No wallet connected'
};
}
const address = await this.walletService.connectWallet('phantom');
const user: User = {
address,
address: walletInfo.address,
walletType: walletInfo.walletType,
verificationStatus: 'unverified',
lastChecked: Date.now(),
};
// Add ENS info for Ethereum wallets
if (walletInfo.walletType === 'ethereum' && walletInfo.ensName) {
user.ensName = walletInfo.ensName;
user.ensOwnership = true;
}
return {
success: true,
user
@ -56,53 +70,93 @@ export class AuthService {
* Disconnect wallet and clear user data
*/
async disconnectWallet(): Promise<void> {
await this.walletService.disconnectWallet('phantom');
const walletType = this.walletService.getActiveWalletType();
if (walletType) {
await this.walletService.disconnectWallet(walletType);
}
}
/**
* Verify ordinal ownership for a user
* Verify ordinal ownership for Bitcoin users or ENS ownership for Ethereum users
*/
async verifyOrdinal(user: User): Promise<AuthResult> {
async verifyOwnership(user: User): Promise<AuthResult> {
try {
// TODO: revert when the API is ready
// const response = await this.ordinalApi.getOperatorDetails(user.address);
// const hasOperators = response.has_operators;
const hasOperators = true;
const updatedUser = {
...user,
ordinalOwnership: hasOperators,
lastChecked: Date.now(),
};
return {
success: true,
user: updatedUser
};
if (user.walletType === 'bitcoin') {
return await this.verifyBitcoinOrdinal(user);
} else if (user.walletType === 'ethereum') {
return await this.verifyEthereumENS(user);
} else {
return {
success: false,
error: 'Unknown wallet type'
};
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to verify ordinal'
error: error instanceof Error ? error.message : 'Failed to verify ownership'
};
}
}
/**
* Verify Bitcoin Ordinal ownership
*/
private async verifyBitcoinOrdinal(user: User): Promise<AuthResult> {
// TODO: revert when the API is ready
// const response = await this.ordinalApi.getOperatorDetails(user.address);
// const hasOperators = response.has_operators;
const hasOperators = true;
const updatedUser = {
...user,
ordinalOwnership: hasOperators,
lastChecked: Date.now(),
};
return {
success: true,
user: updatedUser
};
}
/**
* Verify Ethereum ENS ownership
*/
private async verifyEthereumENS(user: User): Promise<AuthResult> {
const walletInfo = await this.walletService.getWalletInfo();
const hasENS = walletInfo?.ensName && walletInfo.ensName.length > 0;
const updatedUser = {
...user,
ensOwnership: hasENS,
ensName: walletInfo?.ensName,
lastChecked: Date.now(),
};
return {
success: true,
user: updatedUser
};
}
/**
* Set up key delegation for the user
*/
async delegateKey(user: User): Promise<AuthResult> {
try {
const canConnect = await this.walletService.canConnectWallet('phantom');
const walletType = user.walletType;
const canConnect = await this.walletService.canConnectWallet(walletType);
if (!canConnect) {
return {
success: false,
error: 'Phantom wallet is not available or cannot be connected. Please ensure it is installed and unlocked.'
error: `${walletType} wallet is not available or cannot be connected. Please ensure it is installed and unlocked.`
};
}
const delegationInfo = await this.walletService.setupKeyDelegation(
user.address,
'phantom'
walletType
);
const updatedUser = {
@ -155,8 +209,8 @@ export class AuthService {
/**
* Get current wallet info
*/
getWalletInfo() {
return this.walletService.getWalletInfo();
async getWalletInfo() {
return await this.walletService.getWalletInfo();
}
/**

View File

@ -0,0 +1,225 @@
import { KeyDelegation } from '../signatures/key-delegation';
import { bytesToHex } from '@/lib/utils';
import { getEnsName } from '@wagmi/core';
import { config } from './appkit';
import { UseAppKitAccountReturn } from '@reown/appkit';
export interface WalletInfo {
address: string;
walletType: 'bitcoin' | 'ethereum';
ensName?: string;
isConnected: boolean;
}
export interface DelegationInfo {
browserPublicKey: string;
signature: string;
expiryTimestamp: number;
}
export class ReOwnWalletService {
private keyDelegation: KeyDelegation;
private bitcoinAccount?: UseAppKitAccountReturn;
private ethereumAccount?: UseAppKitAccountReturn;
constructor() {
this.keyDelegation = new KeyDelegation();
}
/**
* Set account references from AppKit hooks
*/
setAccounts(bitcoinAccount: UseAppKitAccountReturn, ethereumAccount: UseAppKitAccountReturn) {
this.bitcoinAccount = bitcoinAccount;
this.ethereumAccount = ethereumAccount;
}
/**
* Check if a wallet type is available and connected
*/
isWalletAvailable(walletType: 'bitcoin' | 'ethereum'): boolean {
if (walletType === 'bitcoin') {
return this.bitcoinAccount?.isConnected || false;
} else {
return this.ethereumAccount?.isConnected || false;
}
}
/**
* Check if wallet can be connected
*/
async canConnectWallet(walletType: 'bitcoin' | 'ethereum'): Promise<boolean> {
// For ReOwn, we assume connection is always possible if AppKit is initialized
return true;
}
/**
* Get wallet connection info with ENS resolution for Ethereum
*/
async getWalletInfo(): Promise<WalletInfo | null> {
if (this.bitcoinAccount?.isConnected) {
return {
address: this.bitcoinAccount.address,
walletType: 'bitcoin',
isConnected: true
};
} else if (this.ethereumAccount?.isConnected) {
// Use Wagmi to resolve ENS name
let ensName: string | undefined;
try {
const resolvedName = await getEnsName(config, {
address: this.ethereumAccount.address as `0x${string}`
});
ensName = resolvedName || undefined;
} catch (error) {
console.warn('Failed to resolve ENS name:', error);
// Continue without ENS name
}
return {
address: this.ethereumAccount.address,
walletType: 'ethereum',
ensName,
isConnected: true
};
}
return null;
}
/**
* Get the active wallet address
*/
getActiveAddress(): string | null {
if (this.bitcoinAccount?.isConnected) {
return this.bitcoinAccount.address;
} else if (this.ethereumAccount?.isConnected) {
return this.ethereumAccount.address;
}
return null;
}
/**
* Get the active wallet type
*/
getActiveWalletType(): 'bitcoin' | 'ethereum' | null {
if (this.bitcoinAccount?.isConnected) {
return 'bitcoin';
} else if (this.ethereumAccount?.isConnected) {
return 'ethereum';
}
return null;
}
/**
* Setup key delegation for the connected wallet
*/
async setupKeyDelegation(
address: string,
walletType: 'bitcoin' | 'ethereum'
): Promise<DelegationInfo> {
// Generate browser keypair
const keypair = this.keyDelegation.generateKeypair();
// Create delegation message with chain-specific format
const expiryTimestamp = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
const delegationMessage = this.createDelegationMessage(
keypair.publicKey,
address,
walletType,
expiryTimestamp
);
// Get the appropriate account for signing
const account = walletType === 'bitcoin' ? this.bitcoinAccount : this.ethereumAccount;
if (!account?.isConnected) {
throw new Error(`${walletType} wallet is not connected`);
}
// Sign the delegation message
const signature = await this.signMessage(delegationMessage, walletType);
// Create and store delegation
const delegationInfo = this.keyDelegation.createDelegation(
address,
signature,
keypair.publicKey,
keypair.privateKey,
24
);
this.keyDelegation.storeDelegation(delegationInfo);
return {
browserPublicKey: keypair.publicKey,
signature,
expiryTimestamp
};
}
/**
* Create chain-specific delegation message
*/
private createDelegationMessage(
browserPublicKey: string,
address: string,
walletType: 'bitcoin' | 'ethereum',
expiryTimestamp: number
): string {
const chainName = walletType === 'bitcoin' ? 'Bitcoin' : 'Ethereum';
const expiryDate = new Date(expiryTimestamp).toISOString();
return `I, ${address} (${chainName}), delegate authority to this pubkey: ${browserPublicKey} until ${expiryDate}`;
}
/**
* Sign a message with the appropriate wallet
*/
private async signMessage(message: string, walletType: 'bitcoin' | 'ethereum'): Promise<string> {
const account = walletType === 'bitcoin' ? this.bitcoinAccount : this.ethereumAccount;
if (!account?.isConnected) {
throw new Error(`${walletType} wallet is not connected`);
}
// Convert message to bytes for signing
const messageBytes = new TextEncoder().encode(message);
// Sign with the appropriate wallet
const signature = await account.signMessage({ message: messageBytes });
// Return hex-encoded signature
return bytesToHex(signature);
}
/**
* Disconnect wallet (handled by AppKit)
*/
async disconnectWallet(walletType: 'bitcoin' | 'ethereum'): Promise<void> {
// Clear stored delegation
this.keyDelegation.clearDelegation();
// Note: Actual disconnection is handled by AppKit's useDisconnect hook
}
/**
* Check if delegation is valid
*/
isDelegationValid(): boolean {
return this.keyDelegation.isDelegationValid();
}
/**
* Get delegation time remaining
*/
getDelegationTimeRemaining(): number {
return this.keyDelegation.getDelegationTimeRemaining();
}
/**
* Get the key delegation instance
*/
getKeyDelegation(): KeyDelegation {
return this.keyDelegation;
}
}

View File

@ -0,0 +1,49 @@
import { AppKitOptions } from '@reown/appkit'
import { BitcoinAdapter } from '@reown/appkit-adapter-bitcoin'
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
import { createStorage } from 'wagmi'
import { mainnet, bitcoin, AppKitNetwork } from '@reown/appkit/networks'
const networks: [AppKitNetwork, ...AppKitNetwork[]] = [mainnet, bitcoin]
const projectId = process.env.VITE_REOWN_SECRET || '2ead96ea166a03e5ab50e5c190532e72'
if (!projectId) {
throw new Error('VITE_REOWN_SECRET is not defined. Please set it in your .env file')
}
export const wagmiAdapter = new WagmiAdapter({
storage: createStorage({ storage: localStorage }),
ssr: false, // Set to false for Vite/React apps
projectId,
networks
})
// Export the Wagmi config for the provider
export const config = wagmiAdapter.wagmiConfig
const bitcoinAdapter = new BitcoinAdapter({
projectId
})
const metadata = {
name: 'OpChan',
description: 'Decentralized forum powered by Bitcoin Ordinals',
url: process.env.NODE_ENV === 'production' ? 'https://opchan.app' : 'http://localhost:8080',
icons: ['https://opchan.com/logo.png']
}
export const appkitConfig: AppKitOptions = {
adapters: [wagmiAdapter, bitcoinAdapter],
networks,
metadata,
projectId,
features: {
analytics: false,
socials: false,
allWallets: false,
},
enableWalletConnect: false
}

View File

@ -1,216 +1,2 @@
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();
}
/**
* Check if wallet is available and can be connected
*/
public async canConnectWallet(type: WalletType = 'phantom'): Promise<boolean> {
if (!this.isWalletAvailable(type)) {
return false;
}
try {
const isConnected = await this.phantomAdapter.isConnected();
return isConnected;
} catch (error) {
console.debug('WalletService: Cannot connect wallet:', error);
return false;
}
}
/**
* 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'>> {
console.debug('WalletService: Starting key delegation for address:', bitcoinAddress);
let isConnected = await this.phantomAdapter.isConnected();
console.debug('WalletService: Initial wallet connection check result:', isConnected);
if (!isConnected) {
console.debug('WalletService: Wallet not connected, attempting to connect automatically');
try {
await this.phantomAdapter.connect();
isConnected = await this.phantomAdapter.isConnected();
console.debug('WalletService: Auto-connection result:', isConnected);
} catch (error) {
console.error('WalletService: Failed to auto-connect wallet:', error);
throw new Error('Failed to connect wallet. Please ensure Phantom wallet is installed and try again.');
}
}
if (!isConnected) {
console.error('WalletService: Wallet is still not connected after auto-connection attempt');
throw new Error('Wallet is not connected. Please connect your wallet first.');
}
// Generate browser keypair
const keypair = this.keyDelegation.generateKeypair();
console.debug('WalletService: Generated browser keypair');
// Calculate expiry in hours
const expiryHours = validityPeriod / (60 * 60 * 1000);
// Create delegation message
const delegationMessage = this.keyDelegation.createDelegationMessage(
keypair.publicKey,
bitcoinAddress,
Date.now() + validityPeriod
);
console.debug('WalletService: Created delegation message');
// Sign the delegation message with the Bitcoin wallet
console.debug('WalletService: Requesting signature from wallet');
const signature = await this.phantomAdapter.signMessage(delegationMessage);
console.debug('WalletService: Received signature from wallet');
// Create and store the delegation
const delegationInfo = this.keyDelegation.createDelegation(
bitcoinAddress,
signature,
keypair.publicKey,
keypair.privateKey,
expiryHours
);
this.keyDelegation.storeDelegation(delegationInfo);
console.debug('WalletService: Stored delegation info');
// 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();
}
}
export { ReOwnWalletService as WalletService } from './ReOwnWalletService';
export type { WalletInfo, DelegationInfo, AppKitAccount } from './ReOwnWalletService';

View File

@ -1,216 +0,0 @@
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();
this.restoreConnectionState();
}
/**
* Restore connection state from existing wallet connection
*/
private async restoreConnectionState(): Promise<void> {
if (typeof window === 'undefined' || !window?.phantom?.bitcoin) {
console.debug('PhantomWalletAdapter: No wallet available for connection restoration');
return;
}
try {
console.debug('PhantomWalletAdapter: Attempting to restore connection state');
this.provider = window.phantom;
this.btcProvider = window.phantom.bitcoin;
// Check if wallet is already connected by trying to get accounts
if (this.btcProvider?.requestAccounts) {
const btcAccounts = await this.btcProvider.requestAccounts();
if (btcAccounts && btcAccounts.length > 0) {
const ordinalAccount = btcAccounts.find(acc => acc.purpose === 'ordinals');
const account = ordinalAccount || btcAccounts[0];
this.currentAccount = account.address;
this.connectionStatus = WalletConnectionStatus.Connected;
console.debug('PhantomWalletAdapter: Successfully restored connection for account:', account.address);
} else {
console.debug('PhantomWalletAdapter: No accounts found during connection restoration');
}
} else {
console.debug('PhantomWalletAdapter: requestAccounts method not available');
}
} catch (error) {
// If we can't restore the connection, that's okay - user will need to reconnect
console.debug('PhantomWalletAdapter: Could not restore existing wallet connection:', error);
this.connectionStatus = WalletConnectionStatus.Disconnected;
}
}
public getStatus(): WalletConnectionStatus {
return this.connectionStatus;
}
/**
* Check if the wallet is actually connected by attempting to get accounts
*/
public async isConnected(): Promise<boolean> {
if (!this.btcProvider) {
return false;
}
try {
if (this.btcProvider.requestAccounts) {
const accounts = await this.btcProvider.requestAccounts();
return accounts && accounts.length > 0;
}
return false;
} catch (error) {
console.debug('Error checking wallet connection:', error);
return false;
}
}
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> {
console.debug('PhantomWalletAdapter: signMessage called, btcProvider:', !!this.btcProvider, 'currentAccount:', this.currentAccount);
if (!this.btcProvider && window?.phantom?.bitcoin) {
console.debug('PhantomWalletAdapter: Attempting to restore connection before signing');
await this.restoreConnectionState();
}
if (!this.btcProvider || !this.currentAccount) {
console.debug('PhantomWalletAdapter: Wallet not connected, attempting to connect automatically');
try {
await this.connect();
} catch (error) {
console.error('PhantomWalletAdapter: Failed to auto-connect wallet:', error);
throw new Error('Failed to connect wallet. Please ensure Phantom wallet is installed and try again.');
}
}
if (!this.btcProvider) {
console.error('PhantomWalletAdapter: Wallet is not connected - no btcProvider');
throw new Error('Wallet is not connected');
}
if (!this.currentAccount) {
console.error('PhantomWalletAdapter: No active account to sign with');
throw new Error('No active account to sign with');
}
try {
if (!this.btcProvider.signMessage) {
throw new Error('signMessage method not available on wallet provider');
}
console.debug('PhantomWalletAdapter: Signing message for account:', this.currentAccount);
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 String(signature);
} catch (error) {
console.error('PhantomWalletAdapter: 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

@ -1,34 +0,0 @@
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
src/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
import 'react';
// Ensures file is treated as a module
export {};

View File

@ -3,12 +3,23 @@ import { CellMessage, CommentMessage, PostMessage, VoteMessage, ModerateMessage
export type OpchanMessage = CellMessage | PostMessage | CommentMessage | VoteMessage | ModerateMessage;
export interface User {
address: string;
address: string;
walletType: 'bitcoin' | 'ethereum';
// Bitcoin-specific
ordinalOwnership?: boolean | { id: string; details: string };
// Ethereum-specific
ensName?: string;
ensAvatar?: string;
ensOwnership?: boolean;
verificationStatus: 'unverified' | 'verified-none' | 'verified-owner' | 'verifying';
signature?: string;
lastChecked?: number;
browserPubKey?: string; // Browser-generated public key for key delegation
delegationSignature?: string; // Signature from Bitcoin wallet for delegation
delegationSignature?: string; // Signature from Bitcoin/Ethereum wallet for delegation
delegationExpiry?: number; // When the delegation expires
}