diff --git a/src/components/layout/content.tsx b/src/components/layout/content.tsx index 8931940..b169301 100644 --- a/src/components/layout/content.tsx +++ b/src/components/layout/content.tsx @@ -2,7 +2,9 @@ import { useContext, useEffect, useState } from 'react' import { FilecoinPinContext } from '../../context/filecoin-pin-provider.tsx' import { useDatasetPieces } from '../../hooks/use-dataset-pieces.ts' import { useFilecoinUpload } from '../../hooks/use-filecoin-upload.ts' +import { useWallet } from '../../hooks/use-wallet.ts' import { formatFileSize } from '../../utils/format-file-size.ts' +import { ButtonBase } from '../ui/button/button-base.tsx' import { Heading } from '../ui/heading.tsx' import { LoadingState } from '../ui/loading-state.tsx' import { PageTitle } from '../ui/page-title.tsx' @@ -27,6 +29,7 @@ export default function Content() { const [dragDropKey, setDragDropKey] = useState(0) // Key to force DragNDrop remount const { uploadState, uploadFile, resetUpload } = useFilecoinUpload() const { pieces: uploadHistory, refreshPieces, isLoading: isLoadingPieces } = useDatasetPieces() + const { connect: connectWallet, isUsingWallet } = useWallet() const context = useContext(FilecoinPinContext) if (!context) { throw new Error('Content must be used within FilecoinPinProvider') @@ -36,11 +39,11 @@ export default function Content() { // Determine if we're still initializing (wallet, synapse, provider) // Note: We don't block on isLoadingPieces - users can upload while history loads - const isInitializing = wallet.status === 'loading' || wallet.status === 'idle' + const isInitializing = wallet.status === 'loading' // Get loading message based on current state const getLoadingMessage = () => { - if (wallet.status === 'loading' || wallet.status === 'idle') { + if (wallet.status === 'loading') { return 'Connecting to Filecoin network...' } if (!synapse) { @@ -52,18 +55,6 @@ export default function Content() { return 'Preparing upload interface...' } - // If wallet failed to load, show error instead of spinner - if (wallet.status === 'error') { - return ( -
- -
-

Failed to connect to Filecoin network: {wallet.error}

-
-
- ) - } - const handleUpload = (file: File) => { // Set uploadedFile immediately to switch to progress view setUploadedFile({ file, cid: '' }) @@ -111,6 +102,43 @@ export default function Content() { } }, [uploadState.error, resetUpload]) + // If wallet is disconnected (idle) and using wallet mode (no private key), show connect button + if (wallet.status === 'idle' && isUsingWallet) { + return ( +
+ +
+
+ Connect Your Wallet +

Connect your wallet to start uploading files to Filecoin

+
+ + Connect Wallet + +
+
+ ) + } + + // If wallet failed to load, show error instead of spinner + if (wallet.status === 'error') { + return ( +
+ +
+
+

Failed to connect to Filecoin network: {wallet.error}

+
+ {isUsingWallet && ( + + Try Again + + )} +
+
+ ) + } + return (
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 034e4ef..1c64655 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -12,7 +12,7 @@ import { PillBalance } from '../ui/pill/pill-balance.tsx' import { PillWallet } from '../ui/pill/pill-wallet.tsx' export default function Header() { - const { status, balances, address, network } = useWallet() + const { status, balances, address, network, disconnect, isUsingWallet } = useWallet() const isCalibration = network === 'calibration' const filLabel: FilLabel = isCalibration ? CALIBRATION_LABEL_FIL : MAINNET_LABEL_FIL @@ -45,7 +45,11 @@ export default function Header() { ]} /> )} - +
) diff --git a/src/components/ui/pill/pill-wallet.tsx b/src/components/ui/pill/pill-wallet.tsx index 11fd22d..671eca7 100644 --- a/src/components/ui/pill/pill-wallet.tsx +++ b/src/components/ui/pill/pill-wallet.tsx @@ -1,14 +1,33 @@ +import { LogOut } from 'lucide-react' import { PillWrapper } from './pill-wrapper.tsx' type PillWalletProps = { address: string href: string + onDisconnect?: () => void } -function PillWallet({ address, href }: PillWalletProps) { +function PillWallet({ address, href, onDisconnect }: PillWalletProps) { return ( - {address} +
+ {address} + {onDisconnect && ( + + )} +
) } diff --git a/src/context/filecoin-pin-provider.tsx b/src/context/filecoin-pin-provider.tsx index 475a7ea..b639cac 100644 --- a/src/context/filecoin-pin-provider.tsx +++ b/src/context/filecoin-pin-provider.tsx @@ -2,7 +2,7 @@ import type { SynapseService } from 'filecoin-pin/core/synapse' import { createContext, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type DataSetState, useDataSetManager } from '../hooks/use-data-set-manager.ts' import { filecoinPinConfig } from '../lib/filecoin-pin/config.ts' -import { getSynapseClient } from '../lib/filecoin-pin/synapse.ts' +import { disconnectWallet as disconnectSynapseWallet, getSynapseClient } from '../lib/filecoin-pin/synapse.ts' import { fetchWalletSnapshot, type WalletSnapshot } from '../lib/filecoin-pin/wallet.ts' type ProviderInfo = NonNullable['providerInfo']> @@ -17,6 +17,9 @@ type WalletState = export interface FilecoinPinContextValue { wallet: WalletState refreshWallet: () => Promise + connectWallet: () => Promise + disconnectWallet: () => void + isUsingWallet: boolean // true if using browser wallet, false if using private key synapse: SynapseService['synapse'] | null dataSet: DataSetState ensureDataSet: () => Promise @@ -38,45 +41,65 @@ export const FilecoinPinProvider = ({ children }: { children: ReactNode }) => { const synapseRef = useRef(null) const config = filecoinPinConfig + // Check if using wallet connection (no private key) vs private key + const isUsingWallet = !config.privateKey + // Use the data set manager hook const { dataSet, ensureDataSet, storageContext, providerInfo } = useDataSetManager({ synapse: synapseRef.current, walletAddress: wallet.status === 'ready' ? wallet.data.address : null, }) - const refreshWallet = useCallback(async () => { - if (!config.privateKey) { - setWallet((prev) => ({ - status: 'error', - error: 'Missing VITE_FILECOIN_PRIVATE_KEY environment variable. Wallet data unavailable.', - data: prev.data, - })) - return - } - + const connectWallet = useCallback(async () => { setWallet((prev) => ({ status: 'loading', data: prev.status === 'ready' ? prev.data : undefined, })) try { + // getSynapseClient now handles the fallback logic: + // 1. Try private key if VITE_FILECOIN_PRIVATE_KEY is set + // 2. Otherwise, attempt to connect to browser wallet (MetaMask, etc.) const synapse = await getSynapseClient(config) synapseRef.current = synapse const snapshot = await fetchWalletSnapshot(synapse) + setWallet({ status: 'ready', data: snapshot, }) } catch (error) { - console.error('Failed to load wallet balances', error) + console.error('Failed to load wallet', error) + const errorMessage = error instanceof Error ? error.message : 'Unable to load wallet. See console for details.' setWallet((prev) => ({ status: 'error', - error: error instanceof Error ? error.message : 'Unable to load wallet balances. See console for details.', + error: errorMessage, data: prev.data, })) } }, [config]) + const refreshWallet = useCallback(async () => { + // Only auto-connect if using private key + // For wallet mode, user must explicitly click "Connect Wallet" + if (isUsingWallet) { + console.info('Wallet mode detected, waiting for user to click Connect Wallet button') + setWallet({ + status: 'idle', + }) + return + } + + // Auto-connect with private key + await connectWallet() + }, [connectWallet, isUsingWallet]) + + const disconnectWallet = useCallback(async () => { + await disconnectSynapseWallet() + synapseRef.current = null + setWallet({ status: 'idle' }) + }, []) + useEffect(() => { void refreshWallet() }, [refreshWallet]) @@ -104,13 +127,26 @@ export const FilecoinPinProvider = ({ children }: { children: ReactNode }) => { () => ({ wallet, refreshWallet, + connectWallet, + disconnectWallet, + isUsingWallet, synapse: synapseRef.current, dataSet, ensureDataSet, storageContext, providerInfo, }), - [wallet, refreshWallet, dataSet, ensureDataSet, storageContext, providerInfo] + [ + wallet, + refreshWallet, + connectWallet, + disconnectWallet, + isUsingWallet, + dataSet, + ensureDataSet, + storageContext, + providerInfo, + ] ) return {children} diff --git a/src/env.d.ts b/src/env.d.ts index 792cd87..cc0b8c7 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -11,6 +11,16 @@ interface ImportMetaEnv { readonly VITE_WARM_STORAGE_ADDRESS?: string } +/** + * Ethereum provider types for MetaMask and other Web3 wallets + */ +interface EthereumProvider { + request: (args: { method: string; params?: any[] }) => Promise + on: (event: string, handler: (...args: any[]) => void) => void + removeListener: (event: string, handler: (...args: any[]) => void) => void + isMetaMask?: boolean +} + /** * We are delcaring a name on the global interface because this repo should not be consumed. * Don't do this for libs, export proper types instead. @@ -19,4 +29,8 @@ declare global { interface ImportMeta { readonly env: ImportMetaEnv } + + interface Window { + ethereum?: EthereumProvider + } } diff --git a/src/hooks/use-wallet.ts b/src/hooks/use-wallet.ts index d3cfa1b..1ce3d6a 100644 --- a/src/hooks/use-wallet.ts +++ b/src/hooks/use-wallet.ts @@ -7,7 +7,7 @@ export const useWallet = () => { throw new Error('useWallet must be used within FilecoinPinProvider') } - const { wallet, refreshWallet } = context + const { wallet, refreshWallet, connectWallet, disconnectWallet, isUsingWallet } = context return useMemo( () => ({ @@ -18,7 +18,10 @@ export const useWallet = () => { raw: wallet.data?.raw, error: wallet.status === 'error' ? wallet.error : undefined, refresh: refreshWallet, + connect: connectWallet, + disconnect: disconnectWallet, + isUsingWallet, // true if using browser wallet, false if using private key }), - [wallet, refreshWallet] + [wallet, refreshWallet, connectWallet, disconnectWallet, isUsingWallet] ) } diff --git a/src/lib/filecoin-pin/config.ts b/src/lib/filecoin-pin/config.ts index 8dcd329..7224260 100644 --- a/src/lib/filecoin-pin/config.ts +++ b/src/lib/filecoin-pin/config.ts @@ -6,12 +6,9 @@ const normalizeEnvValue = (value: string | boolean | number | undefined) => { return trimmed.length === 0 ? undefined : trimmed } -if (!import.meta.env.VITE_FILECOIN_PRIVATE_KEY) { - throw new Error('Missing VITE_FILECOIN_PRIVATE_KEY; unable to initialize Synapse') -} - -export const filecoinPinConfig: SynapseSetupConfig = { - privateKey: import.meta.env.VITE_FILECOIN_PRIVATE_KEY, +// Private key is now optional - will fall back to wallet connection if not provided +export const filecoinPinConfig: Partial = { + privateKey: normalizeEnvValue(import.meta.env.VITE_FILECOIN_PRIVATE_KEY), rpcUrl: normalizeEnvValue(import.meta.env.VITE_FILECOIN_RPC_URL), warmStorageAddress: normalizeEnvValue(import.meta.env.VITE_WARM_STORAGE_ADDRESS), } diff --git a/src/lib/filecoin-pin/synapse.ts b/src/lib/filecoin-pin/synapse.ts index 10fbe97..f90690c 100644 --- a/src/lib/filecoin-pin/synapse.ts +++ b/src/lib/filecoin-pin/synapse.ts @@ -1,3 +1,6 @@ +import { Synapse } from '@filoz/synapse-sdk' +import { BrowserProvider } from 'ethers' +import type { SynapseSetupConfig } from 'filecoin-pin/core/synapse' import { initializeSynapse, type SynapseService } from 'filecoin-pin/core/synapse' import pino from 'pino' @@ -8,11 +11,12 @@ const logger = pino({ }, }) -import type { SynapseSetupConfig } from 'filecoin-pin/core/synapse' - let synapsePromise: Promise | null = null -export const getSynapseClient = (config: SynapseSetupConfig) => { +/** + * Get Synapse client with private key configuration + */ +export const getSynapseClientWithPrivateKey = (config: SynapseSetupConfig) => { if (!config.privateKey) { return Promise.reject(new Error('Missing VITE_FILECOIN_PRIVATE_KEY; unable to initialize Synapse')) } @@ -24,6 +28,68 @@ export const getSynapseClient = (config: SynapseSetupConfig) => { return synapsePromise } +/** + * Get Synapse client with browser wallet (MetaMask, etc.) + */ +export const getSynapseClientWithWallet = async (config?: Partial) => { + if (typeof window === 'undefined' || !window.ethereum) { + throw new Error('No browser wallet detected. Please install MetaMask or another Web3 wallet.') + } + + logger.info('Requesting wallet connection...') + + // Request account access + await window.ethereum.request({ method: 'eth_requestAccounts' }) + + // Create ethers provider from browser wallet + const provider = new BrowserProvider(window.ethereum) + + if (!synapsePromise) { + logger.info('Initializing Synapse with wallet provider') + + // Use Synapse SDK directly with provider (bypassing filecoin-pin wrapper) + // The filecoin-pin wrapper doesn't expose the provider option + synapsePromise = Synapse.create({ + provider, + warmStorageAddress: config?.warmStorageAddress, + }) + } + + return synapsePromise +} + +/** + * Get Synapse client - tries private key first, then falls back to wallet + */ +export const getSynapseClient = async (config: Partial) => { + // Try private key first if available + if (config.privateKey) { + logger.info('Initializing Synapse with private key') + return getSynapseClientWithPrivateKey(config as SynapseSetupConfig) + } + + // Fall back to browser wallet + logger.info('No private key found, attempting wallet connection') + return getSynapseClientWithWallet(config) +} + export const resetSynapseClient = () => { synapsePromise = null } + +/** + * Disconnect wallet and clear synapse client + * Note: MetaMask doesn't provide a programmatic disconnect API. + * This clears our internal state. Users must manually disconnect in MetaMask + * if they want to fully revoke permissions. + */ +export const disconnectWallet = async () => { + logger.info('Disconnecting wallet and clearing Synapse client') + + // Clear synapse client + resetSynapseClient() + + // Note: There's no standard way to programmatically disconnect from MetaMask + // The user needs to disconnect manually in their wallet extension + logger.info('Wallet state cleared. To fully disconnect, please disconnect in your wallet extension.') +}