diff --git a/.env.example b/.env.example index b17bb28..1629262 100644 --- a/.env.example +++ b/.env.example @@ -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_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_) CLOUDFLARE_API_TOKEN='your_cloudflare_token' CLOUDFLARE_ACCOUNT_ID='your_account_id' diff --git a/src/components/WalletLinkPanel.tsx b/src/components/WalletLinkPanel.tsx new file mode 100644 index 0000000..33df28b --- /dev/null +++ b/src/components/WalletLinkPanel.tsx @@ -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 ( +
+ Link this wallet to your CryptID account. You'll be asked to sign a message + to prove ownership. +
+ setLabel(e.target.value)} + style={styles.input} + /> + + {linkError &&
diff --git a/worker/migrations/002_linked_wallets.sql b/worker/migrations/002_linked_wallets.sql
new file mode 100644
index 0000000..bbcb773
--- /dev/null
+++ b/worker/migrations/002_linked_wallets.sql
@@ -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);
diff --git a/worker/types.ts b/worker/types.ts
index 1fe028a..3737b49 100644
--- a/worker/types.ts
+++ b/worker/types.ts
@@ -232,4 +232,95 @@ export interface NetworkGraph {
edges: GraphEdge[];
// Current user's connections (for filtering)
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;
}
\ No newline at end of file
diff --git a/worker/walletAuth.ts b/worker/walletAuth.ts
new file mode 100644
index 0000000..3b7114d
--- /dev/null
+++ b/worker/walletAuth.ts
@@ -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