feat: add Web3 wallet linking to CryptID accounts
- Add WalletLinkPanel component for connecting and linking wallets - Add useWallet hooks (useWalletConnection, useWalletLink, useLinkedWallets) - Add wallet API endpoints in worker (link, list, update, unlink, verify) - Add proper signature verification with @noble/hashes and @noble/secp256k1 - Add D1 migration for linked_wallets table - Integrate wallet section into Settings > Integrations tab - Support for MetaMask, WalletConnect, Coinbase Wallet - Multi-chain support: Ethereum, Optimism, Arbitrum, Base, Polygon 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9410961486
commit
80f457f615
|
|
@ -16,6 +16,10 @@ VITE_RUNPOD_IMAGE_ENDPOINT_ID='your_image_endpoint_id' # Automatic1111/SD
|
||||||
VITE_RUNPOD_VIDEO_ENDPOINT_ID='your_video_endpoint_id' # Wan2.2
|
VITE_RUNPOD_VIDEO_ENDPOINT_ID='your_video_endpoint_id' # Wan2.2
|
||||||
VITE_RUNPOD_WHISPER_ENDPOINT_ID='your_whisper_endpoint_id' # WhisperX
|
VITE_RUNPOD_WHISPER_ENDPOINT_ID='your_whisper_endpoint_id' # WhisperX
|
||||||
|
|
||||||
|
# WalletConnect (Web3 wallet integration)
|
||||||
|
# Get your project ID at https://cloud.walletconnect.com/
|
||||||
|
VITE_WALLETCONNECT_PROJECT_ID='your_walletconnect_project_id'
|
||||||
|
|
||||||
# Worker-only Variables (Do not prefix with VITE_)
|
# Worker-only Variables (Do not prefix with VITE_)
|
||||||
CLOUDFLARE_API_TOKEN='your_cloudflare_token'
|
CLOUDFLARE_API_TOKEN='your_cloudflare_token'
|
||||||
CLOUDFLARE_ACCOUNT_ID='your_account_id'
|
CLOUDFLARE_ACCOUNT_ID='your_account_id'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,521 @@
|
||||||
|
/**
|
||||||
|
* WalletLinkPanel - UI for connecting and linking Web3 wallets to CryptID
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Connect wallet (MetaMask, WalletConnect, etc.)
|
||||||
|
* - Link wallet to CryptID account via signature
|
||||||
|
* - View and manage linked wallets
|
||||||
|
* - Set primary wallet
|
||||||
|
* - Unlink wallets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
useWalletConnection,
|
||||||
|
useWalletLink,
|
||||||
|
useLinkedWallets,
|
||||||
|
formatAddress,
|
||||||
|
LinkedWallet,
|
||||||
|
} from '../hooks/useWallet';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Styles (inline for simplicity - can be moved to CSS/Tailwind)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '16px',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
section: {
|
||||||
|
marginBottom: '24px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: '12px',
|
||||||
|
color: '#374151',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
card: {
|
||||||
|
background: '#f9fafb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
button: {
|
||||||
|
background: '#4f46e5',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
buttonSecondary: {
|
||||||
|
background: '#e5e7eb',
|
||||||
|
color: '#374151',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
buttonDanger: {
|
||||||
|
background: '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
buttonSmall: {
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
flexRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
flexBetween: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
address: {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#6b7280',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
badge: {
|
||||||
|
background: '#dbeafe',
|
||||||
|
color: '#1d4ed8',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
badgePrimary: {
|
||||||
|
background: '#dcfce7',
|
||||||
|
color: '#166534',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
error: {
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '13px',
|
||||||
|
marginTop: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
success: {
|
||||||
|
color: '#22c55e',
|
||||||
|
fontSize: '13px',
|
||||||
|
marginTop: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
input: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
connectorButton: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '8px',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
walletIcon: {
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '14px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Sub-components
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ConnectWalletSectionProps {
|
||||||
|
onConnect: (connectorId?: string) => void;
|
||||||
|
connectors: Array<{ id: string; name: string; type: string }>;
|
||||||
|
isConnecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectWalletSection({ onConnect, connectors, isConnecting }: ConnectWalletSectionProps) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Connect Wallet</div>
|
||||||
|
{connectors.map((connector) => (
|
||||||
|
<button
|
||||||
|
key={connector.id}
|
||||||
|
onClick={() => onConnect(connector.id)}
|
||||||
|
disabled={isConnecting}
|
||||||
|
style={styles.connectorButton}
|
||||||
|
>
|
||||||
|
<div style={styles.walletIcon}>
|
||||||
|
{connector.name === 'MetaMask' ? '🦊' :
|
||||||
|
connector.name === 'WalletConnect' ? '🔗' :
|
||||||
|
connector.name === 'Coinbase Wallet' ? '🔵' : '👛'}
|
||||||
|
</div>
|
||||||
|
<span>{connector.name}</span>
|
||||||
|
{isConnecting && <span style={{ marginLeft: 'auto', color: '#9ca3af' }}>Connecting...</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectedWalletSectionProps {
|
||||||
|
address: string;
|
||||||
|
ensName: string | null;
|
||||||
|
chainId: number;
|
||||||
|
connectorName: string | undefined;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
isDisconnecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectedWalletSection({
|
||||||
|
address,
|
||||||
|
ensName,
|
||||||
|
chainId,
|
||||||
|
connectorName,
|
||||||
|
onDisconnect,
|
||||||
|
isDisconnecting,
|
||||||
|
}: ConnectedWalletSectionProps) {
|
||||||
|
const chainNames: Record<number, string> = {
|
||||||
|
1: 'Ethereum',
|
||||||
|
10: 'Optimism',
|
||||||
|
137: 'Polygon',
|
||||||
|
42161: 'Arbitrum',
|
||||||
|
8453: 'Base',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Connected Wallet</div>
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.flexBetween}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '4px' }}>
|
||||||
|
{ensName || formatAddress(address)}
|
||||||
|
</div>
|
||||||
|
<div style={styles.address}>{formatAddress(address, 6)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={styles.badge}>{chainNames[chainId] || `Chain ${chainId}`}</div>
|
||||||
|
{connectorName && (
|
||||||
|
<div style={{ fontSize: '11px', color: '#9ca3af', marginTop: '4px' }}>
|
||||||
|
via {connectorName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={onDisconnect}
|
||||||
|
disabled={isDisconnecting}
|
||||||
|
style={styles.buttonSecondary}
|
||||||
|
>
|
||||||
|
{isDisconnecting ? 'Disconnecting...' : 'Disconnect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkWalletSectionProps {
|
||||||
|
address: string;
|
||||||
|
isLinking: boolean;
|
||||||
|
linkError: string | null;
|
||||||
|
onLink: (label?: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkWalletSection({
|
||||||
|
address: _address,
|
||||||
|
isLinking,
|
||||||
|
linkError,
|
||||||
|
onLink,
|
||||||
|
isAuthenticated,
|
||||||
|
}: LinkWalletSectionProps) {
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleLink = async () => {
|
||||||
|
setSuccess(false);
|
||||||
|
const result = await onLink(label || undefined);
|
||||||
|
if (result.success) {
|
||||||
|
setSuccess(true);
|
||||||
|
setLabel('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Link to CryptID</div>
|
||||||
|
<div style={{ ...styles.card, color: '#6b7280' }}>
|
||||||
|
Please sign in with CryptID to link your wallet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Link to CryptID</div>
|
||||||
|
<div style={styles.card}>
|
||||||
|
<p style={{ fontSize: '13px', color: '#6b7280', marginBottom: '12px' }}>
|
||||||
|
Link this wallet to your CryptID account. You'll be asked to sign a message
|
||||||
|
to prove ownership.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Label (optional, e.g., 'Main Wallet')"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleLink}
|
||||||
|
disabled={isLinking}
|
||||||
|
style={styles.button}
|
||||||
|
>
|
||||||
|
{isLinking ? 'Signing...' : 'Link Wallet'}
|
||||||
|
</button>
|
||||||
|
{linkError && <div style={styles.error}>{linkError}</div>}
|
||||||
|
{success && <div style={styles.success}>Wallet linked successfully!</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkedWalletItemProps {
|
||||||
|
wallet: LinkedWallet;
|
||||||
|
onSetPrimary: () => Promise<boolean>;
|
||||||
|
onUnlink: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkedWalletItem({ wallet, onSetPrimary, onUnlink }: LinkedWalletItemProps) {
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
const handleSetPrimary = async () => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
await onSetPrimary();
|
||||||
|
setIsUpdating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlink = async () => {
|
||||||
|
if (!confirm('Are you sure you want to unlink this wallet?')) return;
|
||||||
|
setIsUpdating(true);
|
||||||
|
await onUnlink();
|
||||||
|
setIsUpdating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.flexBetween}>
|
||||||
|
<div>
|
||||||
|
<div style={styles.flexRow}>
|
||||||
|
<span style={{ fontWeight: 500 }}>
|
||||||
|
{wallet.ensName || wallet.label || formatAddress(wallet.address)}
|
||||||
|
</span>
|
||||||
|
{wallet.isPrimary && (
|
||||||
|
<span style={{ ...styles.badge, ...styles.badgePrimary }}>Primary</span>
|
||||||
|
)}
|
||||||
|
<span style={styles.badge}>{wallet.type.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...styles.address, marginTop: '4px' }}>
|
||||||
|
{formatAddress(wallet.address, 8)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...styles.flexRow, gap: '4px' }}>
|
||||||
|
{!wallet.isPrimary && (
|
||||||
|
<button
|
||||||
|
onClick={handleSetPrimary}
|
||||||
|
disabled={isUpdating}
|
||||||
|
style={{ ...styles.buttonSecondary, ...styles.buttonSmall }}
|
||||||
|
>
|
||||||
|
Set Primary
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleUnlink}
|
||||||
|
disabled={isUpdating}
|
||||||
|
style={{ ...styles.buttonDanger, ...styles.buttonSmall }}
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkedWalletsSectionProps {
|
||||||
|
wallets: LinkedWallet[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onUpdateWallet: (address: string, updates: { isPrimary?: boolean }) => Promise<boolean>;
|
||||||
|
onUnlinkWallet: (address: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkedWalletsSection({
|
||||||
|
wallets,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onUpdateWallet,
|
||||||
|
onUnlinkWallet,
|
||||||
|
}: LinkedWalletsSectionProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||||
|
<div style={{ color: '#9ca3af', fontSize: '13px' }}>Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||||
|
<div style={styles.error}>{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wallets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||||
|
<div style={{ color: '#9ca3af', fontSize: '13px' }}>
|
||||||
|
No wallets linked yet. Connect a wallet and link it above.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Linked Wallets ({wallets.length})</div>
|
||||||
|
{wallets.map((wallet) => (
|
||||||
|
<LinkedWalletItem
|
||||||
|
key={wallet.id}
|
||||||
|
wallet={wallet}
|
||||||
|
onSetPrimary={() => onUpdateWallet(wallet.address, { isPrimary: true })}
|
||||||
|
onUnlink={() => onUnlinkWallet(wallet.address)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function WalletLinkPanel() {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const {
|
||||||
|
address,
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
chainId,
|
||||||
|
connectorName,
|
||||||
|
ensName,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
isDisconnecting,
|
||||||
|
connectors,
|
||||||
|
} = useWalletConnection();
|
||||||
|
|
||||||
|
const { isLinking, linkError, linkWallet, clearError } = useWalletLink();
|
||||||
|
|
||||||
|
const {
|
||||||
|
wallets,
|
||||||
|
isLoading: isLoadingWallets,
|
||||||
|
error: walletsError,
|
||||||
|
updateWallet,
|
||||||
|
unlinkWallet,
|
||||||
|
refetch: refetchWallets,
|
||||||
|
} = useLinkedWallets();
|
||||||
|
|
||||||
|
// Check if the connected wallet is already linked
|
||||||
|
const isCurrentWalletLinked = address
|
||||||
|
? wallets.some(w => w.address.toLowerCase() === address.toLowerCase())
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const handleLink = async (label?: string) => {
|
||||||
|
clearError();
|
||||||
|
const result = await linkWallet(label);
|
||||||
|
if (result.success) {
|
||||||
|
await refetchWallets();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600 }}>
|
||||||
|
Web3 Wallet
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!isConnected ? (
|
||||||
|
<ConnectWalletSection
|
||||||
|
onConnect={connect}
|
||||||
|
connectors={connectors}
|
||||||
|
isConnecting={isConnecting}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ConnectedWalletSection
|
||||||
|
address={address!}
|
||||||
|
ensName={ensName}
|
||||||
|
chainId={chainId}
|
||||||
|
connectorName={connectorName}
|
||||||
|
onDisconnect={disconnect}
|
||||||
|
isDisconnecting={isDisconnecting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isCurrentWalletLinked && (
|
||||||
|
<LinkWalletSection
|
||||||
|
address={address!}
|
||||||
|
isLinking={isLinking}
|
||||||
|
linkError={linkError}
|
||||||
|
onLink={handleLink}
|
||||||
|
isAuthenticated={session.authed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinkedWalletsSection
|
||||||
|
wallets={wallets}
|
||||||
|
isLoading={isLoadingWallets}
|
||||||
|
error={walletsError}
|
||||||
|
onUpdateWallet={updateWallet}
|
||||||
|
onUnlinkWallet={unlinkWallet}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WalletLinkPanel;
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
/**
|
||||||
|
* useWallet - Hooks for Web3 wallet integration
|
||||||
|
*
|
||||||
|
* Provides functionality for:
|
||||||
|
* - Connecting/disconnecting wallets
|
||||||
|
* - Linking wallets to CryptID accounts
|
||||||
|
* - Managing linked wallets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useAccount,
|
||||||
|
useConnect,
|
||||||
|
useDisconnect,
|
||||||
|
useSignMessage,
|
||||||
|
useEnsName,
|
||||||
|
useEnsAvatar,
|
||||||
|
useChainId,
|
||||||
|
} from 'wagmi';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { WORKER_URL } from '../constants/workerUrl';
|
||||||
|
import * as crypto from '../lib/auth/crypto';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||||
|
|
||||||
|
export interface LinkedWallet {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
type: WalletType;
|
||||||
|
chainId: number;
|
||||||
|
label: string | null;
|
||||||
|
ensName: string | null;
|
||||||
|
ensAvatar: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
linkedAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkWalletResult {
|
||||||
|
success: boolean;
|
||||||
|
wallet?: LinkedWallet;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Message Generation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the message that must be signed to link a wallet
|
||||||
|
*/
|
||||||
|
function generateLinkMessage(
|
||||||
|
username: string,
|
||||||
|
address: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string
|
||||||
|
): string {
|
||||||
|
return `Link wallet to CryptID
|
||||||
|
|
||||||
|
Account: ${username}
|
||||||
|
Wallet: ${address}
|
||||||
|
Timestamp: ${timestamp}
|
||||||
|
Nonce: ${nonce}
|
||||||
|
|
||||||
|
This signature proves you own this wallet.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useWalletConnection - Basic wallet connection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useWalletConnection() {
|
||||||
|
const { address, isConnected, isConnecting, connector } = useAccount();
|
||||||
|
const { connect, connectors, isPending: isConnectPending } = useConnect();
|
||||||
|
const { disconnect, isPending: isDisconnectPending } = useDisconnect();
|
||||||
|
const chainId = useChainId();
|
||||||
|
|
||||||
|
// ENS data for connected wallet
|
||||||
|
const { data: ensName } = useEnsName({ address });
|
||||||
|
const { data: ensAvatar } = useEnsAvatar({ name: ensName || undefined });
|
||||||
|
|
||||||
|
const connectWallet = useCallback((connectorId?: string) => {
|
||||||
|
const targetConnector = connectorId
|
||||||
|
? connectors.find(c => c.id === connectorId)
|
||||||
|
: connectors[0]; // Default to first connector (usually injected)
|
||||||
|
|
||||||
|
if (targetConnector) {
|
||||||
|
connect({ connector: targetConnector });
|
||||||
|
}
|
||||||
|
}, [connect, connectors]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Connection state
|
||||||
|
address,
|
||||||
|
isConnected,
|
||||||
|
isConnecting: isConnecting || isConnectPending,
|
||||||
|
chainId,
|
||||||
|
connectorName: connector?.name,
|
||||||
|
|
||||||
|
// ENS data
|
||||||
|
ensName: ensName || null,
|
||||||
|
ensAvatar: ensAvatar || null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
connect: connectWallet,
|
||||||
|
disconnect,
|
||||||
|
isDisconnecting: isDisconnectPending,
|
||||||
|
|
||||||
|
// Available connectors
|
||||||
|
connectors: connectors.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
type: c.type,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useWalletLink - Link wallet to CryptID
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useWalletLink() {
|
||||||
|
const { address, isConnected } = useAccount();
|
||||||
|
const { signMessageAsync } = useSignMessage();
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
|
const [linkError, setLinkError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const linkWallet = useCallback(async (label?: string): Promise<LinkWalletResult> => {
|
||||||
|
if (!address) {
|
||||||
|
return { success: false, error: 'No wallet connected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.authed || !session.username) {
|
||||||
|
return { success: false, error: 'Not authenticated with CryptID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLinking(true);
|
||||||
|
setLinkError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate the message to sign
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const nonce = globalThis.crypto.randomUUID();
|
||||||
|
const message = generateLinkMessage(
|
||||||
|
session.username,
|
||||||
|
address,
|
||||||
|
timestamp,
|
||||||
|
nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request signature from wallet
|
||||||
|
const signature = await signMessageAsync({ message });
|
||||||
|
|
||||||
|
// Get public key for auth header
|
||||||
|
const publicKey = crypto.getPublicKey(session.username);
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new Error('Could not get CryptID public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to backend for verification
|
||||||
|
const response = await fetch(`${WORKER_URL}/api/wallet/link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CryptID-PublicKey': publicKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
walletAddress: address,
|
||||||
|
signature,
|
||||||
|
message,
|
||||||
|
label,
|
||||||
|
walletType: 'eoa',
|
||||||
|
chainId: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as { error?: string; wallet?: LinkedWallet };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to link wallet');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
wallet: data.wallet,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setLinkError(errorMessage);
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
} finally {
|
||||||
|
setIsLinking(false);
|
||||||
|
}
|
||||||
|
}, [address, session.authed, session.username, signMessageAsync]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
isConnected,
|
||||||
|
isLinking,
|
||||||
|
linkError,
|
||||||
|
linkWallet,
|
||||||
|
clearError: () => setLinkError(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useLinkedWallets - Manage linked wallets
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useLinkedWallets() {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [wallets, setWallets] = useState<LinkedWallet[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch linked wallets
|
||||||
|
const fetchWallets = useCallback(async () => {
|
||||||
|
if (!session.authed || !session.username) {
|
||||||
|
setWallets([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = crypto.getPublicKey(session.username);
|
||||||
|
if (!publicKey) {
|
||||||
|
setError('Could not get CryptID public key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_URL}/api/wallet/list`, {
|
||||||
|
headers: {
|
||||||
|
'X-CryptID-PublicKey': publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as { error?: string; wallets?: LinkedWallet[] };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch wallets');
|
||||||
|
}
|
||||||
|
|
||||||
|
setWallets(data.wallets || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [session.authed, session.username]);
|
||||||
|
|
||||||
|
// Fetch on mount and when session changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWallets();
|
||||||
|
}, [fetchWallets]);
|
||||||
|
|
||||||
|
// Update a wallet
|
||||||
|
const updateWallet = useCallback(async (
|
||||||
|
address: string,
|
||||||
|
updates: { label?: string; isPrimary?: boolean }
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!session.username) return false;
|
||||||
|
|
||||||
|
const publicKey = crypto.getPublicKey(session.username);
|
||||||
|
if (!publicKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_URL}/api/wallet/${address}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CryptID-PublicKey': publicKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchWallets(); // Refresh list
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [session.username, fetchWallets]);
|
||||||
|
|
||||||
|
// Unlink a wallet
|
||||||
|
const unlinkWallet = useCallback(async (address: string): Promise<boolean> => {
|
||||||
|
if (!session.username) return false;
|
||||||
|
|
||||||
|
const publicKey = crypto.getPublicKey(session.username);
|
||||||
|
if (!publicKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_URL}/api/wallet/${address}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CryptID-PublicKey': publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchWallets(); // Refresh list
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [session.username, fetchWallets]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
wallets,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch: fetchWallets,
|
||||||
|
updateWallet,
|
||||||
|
unlinkWallet,
|
||||||
|
primaryWallet: wallets.find(w => w.isPrimary) || wallets[0] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utility functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an address for display (0x1234...5678)
|
||||||
|
*/
|
||||||
|
export function formatAddress(address: string, chars = 4): string {
|
||||||
|
if (!address) return '';
|
||||||
|
return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an address is valid
|
||||||
|
*/
|
||||||
|
export function isValidAddress(address: string): boolean {
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyCo
|
||||||
import { linkEmailToAccount, checkEmailStatus, type LookupResult } from "../lib/auth/cryptidEmailService"
|
import { linkEmailToAccount, checkEmailStatus, type LookupResult } from "../lib/auth/cryptidEmailService"
|
||||||
import { GoogleDataService, type GoogleService, type ShareableItem } from "../lib/google"
|
import { GoogleDataService, type GoogleService, type ShareableItem } from "../lib/google"
|
||||||
import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
|
import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
|
||||||
|
import { WalletLinkPanel } from "../components/WalletLinkPanel"
|
||||||
|
|
||||||
// AI tool model configurations
|
// AI tool model configurations
|
||||||
const AI_TOOLS = [
|
const AI_TOOLS = [
|
||||||
|
|
@ -977,6 +978,25 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-divider" />
|
||||||
|
|
||||||
|
{/* Web3 Wallet Section */}
|
||||||
|
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', marginTop: '8px', color: colors.text }}>
|
||||||
|
Web3 Wallets
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0',
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${colors.cardBorder}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WalletLinkPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Future Integrations Placeholder */}
|
{/* Future Integrations Placeholder */}
|
||||||
<div style={{ marginTop: '16px', padding: '12px', backgroundColor: colors.legendBg, borderRadius: '6px', border: `1px dashed ${colors.cardBorder}` }}>
|
<div style={{ marginTop: '16px', padding: '12px', backgroundColor: colors.legendBg, borderRadius: '6px', border: `1px dashed ${colors.cardBorder}` }}>
|
||||||
<p style={{ fontSize: '12px', color: colors.textMuted, textAlign: 'center' }}>
|
<p style={{ fontSize: '12px', color: colors.textMuted, textAlign: 'center' }}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
-- Migration: Add Linked Wallets for Web3 Integration
|
||||||
|
-- Date: 2026-01-02
|
||||||
|
-- Description: Enables CryptID users to link Ethereum wallets for
|
||||||
|
-- on-chain transactions, voting, and token-gated features.
|
||||||
|
-- Related: task-007, doc-001
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- LINKED WALLETS TABLE
|
||||||
|
-- =============================================================================
|
||||||
|
-- Each CryptID user can link multiple Ethereum wallets (EOA, Safe, hardware)
|
||||||
|
-- Linking requires signature verification to prove wallet ownership
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS linked_wallets (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID for the link record
|
||||||
|
user_id TEXT NOT NULL, -- References users.id (CryptID account)
|
||||||
|
wallet_address TEXT NOT NULL, -- Ethereum address (checksummed, 0x-prefixed)
|
||||||
|
|
||||||
|
-- Wallet metadata
|
||||||
|
wallet_type TEXT DEFAULT 'eoa' CHECK (wallet_type IN ('eoa', 'safe', 'hardware', 'contract')),
|
||||||
|
chain_id INTEGER DEFAULT 1, -- Primary chain (1 = Ethereum mainnet)
|
||||||
|
label TEXT, -- User-provided label (e.g., "Main Wallet")
|
||||||
|
|
||||||
|
-- Verification proof
|
||||||
|
signature_message TEXT NOT NULL, -- The message that was signed
|
||||||
|
signature TEXT NOT NULL, -- EIP-191 personal_sign signature
|
||||||
|
verified_at TEXT NOT NULL, -- When signature was verified
|
||||||
|
|
||||||
|
-- ENS integration
|
||||||
|
ens_name TEXT, -- Resolved ENS name (if any)
|
||||||
|
ens_avatar TEXT, -- ENS avatar URL (if any)
|
||||||
|
ens_resolved_at TEXT, -- When ENS was last resolved
|
||||||
|
|
||||||
|
-- Flags
|
||||||
|
is_primary INTEGER DEFAULT 0, -- 1 = primary wallet for this user
|
||||||
|
is_active INTEGER DEFAULT 1, -- 0 = soft-deleted
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
last_used_at TEXT, -- Last time wallet was used for action
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, wallet_address) -- Can't link same wallet twice
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for efficient lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user ON linked_wallets(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address ON linked_wallets(wallet_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_active ON linked_wallets(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_primary ON linked_wallets(user_id, is_primary);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- WALLET LINKING TOKENS TABLE (for Safe/multisig delayed verification)
|
||||||
|
-- =============================================================================
|
||||||
|
-- For contract wallets that require on-chain signature verification
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_link_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
wallet_address TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL, -- Random nonce for signature message
|
||||||
|
token TEXT NOT NULL UNIQUE, -- Secret token for verification callback
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_link_tokens_token ON wallet_link_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_link_tokens_user ON wallet_link_tokens(user_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- TOKEN BALANCES CACHE (optional, for token-gating)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Cache of token balances for faster permission checks
|
||||||
|
-- Updated periodically or on-demand
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_token_balances (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet_address TEXT NOT NULL,
|
||||||
|
token_address TEXT NOT NULL, -- ERC-20/721/1155 contract address
|
||||||
|
token_type TEXT CHECK (token_type IN ('erc20', 'erc721', 'erc1155')),
|
||||||
|
chain_id INTEGER NOT NULL,
|
||||||
|
balance TEXT NOT NULL, -- String to handle big numbers
|
||||||
|
last_updated TEXT DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
UNIQUE(wallet_address, token_address, chain_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_balances_wallet ON wallet_token_balances(wallet_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_balances_token ON wallet_token_balances(token_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_balances_chain ON wallet_token_balances(chain_id);
|
||||||
|
|
@ -232,4 +232,95 @@ export interface NetworkGraph {
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
// Current user's connections (for filtering)
|
// Current user's connections (for filtering)
|
||||||
myConnections: string[]; // User IDs I'm connected to
|
myConnections: string[]; // User IDs I'm connected to
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Linked Wallet Types (Web3 Integration)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wallet types supported for linking
|
||||||
|
* - 'eoa': Externally Owned Account (standard wallet)
|
||||||
|
* - 'safe': Safe (Gnosis Safe) multisig
|
||||||
|
* - 'hardware': Hardware wallet (via MetaMask bridge)
|
||||||
|
* - 'contract': Other smart contract wallet
|
||||||
|
*/
|
||||||
|
export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linked wallet record in the database
|
||||||
|
*/
|
||||||
|
export interface LinkedWallet {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
wallet_type: WalletType;
|
||||||
|
chain_id: number;
|
||||||
|
label: string | null;
|
||||||
|
signature_message: string;
|
||||||
|
signature: string;
|
||||||
|
verified_at: string;
|
||||||
|
ens_name: string | null;
|
||||||
|
ens_avatar: string | null;
|
||||||
|
ens_resolved_at: string | null;
|
||||||
|
is_primary: number; // SQLite boolean (0 or 1)
|
||||||
|
is_active: number; // SQLite boolean (0 or 1)
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wallet link token for delayed verification (Safe/contract wallets)
|
||||||
|
*/
|
||||||
|
export interface WalletLinkToken {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
nonce: string;
|
||||||
|
token: string;
|
||||||
|
expires_at: string;
|
||||||
|
used: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached token balance for a wallet
|
||||||
|
*/
|
||||||
|
export interface WalletTokenBalance {
|
||||||
|
id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
token_address: string;
|
||||||
|
token_type: 'erc20' | 'erc721' | 'erc1155';
|
||||||
|
chain_id: number;
|
||||||
|
balance: string; // String to handle big numbers
|
||||||
|
last_updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API response format for linked wallets
|
||||||
|
*/
|
||||||
|
export interface LinkedWalletResponse {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
type: WalletType;
|
||||||
|
chainId: number;
|
||||||
|
label: string | null;
|
||||||
|
ensName: string | null;
|
||||||
|
ensAvatar: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
linkedAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for linking a wallet
|
||||||
|
*/
|
||||||
|
export interface WalletLinkRequest {
|
||||||
|
walletAddress: string;
|
||||||
|
signature: string;
|
||||||
|
message: string;
|
||||||
|
walletType?: WalletType;
|
||||||
|
chainId?: number;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,769 @@
|
||||||
|
/**
|
||||||
|
* Wallet Authentication Module
|
||||||
|
*
|
||||||
|
* Handles signature verification for linking Ethereum wallets to CryptID accounts.
|
||||||
|
* Uses EIP-191 personal_sign for EOA wallets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Environment,
|
||||||
|
User,
|
||||||
|
LinkedWallet,
|
||||||
|
LinkedWalletResponse,
|
||||||
|
WalletLinkRequest,
|
||||||
|
WalletType
|
||||||
|
} from './types';
|
||||||
|
// @ts-ignore - noble packages have proper ESM exports
|
||||||
|
import { keccak_256 } from '@noble/hashes/sha3.js';
|
||||||
|
// @ts-ignore - noble packages have proper ESM exports
|
||||||
|
import { recoverPublicKey, Signature } from '@noble/secp256k1';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Maximum age for signature timestamps (5 minutes)
|
||||||
|
const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Maximum wallets per user
|
||||||
|
const MAX_WALLETS_PER_USER = 10;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Message Generation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the message that must be signed to link a wallet
|
||||||
|
*/
|
||||||
|
export function generateLinkMessage(
|
||||||
|
username: string,
|
||||||
|
address: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string
|
||||||
|
): string {
|
||||||
|
return `Link wallet to CryptID
|
||||||
|
|
||||||
|
Account: ${username}
|
||||||
|
Wallet: ${address}
|
||||||
|
Timestamp: ${timestamp}
|
||||||
|
Nonce: ${nonce}
|
||||||
|
|
||||||
|
This signature proves you own this wallet.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a link message
|
||||||
|
*/
|
||||||
|
export function parseLinkMessage(message: string): {
|
||||||
|
username: string;
|
||||||
|
address: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
const lines = message.split('\n');
|
||||||
|
|
||||||
|
// Find the relevant lines
|
||||||
|
const accountLine = lines.find(l => l.startsWith('Account: '));
|
||||||
|
const walletLine = lines.find(l => l.startsWith('Wallet: '));
|
||||||
|
const timestampLine = lines.find(l => l.startsWith('Timestamp: '));
|
||||||
|
const nonceLine = lines.find(l => l.startsWith('Nonce: '));
|
||||||
|
|
||||||
|
if (!accountLine || !walletLine || !timestampLine || !nonceLine) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: accountLine.replace('Account: ', '').trim(),
|
||||||
|
address: walletLine.replace('Wallet: ', '').trim(),
|
||||||
|
timestamp: timestampLine.replace('Timestamp: ', '').trim(),
|
||||||
|
nonce: nonceLine.replace('Nonce: ', '').trim(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Address Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an Ethereum address format
|
||||||
|
*/
|
||||||
|
export function isValidAddress(address: string): boolean {
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checksum an Ethereum address (EIP-55)
|
||||||
|
* This is a simplified version - in production use viem's getAddress
|
||||||
|
*/
|
||||||
|
export function checksumAddress(address: string): string {
|
||||||
|
// For now, just return lowercase - viem will handle proper checksumming
|
||||||
|
return address.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Signature Verification
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an EIP-191 personal_sign signature
|
||||||
|
*
|
||||||
|
* This uses the ecrecover approach to recover the signer address from the signature.
|
||||||
|
* For Cloudflare Workers, we need to implement this without ethers/viem dependencies
|
||||||
|
* in the worker bundle (they're too large).
|
||||||
|
*
|
||||||
|
* Instead, we use a lightweight approach with the Web Crypto API.
|
||||||
|
*/
|
||||||
|
export function verifyPersonalSignature(
|
||||||
|
address: string,
|
||||||
|
message: string,
|
||||||
|
signature: string
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
// The signature should be 65 bytes (130 hex chars + 0x prefix)
|
||||||
|
if (!signature.startsWith('0x') || signature.length !== 132) {
|
||||||
|
console.error('Invalid signature format');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For EIP-191 personal_sign, the message is prefixed with:
|
||||||
|
// "\x19Ethereum Signed Message:\n" + message.length + message
|
||||||
|
const prefix = `\x19Ethereum Signed Message:\n${message.length}`;
|
||||||
|
const prefixedMessage = prefix + message;
|
||||||
|
|
||||||
|
// Hash the prefixed message with keccak256
|
||||||
|
const messageHash = keccak256(prefixedMessage);
|
||||||
|
|
||||||
|
// Parse signature components
|
||||||
|
const r = signature.slice(2, 66);
|
||||||
|
const s = signature.slice(66, 130);
|
||||||
|
let v = parseInt(signature.slice(130, 132), 16);
|
||||||
|
|
||||||
|
// Normalize v value (some wallets use 0/1, others use 27/28)
|
||||||
|
if (v < 27) {
|
||||||
|
v += 27;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover the public key and derive the address
|
||||||
|
const recoveredAddress = ecrecover(messageHash, v, r, s);
|
||||||
|
|
||||||
|
if (!recoveredAddress) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare addresses (case-insensitive)
|
||||||
|
return recoveredAddress.toLowerCase() === address.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Signature verification error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keccak256 hash implementation using @noble/hashes
|
||||||
|
*/
|
||||||
|
function keccak256(message: string | Uint8Array): Uint8Array {
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return keccak_256(encoder.encode(message));
|
||||||
|
}
|
||||||
|
return keccak_256(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex string to Uint8Array
|
||||||
|
*/
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||||
|
const bytes = new Uint8Array(cleanHex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Uint8Array to hex string
|
||||||
|
*/
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover address from signature using @noble/secp256k1
|
||||||
|
*/
|
||||||
|
function ecrecover(
|
||||||
|
messageHash: Uint8Array,
|
||||||
|
v: number,
|
||||||
|
r: string,
|
||||||
|
s: string
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
// Convert r and s to bytes
|
||||||
|
const rBytes = hexToBytes(r);
|
||||||
|
const sBytes = hexToBytes(s);
|
||||||
|
|
||||||
|
// Combine r and s into signature (64 bytes compact format)
|
||||||
|
const signature = new Uint8Array(64);
|
||||||
|
signature.set(rBytes, 0);
|
||||||
|
signature.set(sBytes, 32);
|
||||||
|
|
||||||
|
// Recovery id is v - 27 (for non-EIP-155 signatures)
|
||||||
|
const recoveryId = v - 27;
|
||||||
|
|
||||||
|
if (recoveryId !== 0 && recoveryId !== 1) {
|
||||||
|
console.error('Invalid recovery id:', recoveryId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover the public key using @noble/secp256k1 v3 API
|
||||||
|
// The signature needs to include the recovery bit (65 bytes: r || s || v)
|
||||||
|
const recoveredSig = new Uint8Array(65);
|
||||||
|
recoveredSig.set(signature, 0);
|
||||||
|
recoveredSig[64] = recoveryId;
|
||||||
|
|
||||||
|
// prehash: false because we already hashed the message with keccak256
|
||||||
|
const pubKeyBytes = recoverPublicKey(recoveredSig, messageHash, {
|
||||||
|
prehash: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pubKeyBytes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address is last 20 bytes of keccak256(pubkey without 0x04 prefix)
|
||||||
|
// For uncompressed key (65 bytes), skip the first byte (0x04 prefix)
|
||||||
|
const pubKeyHash = keccak256(pubKeyBytes.slice(1));
|
||||||
|
const addressBytes = pubKeyHash.slice(-20);
|
||||||
|
|
||||||
|
return '0x' + bytesToHex(addressBytes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ecrecover error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Database Operations
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a LinkedWallet database record to API response format
|
||||||
|
*/
|
||||||
|
export function walletToResponse(wallet: LinkedWallet): LinkedWalletResponse {
|
||||||
|
return {
|
||||||
|
id: wallet.id,
|
||||||
|
address: wallet.wallet_address,
|
||||||
|
type: wallet.wallet_type,
|
||||||
|
chainId: wallet.chain_id,
|
||||||
|
label: wallet.label,
|
||||||
|
ensName: wallet.ens_name,
|
||||||
|
ensAvatar: wallet.ens_avatar,
|
||||||
|
isPrimary: wallet.is_primary === 1,
|
||||||
|
linkedAt: wallet.verified_at,
|
||||||
|
lastUsedAt: wallet.last_used_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by public key (from CryptID auth header)
|
||||||
|
*/
|
||||||
|
export async function getUserByPublicKey(
|
||||||
|
db: D1Database,
|
||||||
|
publicKey: string
|
||||||
|
): Promise<User | null> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT u.* FROM users u
|
||||||
|
JOIN device_keys dk ON u.id = dk.user_id
|
||||||
|
WHERE dk.public_key = ?
|
||||||
|
`).bind(publicKey).first<User>();
|
||||||
|
|
||||||
|
return result || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all linked wallets for a user
|
||||||
|
*/
|
||||||
|
export async function getLinkedWallets(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string
|
||||||
|
): Promise<LinkedWallet[]> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT * FROM linked_wallets
|
||||||
|
WHERE user_id = ? AND is_active = 1
|
||||||
|
ORDER BY is_primary DESC, created_at ASC
|
||||||
|
`).bind(userId).all<LinkedWallet>();
|
||||||
|
|
||||||
|
return result.results || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific linked wallet
|
||||||
|
*/
|
||||||
|
export async function getLinkedWallet(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
walletAddress: string
|
||||||
|
): Promise<LinkedWallet | null> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT * FROM linked_wallets
|
||||||
|
WHERE user_id = ? AND wallet_address = ? AND is_active = 1
|
||||||
|
`).bind(userId, walletAddress.toLowerCase()).first<LinkedWallet>();
|
||||||
|
|
||||||
|
return result || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a wallet is already linked to any account
|
||||||
|
*/
|
||||||
|
export async function isWalletLinked(
|
||||||
|
db: D1Database,
|
||||||
|
walletAddress: string
|
||||||
|
): Promise<{ linked: boolean; userId?: string; username?: string }> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT lw.user_id, u.cryptid_username
|
||||||
|
FROM linked_wallets lw
|
||||||
|
JOIN users u ON lw.user_id = u.id
|
||||||
|
WHERE lw.wallet_address = ? AND lw.is_active = 1
|
||||||
|
`).bind(walletAddress.toLowerCase()).first<{ user_id: string; cryptid_username: string }>();
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
linked: true,
|
||||||
|
userId: result.user_id,
|
||||||
|
username: result.cryptid_username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { linked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count wallets linked to a user
|
||||||
|
*/
|
||||||
|
export async function countUserWallets(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT COUNT(*) as count FROM linked_wallets
|
||||||
|
WHERE user_id = ? AND is_active = 1
|
||||||
|
`).bind(userId).first<{ count: number }>();
|
||||||
|
|
||||||
|
return result?.count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a new wallet to a user
|
||||||
|
*/
|
||||||
|
export async function linkWallet(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
request: WalletLinkRequest
|
||||||
|
): Promise<LinkedWallet> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const walletAddress = request.walletAddress.toLowerCase();
|
||||||
|
|
||||||
|
// Check if this is the first wallet (make it primary)
|
||||||
|
const existingCount = await countUserWallets(db, userId);
|
||||||
|
const isPrimary = existingCount === 0 ? 1 : 0;
|
||||||
|
|
||||||
|
await db.prepare(`
|
||||||
|
INSERT INTO linked_wallets (
|
||||||
|
id, user_id, wallet_address, wallet_type, chain_id, label,
|
||||||
|
signature_message, signature, verified_at, is_primary, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
walletAddress,
|
||||||
|
request.walletType || 'eoa',
|
||||||
|
request.chainId || 1,
|
||||||
|
request.label || null,
|
||||||
|
request.message,
|
||||||
|
request.signature,
|
||||||
|
now,
|
||||||
|
isPrimary,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
).run();
|
||||||
|
|
||||||
|
// Fetch and return the created wallet
|
||||||
|
const wallet = await db.prepare(`
|
||||||
|
SELECT * FROM linked_wallets WHERE id = ?
|
||||||
|
`).bind(id).first<LinkedWallet>();
|
||||||
|
|
||||||
|
return wallet!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a linked wallet
|
||||||
|
*/
|
||||||
|
export async function updateWallet(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
walletAddress: string,
|
||||||
|
updates: { label?: string; isPrimary?: boolean }
|
||||||
|
): Promise<LinkedWallet | null> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const address = walletAddress.toLowerCase();
|
||||||
|
|
||||||
|
// If setting as primary, unset other primaries first
|
||||||
|
if (updates.isPrimary) {
|
||||||
|
await db.prepare(`
|
||||||
|
UPDATE linked_wallets SET is_primary = 0, updated_at = ?
|
||||||
|
WHERE user_id = ? AND is_active = 1
|
||||||
|
`).bind(now, userId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update query
|
||||||
|
const setClauses: string[] = ['updated_at = ?'];
|
||||||
|
const values: (string | number)[] = [now];
|
||||||
|
|
||||||
|
if (updates.label !== undefined) {
|
||||||
|
setClauses.push('label = ?');
|
||||||
|
values.push(updates.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.isPrimary !== undefined) {
|
||||||
|
setClauses.push('is_primary = ?');
|
||||||
|
values.push(updates.isPrimary ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(userId, address);
|
||||||
|
|
||||||
|
await db.prepare(`
|
||||||
|
UPDATE linked_wallets SET ${setClauses.join(', ')}
|
||||||
|
WHERE user_id = ? AND wallet_address = ? AND is_active = 1
|
||||||
|
`).bind(...values).run();
|
||||||
|
|
||||||
|
return getLinkedWallet(db, userId, address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink (soft-delete) a wallet
|
||||||
|
*/
|
||||||
|
export async function unlinkWallet(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
walletAddress: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const address = walletAddress.toLowerCase();
|
||||||
|
|
||||||
|
const result = await db.prepare(`
|
||||||
|
UPDATE linked_wallets SET is_active = 0, updated_at = ?
|
||||||
|
WHERE user_id = ? AND wallet_address = ? AND is_active = 1
|
||||||
|
`).bind(now, userId, address).run();
|
||||||
|
|
||||||
|
return (result.meta?.changes || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Request Handlers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle POST /api/wallet/link
|
||||||
|
*/
|
||||||
|
export async function handleLinkWallet(
|
||||||
|
request: Request,
|
||||||
|
env: Environment
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get authenticated user from public key header
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json() as WalletLinkRequest;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.walletAddress || !body.signature || !body.message) {
|
||||||
|
return jsonResponse({ error: 'Missing required fields: walletAddress, signature, message' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate address format
|
||||||
|
if (!isValidAddress(body.walletAddress)) {
|
||||||
|
return jsonResponse({ error: 'Invalid wallet address format' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check wallet count limit
|
||||||
|
const walletCount = await countUserWallets(db, user.id);
|
||||||
|
if (walletCount >= MAX_WALLETS_PER_USER) {
|
||||||
|
return jsonResponse({ error: `Maximum ${MAX_WALLETS_PER_USER} wallets allowed per account` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if wallet is already linked
|
||||||
|
const existingLink = await isWalletLinked(db, body.walletAddress);
|
||||||
|
if (existingLink.linked) {
|
||||||
|
if (existingLink.userId === user.id) {
|
||||||
|
return jsonResponse({ error: 'Wallet already linked to your account' }, 409);
|
||||||
|
}
|
||||||
|
return jsonResponse({ error: 'Wallet is linked to another account' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate the message
|
||||||
|
const parsedMessage = parseLinkMessage(body.message);
|
||||||
|
if (!parsedMessage) {
|
||||||
|
return jsonResponse({ error: 'Invalid message format' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message matches request
|
||||||
|
if (parsedMessage.address.toLowerCase() !== body.walletAddress.toLowerCase()) {
|
||||||
|
return jsonResponse({ error: 'Message wallet address does not match request' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage.username !== user.cryptid_username) {
|
||||||
|
return jsonResponse({ error: 'Message username does not match authenticated user' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate timestamp is recent
|
||||||
|
const messageTime = new Date(parsedMessage.timestamp).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
if (isNaN(messageTime) || now - messageTime > MAX_SIGNATURE_AGE_MS) {
|
||||||
|
return jsonResponse({ error: 'Signature expired. Please sign a new message.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature using proper ecrecover
|
||||||
|
const signatureValid = verifyPersonalSignature(
|
||||||
|
body.walletAddress,
|
||||||
|
body.message,
|
||||||
|
body.signature
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!signatureValid) {
|
||||||
|
return jsonResponse({ error: 'Signature verification failed' }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link the wallet
|
||||||
|
const wallet = await linkWallet(db, user.id, body);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
wallet: walletToResponse(wallet),
|
||||||
|
}, 201);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Link wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/wallet/list
|
||||||
|
*/
|
||||||
|
export async function handleListWallets(
|
||||||
|
request: Request,
|
||||||
|
env: Environment
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallets = await getLinkedWallets(db, user.id);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
wallets: wallets.map(walletToResponse),
|
||||||
|
count: wallets.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List wallets error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/wallet/:address
|
||||||
|
*/
|
||||||
|
export async function handleGetWallet(
|
||||||
|
request: Request,
|
||||||
|
env: Environment,
|
||||||
|
address: string
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = await getLinkedWallet(db, user.id, address);
|
||||||
|
if (!wallet) {
|
||||||
|
return jsonResponse({ error: 'Wallet not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
wallet: walletToResponse(wallet),
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PATCH /api/wallet/:address
|
||||||
|
*/
|
||||||
|
export async function handleUpdateWallet(
|
||||||
|
request: Request,
|
||||||
|
env: Environment,
|
||||||
|
address: string
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json() as { label?: string; isPrimary?: boolean };
|
||||||
|
|
||||||
|
const wallet = await updateWallet(db, user.id, address, body);
|
||||||
|
if (!wallet) {
|
||||||
|
return jsonResponse({ error: 'Wallet not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
wallet: walletToResponse(wallet),
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DELETE /api/wallet/:address
|
||||||
|
*/
|
||||||
|
export async function handleUnlinkWallet(
|
||||||
|
request: Request,
|
||||||
|
env: Environment,
|
||||||
|
address: string
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await unlinkWallet(db, user.id, address);
|
||||||
|
if (!success) {
|
||||||
|
return jsonResponse({ error: 'Wallet not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Wallet unlinked',
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unlink wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/wallet/verify/:address (public endpoint)
|
||||||
|
*/
|
||||||
|
export async function handleVerifyWallet(
|
||||||
|
_request: Request,
|
||||||
|
env: Environment,
|
||||||
|
address: string
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidAddress(address)) {
|
||||||
|
return jsonResponse({ error: 'Invalid wallet address format' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkStatus = await isWalletLinked(db, address);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
linked: linkStatus.linked,
|
||||||
|
cryptidUsername: linkStatus.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Verify wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function jsonResponse(data: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
205
worker/worker.ts
205
worker/worker.ts
|
|
@ -37,6 +37,14 @@ import {
|
||||||
handleListAllUsers,
|
handleListAllUsers,
|
||||||
handleCheckUsername,
|
handleCheckUsername,
|
||||||
} from "./cryptidAuth"
|
} from "./cryptidAuth"
|
||||||
|
import {
|
||||||
|
handleLinkWallet,
|
||||||
|
handleListWallets,
|
||||||
|
handleGetWallet,
|
||||||
|
handleUpdateWallet,
|
||||||
|
handleUnlinkWallet,
|
||||||
|
handleVerifyWallet,
|
||||||
|
} from "./walletAuth"
|
||||||
|
|
||||||
// make sure our sync durable objects are made available to cloudflare
|
// make sure our sync durable objects are made available to cloudflare
|
||||||
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
||||||
|
|
@ -995,6 +1003,32 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
// List all CryptID users (admin only, requires X-Admin-Secret header)
|
// List all CryptID users (admin only, requires X-Admin-Secret header)
|
||||||
.get("/admin/users", handleListAllUsers)
|
.get("/admin/users", handleListAllUsers)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Wallet Linking API (Web3 Integration)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Link a new wallet to the authenticated user's CryptID account
|
||||||
|
.post("/api/wallet/link", handleLinkWallet)
|
||||||
|
|
||||||
|
// List all wallets linked to the authenticated user
|
||||||
|
.get("/api/wallet/list", handleListWallets)
|
||||||
|
|
||||||
|
// Check if a wallet address is linked to any CryptID account (public)
|
||||||
|
.get("/api/wallet/verify/:address", (req, env) =>
|
||||||
|
handleVerifyWallet(req, env, req.params.address))
|
||||||
|
|
||||||
|
// Get details for a specific linked wallet
|
||||||
|
.get("/api/wallet/:address", (req, env) =>
|
||||||
|
handleGetWallet(req, env, req.params.address))
|
||||||
|
|
||||||
|
// Update a linked wallet (label, primary status)
|
||||||
|
.patch("/api/wallet/:address", (req, env) =>
|
||||||
|
handleUpdateWallet(req, env, req.params.address))
|
||||||
|
|
||||||
|
// Unlink a wallet from the account
|
||||||
|
.delete("/api/wallet/:address", (req, env) =>
|
||||||
|
handleUnlinkWallet(req, env, req.params.address))
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// User Networking / Social Graph API
|
// User Networking / Social Graph API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -1714,6 +1748,177 @@ router
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Migration endpoints - for batch converting legacy JSON to Automerge format
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// List all rooms with their size and migration status
|
||||||
|
.get("/migrate/list", async (_, env) => {
|
||||||
|
try {
|
||||||
|
const rooms: Array<{
|
||||||
|
roomId: string
|
||||||
|
hasJson: boolean
|
||||||
|
hasAutomerge: boolean
|
||||||
|
shapeCount: number
|
||||||
|
recordCount: number
|
||||||
|
needsMigration: boolean
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
// List all rooms in R2
|
||||||
|
let cursor: string | undefined = undefined
|
||||||
|
do {
|
||||||
|
const result = await env.TLDRAW_BUCKET.list({
|
||||||
|
prefix: 'rooms/',
|
||||||
|
cursor,
|
||||||
|
delimiter: '/'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process each room prefix
|
||||||
|
for (const prefix of result.delimitedPrefixes || []) {
|
||||||
|
const roomId = prefix.replace('rooms/', '').replace('/', '')
|
||||||
|
if (!roomId) continue
|
||||||
|
|
||||||
|
// Check if JSON exists
|
||||||
|
const jsonObject = await env.TLDRAW_BUCKET.head(`rooms/${roomId}`)
|
||||||
|
const hasJson = !!jsonObject
|
||||||
|
|
||||||
|
// Check if Automerge binary exists
|
||||||
|
const automergeObject = await env.TLDRAW_BUCKET.head(`rooms/${roomId}/automerge.bin`)
|
||||||
|
const hasAutomerge = !!automergeObject
|
||||||
|
|
||||||
|
// Get shape count from JSON if it exists
|
||||||
|
let shapeCount = 0
|
||||||
|
let recordCount = 0
|
||||||
|
if (hasJson) {
|
||||||
|
try {
|
||||||
|
const jsonData = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`)
|
||||||
|
if (jsonData) {
|
||||||
|
const doc = await jsonData.json() as { store?: Record<string, any> }
|
||||||
|
if (doc.store) {
|
||||||
|
recordCount = Object.keys(doc.store).length
|
||||||
|
shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error reading room ${roomId}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms.push({
|
||||||
|
roomId,
|
||||||
|
hasJson,
|
||||||
|
hasAutomerge,
|
||||||
|
shapeCount,
|
||||||
|
recordCount,
|
||||||
|
needsMigration: hasJson && !hasAutomerge
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = result.truncated ? result.cursor : undefined
|
||||||
|
} while (cursor)
|
||||||
|
|
||||||
|
// Sort by shape count descending (largest first)
|
||||||
|
rooms.sort((a, b) => b.shapeCount - a.shapeCount)
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: rooms.length,
|
||||||
|
needsMigration: rooms.filter(r => r.needsMigration).length,
|
||||||
|
alreadyMigrated: rooms.filter(r => r.hasAutomerge).length,
|
||||||
|
largeRooms: rooms.filter(r => r.shapeCount > 5000).length
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ summary, rooms }, null, 2), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration list failed:', error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Failed to list rooms',
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Migrate a single room to Automerge format
|
||||||
|
.post("/migrate/:roomId", async (request, env) => {
|
||||||
|
const roomId = request.params.roomId
|
||||||
|
if (!roomId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Room ID is required' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Import the storage module dynamically
|
||||||
|
const { AutomergeR2Storage } = await import('./automerge-r2-storage')
|
||||||
|
const storage = new AutomergeR2Storage(env.TLDRAW_BUCKET)
|
||||||
|
|
||||||
|
// Check if already migrated
|
||||||
|
const isAutomerge = await storage.isAutomergeFormat(roomId)
|
||||||
|
if (isAutomerge) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Room already migrated to Automerge format',
|
||||||
|
roomId
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load legacy JSON
|
||||||
|
const jsonObject = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`)
|
||||||
|
if (!jsonObject) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Room not found',
|
||||||
|
roomId
|
||||||
|
}), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonDoc = await jsonObject.json() as { store?: Record<string, any>; schema?: any }
|
||||||
|
const recordCount = jsonDoc.store ? Object.keys(jsonDoc.store).length : 0
|
||||||
|
const shapeCount = jsonDoc.store ? Object.values(jsonDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
|
||||||
|
console.log(`🔄 Starting migration for room ${roomId}: ${shapeCount} shapes, ${recordCount} records`)
|
||||||
|
|
||||||
|
// Perform migration
|
||||||
|
const startTime = Date.now()
|
||||||
|
const doc = await storage.migrateFromJson(roomId, jsonDoc as any)
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error('Migration returned null')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Room migrated successfully',
|
||||||
|
roomId,
|
||||||
|
shapeCount,
|
||||||
|
recordCount,
|
||||||
|
durationMs: duration
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Migration failed for room ${roomId}:`, error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Migration failed',
|
||||||
|
roomId,
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Handle scheduled events (cron jobs)
|
// Handle scheduled events (cron jobs)
|
||||||
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
||||||
// Cron job triggered
|
// Cron job triggered
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue