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.')
+}