diff --git a/.env.example b/.env.example
index 773be71a4..b875819fe 100644
--- a/.env.example
+++ b/.env.example
@@ -5,4 +5,9 @@ ANVIL_BLOCK_NUMBER=
# Set to 'true' to enable Fast DAO feature on mainnet chains (LOCAL TESTING ONLY)
# Default: false (Fast DAO only available on testnets)
# WARNING: Only use this for local testing. Never enable in production.
-NEXT_PUBLIC_ALLOW_FAST_DAO_ON_MAINNET=false
\ No newline at end of file
+NEXT_PUBLIC_ALLOW_FAST_DAO_ON_MAINNET=false
+
+# Safe API Key for transaction proposal service
+# Get your API key from: https://dashboard.safe.global/
+# Required for multi-signature Safe wallet transactions
+SAFE_API_KEY=
diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example
index cac45dee0..d8dacc59a 100644
--- a/apps/web/.env.local.example
+++ b/apps/web/.env.local.example
@@ -20,3 +20,11 @@ NEXT_PUBLIC_PINATA_GATEWAY=
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLET_CONNECT_METADATA=
+
+# Iron Session Configuration (for SIWE authentication)
+# Generate a password with: openssl rand -base64 32
+# Must be at least 32 characters long
+IRON_PASSWORD=your-secret-here-at-least-32-chars
+
+# For proposing Safe transactions
+SAFE_API_KEY=
diff --git a/apps/web/next.config.js b/apps/web/next.config.js
index 8be9ee239..89aead340 100644
--- a/apps/web/next.config.js
+++ b/apps/web/next.config.js
@@ -148,6 +148,15 @@ const basicConfig = {
})
)
+ // Tree-shake debug package in production
+ if (!dev) {
+ config.plugins.push(
+ new webpack.NormalModuleReplacementPlugin(/^debug$/, (resource) => {
+ resource.request = require.resolve('./src/utils/debug-noop.js')
+ })
+ )
+ }
+
return {
...config,
// Hot-fix for $RefreshReg issues: https://github.com/vanilla-extract-css/vanilla-extract/issues/679#issuecomment-1402839249
diff --git a/apps/web/package.json b/apps/web/package.json
index 975a4b23a..f136ef748 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -28,23 +28,28 @@
"@fontsource/inter": "5.2.5",
"@fontsource/londrina-solid": "^5.2.5",
"@rainbow-me/rainbowkit": "^2.2.10",
+ "@safe-global/api-kit": "^5.0.2",
"@sentry/nextjs": "^9.22.0",
"@smartinvoicexyz/types": "^0.1.27",
"@tanstack/react-query": "^5.90.3",
"@vanilla-extract/next-plugin": "^2.4.11",
"@vanilla-extract/recipes": "^0.5.7",
"@vercel/og": "^0.6.8",
+ "@wagmi/core": "^2.22.1",
+ "@xstate/react": "^6.1.0",
"@zoralabs/coins-sdk": "^0.4.3",
"@zoralabs/protocol-deployments": "^0.7.2",
"ai": "^5.0.76",
"alchemy-sdk": "^3.6.0",
"axios": "^1.12.2",
"dayjs": "^1.11.13",
+ "debug": "^4.4.3",
"flatpickr": "^4.6.13",
"formik": "^2.4.6",
"framer-motion": "^12.12.1",
"graphql-request": "^7.1.2",
"ioredis": "^5.8.1",
+ "iron-session": "^8.0.4",
"next": "^15.5.9",
"nextjs-progressbar": "^0.0.16",
"ox": "^0.14.0",
@@ -57,6 +62,7 @@
"swr": "^2.3.3",
"viem": "^2.47.1",
"wagmi": "^2.18.1",
+ "xstate": "^5.32.6",
"zustand": "^5.0.4"
},
"devDependencies": {
@@ -69,6 +75,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@types/debug": "^4.1.13",
"@types/lodash": "^4.17.17",
"@types/node": "^22.15.21",
"@types/papaparse": "^5.3.16",
@@ -78,6 +85,7 @@
"@vanilla-extract/css": "^1.17.2",
"@vanilla-extract/vite-plugin": "^5.0.2",
"@vitejs/plugin-react": "^4.5.0",
+ "@xstate/inspect": "^0.8.0",
"dotenv": "^16.5.0",
"eslint": "8.57.1",
"eslint-config-next": "15.5.5",
diff --git a/apps/web/public/icons/wallets/brave.svg b/apps/web/public/icons/wallets/brave.svg
new file mode 100644
index 000000000..b90fc417b
--- /dev/null
+++ b/apps/web/public/icons/wallets/brave.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/icons/wallets/coinbase.svg b/apps/web/public/icons/wallets/coinbase.svg
new file mode 100644
index 000000000..f5fabdf27
--- /dev/null
+++ b/apps/web/public/icons/wallets/coinbase.svg
@@ -0,0 +1,4 @@
+
diff --git a/apps/web/public/icons/wallets/injected.svg b/apps/web/public/icons/wallets/injected.svg
new file mode 100644
index 000000000..7e011718d
--- /dev/null
+++ b/apps/web/public/icons/wallets/injected.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/icons/wallets/ledger.svg b/apps/web/public/icons/wallets/ledger.svg
new file mode 100644
index 000000000..4cf96c371
--- /dev/null
+++ b/apps/web/public/icons/wallets/ledger.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/icons/wallets/metamask.svg b/apps/web/public/icons/wallets/metamask.svg
new file mode 100644
index 000000000..127a8ecb6
--- /dev/null
+++ b/apps/web/public/icons/wallets/metamask.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/icons/wallets/phantom.svg b/apps/web/public/icons/wallets/phantom.svg
new file mode 100644
index 000000000..4c6700787
--- /dev/null
+++ b/apps/web/public/icons/wallets/phantom.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/icons/wallets/rabby.svg b/apps/web/public/icons/wallets/rabby.svg
new file mode 100644
index 000000000..b7380148e
--- /dev/null
+++ b/apps/web/public/icons/wallets/rabby.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/icons/wallets/rainbow.svg b/apps/web/public/icons/wallets/rainbow.svg
new file mode 100644
index 000000000..c16f942ef
--- /dev/null
+++ b/apps/web/public/icons/wallets/rainbow.svg
@@ -0,0 +1,54 @@
+
diff --git a/apps/web/public/icons/wallets/safe.svg b/apps/web/public/icons/wallets/safe.svg
new file mode 100644
index 000000000..7ec49e4fa
--- /dev/null
+++ b/apps/web/public/icons/wallets/safe.svg
@@ -0,0 +1,6 @@
+
diff --git a/apps/web/public/icons/wallets/trust.svg b/apps/web/public/icons/wallets/trust.svg
new file mode 100644
index 000000000..0f5e9daac
--- /dev/null
+++ b/apps/web/public/icons/wallets/trust.svg
@@ -0,0 +1 @@
+
diff --git a/apps/web/public/icons/wallets/walletconnect.svg b/apps/web/public/icons/wallets/walletconnect.svg
new file mode 100644
index 000000000..57b135fea
--- /dev/null
+++ b/apps/web/public/icons/wallets/walletconnect.svg
@@ -0,0 +1,4 @@
+
diff --git a/apps/web/src/components/AsyncImage.tsx b/apps/web/src/components/AsyncImage.tsx
new file mode 100644
index 000000000..70ff544ed
--- /dev/null
+++ b/apps/web/src/components/AsyncImage.tsx
@@ -0,0 +1,64 @@
+import { Box } from '@buildeross/zord'
+import { useReducer } from 'react'
+
+import { type AsyncImageSrc, useAsyncImage } from '../hooks/useAsyncImage'
+import { isIOS } from '../utils/isMobile'
+
+interface AsyncImageProps {
+ alt?: string
+ src: string | AsyncImageSrc | undefined
+ width: number
+ height: number
+ background?: string
+ borderRadius?: string | number
+ className?: string
+}
+
+export function AsyncImage({
+ alt,
+ background,
+ borderRadius = '6px',
+ height,
+ src: srcProp,
+ width,
+ className,
+}: AsyncImageProps) {
+ const ios = isIOS()
+ const src = useAsyncImage(srcProp)
+ const isRemoteImage = src && /^http/.test(src)
+ const [isRemoteImageLoaded, setRemoteImageLoaded] = useReducer(() => true, false)
+
+ return (
+
+ {src && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx
new file mode 100644
index 000000000..b482a5d5b
--- /dev/null
+++ b/apps/web/src/components/ErrorBoundary.tsx
@@ -0,0 +1,128 @@
+import { Box, Button, Stack, Text } from '@buildeross/zord'
+import React from 'react'
+
+import { debugAuth } from '../utils/debug'
+
+interface Props {
+ children: React.ReactNode
+ fallback?: React.ReactNode
+ onReset?: () => void | Promise
+ onError?: (error: Error, errorInfo: React.ErrorInfo) => void
+}
+
+interface State {
+ hasError: boolean
+ error: Error | null
+ isResetting: boolean
+}
+
+export class ErrorBoundary extends React.Component {
+ constructor(props: Props) {
+ super(props)
+ this.state = { hasError: false, error: null, isResetting: false }
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error, isResetting: false }
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ debugAuth('Error caught by boundary: %O', { error, errorInfo })
+ this.props.onError?.(error, errorInfo)
+ }
+
+ handleReset = async () => {
+ if (this.state.isResetting) return
+
+ this.setState({ isResetting: true })
+
+ try {
+ await this.props.onReset?.()
+ this.setState({ hasError: false, error: null, isResetting: false })
+ } catch (error) {
+ debugAuth('Error boundary reset failed: %O', error)
+ this.setState({ isResetting: false })
+ }
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+ this.props.fallback || (
+
+
+
+
+ !
+
+
+
+
+ Authentication interrupted
+
+
+ We hit a problem while restoring your session. Resetting will
+ disconnect the wallet and clear SIWE state.
+
+ {this.state.error?.message && (
+
+ {this.state.error.message}
+
+ )}
+
+
+
+
+
+
+ )
+ )
+ }
+
+ return this.props.children
+ }
+}
diff --git a/apps/web/src/components/SafeTransactionHandler.tsx b/apps/web/src/components/SafeTransactionHandler.tsx
new file mode 100644
index 000000000..3612ae78e
--- /dev/null
+++ b/apps/web/src/components/SafeTransactionHandler.tsx
@@ -0,0 +1,99 @@
+'use client'
+
+import { SafeTransactionModal } from '@buildeross/ui'
+import {
+ registerSafeTransactionHandler,
+ SafeTransactionError,
+ SafeTransactionErrorCode,
+ unregisterSafeTransactionHandler,
+} from '@buildeross/utils'
+import { useMachine } from '@xstate/react'
+import { useEffect, useRef } from 'react'
+
+import { safeTransactionMachine } from '../machines/safeTransactionMachine'
+import { debugSafeTx } from '../utils/debug'
+
+export function SafeTransactionHandler() {
+ const [state, send] = useMachine(safeTransactionMachine)
+ const stateRef = useRef(state)
+ stateRef.current = state
+
+ // Register global handler on mount
+ useEffect(() => {
+ registerSafeTransactionHandler(async (params) => {
+ debugSafeTx('Handler called with params: %O', params)
+
+ // Reject new transactions if one is already in progress
+ if (!stateRef.current.matches('idle')) {
+ debugSafeTx('ERROR: Transaction already in progress, rejecting new transaction')
+ throw new SafeTransactionError(
+ 'Another Safe transaction is already in progress. Please complete or cancel it first.',
+ SafeTransactionErrorCode.TRANSACTION_IN_PROGRESS
+ )
+ }
+
+ // Show modal and wait for user interaction
+ return new Promise((resolve, reject) => {
+ send({
+ type: 'PROPOSE',
+ params,
+ resolve,
+ reject,
+ })
+ })
+ })
+
+ return () => {
+ unregisterSafeTransactionHandler()
+ }
+ }, [send])
+
+ const handleConfirm = async () => {
+ return new Promise<{ safeTxHash: string }>((resolve, reject) => {
+ send({ type: 'CONFIRM', resolve, reject })
+ })
+ }
+
+ const handleClose = () => {
+ const event = state.matches('error') || state.matches('success') ? 'CLOSE' : 'CANCEL'
+ send({ type: event })
+ }
+
+ const handleRetry = () => {
+ send({ type: 'RETRY' })
+ }
+
+ // Don't render if not in a state that needs the modal
+ if (state.matches('idle')) {
+ return null
+ }
+
+ if (!state.context.params) {
+ return null
+ }
+
+ const isOpen = !state.matches('idle')
+
+ return (
+ ({
+ to: transaction.to,
+ value: transaction.value,
+ data: transaction.data,
+ }))}
+ onConfirm={handleConfirm}
+ />
+ )
+}
diff --git a/apps/web/src/components/WalletConnectDialog.tsx b/apps/web/src/components/WalletConnectDialog.tsx
new file mode 100644
index 000000000..ae76698b4
--- /dev/null
+++ b/apps/web/src/components/WalletConnectDialog.tsx
@@ -0,0 +1,700 @@
+'use client'
+
+import { AnimatedModal } from '@buildeross/ui'
+import { addRecentSafeWallet, clearSafeInfo, getSafeInfo } from '@buildeross/utils'
+import { getConnectors } from '@wagmi/core'
+import { useMachine } from '@xstate/react'
+import { useCallback, useEffect, useMemo, useRef } from 'react'
+import type { Address } from 'viem'
+import { createSiweMessage } from 'viem/siwe'
+import { useAccount, useConfig, useConnect, useDisconnect, useSignMessage } from 'wagmi'
+
+import { useWalletConnectors } from '../hooks/useWalletConnectors'
+import { walletModalMachine } from '../machines/walletModalMachine'
+import type { WalletInfo } from '../types/auth'
+import { debugWallet } from '../utils/debug'
+import { getCachedSigningAddress, resolveSigningAddress } from '../utils/signing'
+import { beginSiweAuthFlow, SIWE_NONCE_PATH } from '../utils/siweAuthFlow'
+import { ErrorView } from './wallet/ErrorView'
+import { LoadingView } from './wallet/LoadingView'
+import { SafeAddressView } from './wallet/SafeAddressView'
+import { SignatureView } from './wallet/SignatureView'
+import { WalletListView } from './wallet/WalletListView'
+
+interface WalletConnectDialogProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProps) {
+ const [state, send] = useMachine(walletModalMachine)
+ const { connector: activeConnector, chainId } = useAccount()
+ const { connectAsync } = useConnect()
+ const { disconnectAsync } = useDisconnect()
+ const { signMessageAsync } = useSignMessage()
+ const wagmiConfig = useConfig()
+
+ // Track if we just authenticated to prevent immediate reopening
+ const justAuthenticatedRef = useRef(false)
+ const safeFlowStartedRef = useRef(false)
+ const flowCancelledRef = useRef(false)
+ const authAttemptRef = useRef(0)
+ const recordedRecentSafeRef = useRef(null)
+
+ const cleanupCancelledSafeFlow = useCallback(async () => {
+ flowCancelledRef.current = true
+ authAttemptRef.current += 1
+ if (!safeFlowStartedRef.current) return
+
+ safeFlowStartedRef.current = false
+ clearSafeInfo()
+
+ if (activeConnector?.id === 'safeOwner') {
+ try {
+ await disconnectAsync()
+ } catch (error) {
+ debugWallet('Failed to disconnect cancelled Safe flow: %O', error)
+ }
+ }
+ }, [activeConnector, disconnectAsync])
+
+ // Use proper wallet connector hook (handles deduplication)
+ const walletConnectors = useWalletConnectors()
+
+ // Transform to WalletInfo format (memoized to prevent infinite re-renders)
+ const wallets: WalletInfo[] = useMemo(
+ () =>
+ walletConnectors.map((wc) => ({
+ id: wc.id,
+ name: wc.name,
+ iconUrl: wc.iconUrl,
+ isRainbowKitConnector: wc.isRainbowKitConnector,
+ recent: wc.recent,
+ connector: wc.connector,
+ })),
+ [walletConnectors]
+ )
+
+ // Open modal handler
+ useEffect(() => {
+ if (isOpen && state.matches('closed') && !justAuthenticatedRef.current) {
+ flowCancelledRef.current = false
+ send({ type: 'OPEN', wallets })
+ }
+ // Only react to isOpen and state changes, not wallets
+ // Wallets are captured in the closure when needed
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen, state, send])
+
+ // Close modal handler
+ useEffect(() => {
+ if (!isOpen && !state.matches('closed')) {
+ void cleanupCancelledSafeFlow()
+ send({ type: 'CLOSE' })
+ }
+ }, [cleanupCancelledSafeFlow, isOpen, state, send])
+
+ // Save recent Safe logins only after the flow fully succeeds.
+ useEffect(() => {
+ if (state.matches('authenticated') && state.context.safeInfo) {
+ const { safeAddress, chainId } = state.context.safeInfo
+ const recentKey = `${safeAddress.toLowerCase()}:${chainId}`
+
+ if (recordedRecentSafeRef.current !== recentKey) {
+ addRecentSafeWallet(safeAddress, chainId)
+ recordedRecentSafeRef.current = recentKey
+ }
+ return
+ }
+
+ if (state.matches('closed')) {
+ recordedRecentSafeRef.current = null
+ }
+ }, [state])
+
+ // Handle Safe address validation
+ useEffect(() => {
+ if (state.matches('validatingSafe') && state.context.pendingSafeAddress) {
+ const abortController = new AbortController()
+
+ const validate = async () => {
+ try {
+ debugWallet(
+ 'Validating Safe: %s on chain %d',
+ state.context.pendingSafeAddress,
+ state.context.pendingSafeChainId
+ )
+
+ // Check abort before async operation
+ if (abortController.signal.aborted) {
+ debugWallet('Validation aborted before starting')
+ return
+ }
+
+ const safeInfo = await getSafeInfo(
+ state.context.pendingSafeAddress!,
+ state.context.pendingSafeChainId!
+ )
+
+ // Check if component is still mounted after async operation
+ if (abortController.signal.aborted) {
+ debugWallet('Validation aborted after fetch')
+ return
+ }
+
+ if (safeInfo) {
+ debugWallet('Safe validated: %O', safeInfo)
+ send({ type: 'SAFE_VALIDATED', safeInfo })
+ } else {
+ debugWallet('Safe not found')
+ send({
+ type: 'ERROR',
+ error: {
+ code: 'SAFE_NOT_FOUND',
+ address: state.context.pendingSafeAddress!,
+ },
+ })
+ }
+ } catch (error) {
+ // Check if component is still mounted
+ if (abortController.signal.aborted) {
+ debugWallet('Validation aborted in error handler')
+ return
+ }
+
+ debugWallet('Safe validation error: %O', error)
+ send({
+ type: 'ERROR',
+ error: { code: 'SAFE_NOT_FOUND', address: state.context.pendingSafeAddress! },
+ })
+ }
+ }
+ validate()
+
+ return () => {
+ abortController.abort()
+ }
+ }
+ }, [state, send])
+
+ // Handle Safe connector switch
+ useEffect(() => {
+ if (
+ state.matches('switchingToSafe') &&
+ state.context.safeInfo &&
+ state.context.connector
+ ) {
+ const abortController = new AbortController()
+
+ const switchConnector = async () => {
+ try {
+ debugWallet('Switching to Safe connector...')
+
+ // Check abort before starting
+ if (abortController.signal.aborted) {
+ debugWallet('Safe connector switch aborted before start')
+ return
+ }
+
+ // Find SafeOwnerConnector from wagmi config
+ const safeConnector = getConnectors(wagmiConfig).find(
+ (c) => c.id === 'safeOwner'
+ )
+
+ if (!safeConnector) {
+ throw new Error('SafeOwnerConnector not found in wagmi config')
+ }
+
+ // Check abort before async operation
+ if (abortController.signal.aborted) {
+ debugWallet('Safe connector switch aborted before connect')
+ return
+ }
+
+ debugWallet('Connecting to SafeOwnerConnector...')
+ const result = await connectAsync({ connector: safeConnector })
+
+ // Check abort immediately after async operation (before state updates)
+ if (abortController.signal.aborted) {
+ debugWallet(
+ 'Safe connector switch aborted after connect (component unmounted)'
+ )
+ await disconnectAsync({ connector: safeConnector })
+ return
+ }
+
+ if (flowCancelledRef.current) {
+ await disconnectAsync({ connector: safeConnector })
+ return
+ }
+
+ debugWallet('Safe connector switch complete')
+ const connectedAddress = result.accounts?.[0]
+ const connectedConnector = safeConnector
+
+ if (!connectedAddress) {
+ throw new Error('Safe connector did not return an address')
+ }
+
+ send({
+ type: 'WALLET_CONNECTED',
+ address: connectedAddress,
+ connector: connectedConnector,
+ })
+ } catch (error) {
+ // Check abort in catch block (before state updates)
+ if (abortController.signal.aborted) {
+ debugWallet('Safe connector switch aborted in error handler')
+ return
+ }
+
+ clearSafeInfo()
+ debugWallet('Safe connector switch error: %O', error)
+ send({
+ type: 'ERROR',
+ error: {
+ code: 'NETWORK_ERROR',
+ message: 'Failed to switch to Safe connector',
+ },
+ })
+ }
+ }
+ switchConnector()
+
+ return () => {
+ abortController.abort()
+ }
+ }
+ }, [state, send, wagmiConfig, connectAsync, disconnectAsync])
+
+ // Debug state changes
+ useEffect(() => {
+ debugWallet('State changed: %s', state.value)
+ if (state.context.error) {
+ debugWallet('State has error: %O', state.context.error)
+ }
+
+ // Log when authenticated
+ if (state.matches('authenticated')) {
+ safeFlowStartedRef.current = false
+ debugWallet('AUTHENTICATED STATE REACHED! Modal should close in 500ms')
+ }
+
+ // Log if we go back to selecting wallet (shouldn't happen after auth)
+ if (state.matches('selectingWallet') && state.context.address) {
+ debugWallet(
+ 'WARNING: Back to selectingWallet but have address: %s',
+ state.context.address
+ )
+ }
+ }, [state])
+
+ // Handle modal close after authentication
+ useEffect(() => {
+ // Only close if we just authenticated (came from authenticated state)
+ if (state.matches('authenticated')) {
+ debugWallet('Authenticated! Scheduling modal close...')
+ justAuthenticatedRef.current = true
+
+ // Wait for state machine to transition to closed, then notify parent
+ const timer = setTimeout(() => {
+ debugWallet('Closing modal after authentication')
+ onClose()
+ }, 600) // Slightly longer than state machine's 500ms delay
+
+ return () => {
+ clearTimeout(timer)
+ // Reset flag if component unmounts before timer fires
+ if (justAuthenticatedRef.current) {
+ debugWallet('Component unmounting with authentication pending, resetting flag')
+ justAuthenticatedRef.current = false
+ }
+ }
+ }
+ }, [state, onClose])
+
+ // Clear the authentication flag when session becomes authenticated or modal closes
+ useEffect(() => {
+ if (state.matches('closed') && !isOpen) {
+ // Reset flag after a delay to allow session to update
+ const timer = setTimeout(() => {
+ debugWallet('Clearing justAuthenticated flag')
+ justAuthenticatedRef.current = false
+ }, 2000) // Wait 2 seconds for session to fully update
+ return () => clearTimeout(timer)
+ }
+ }, [state, isOpen])
+
+ // Handle wallet selection
+ const handleSelectWallet = async (walletId: string) => {
+ debugWallet('handleSelectWallet called with walletId: %s', walletId)
+ debugWallet(
+ 'Available wallets: %O',
+ wallets.map((w) => ({ id: w.id, name: w.name, hasConnector: !!w.connector }))
+ )
+
+ const wallet = wallets.find((w) => w.id === walletId)
+
+ if (!wallet) {
+ debugWallet('ERROR: Wallet not found in list! walletId: %s', walletId)
+ send({ type: 'ERROR', error: { code: 'WALLET_NOT_CONNECTED' } })
+ return
+ }
+
+ if (!wallet.connector) {
+ debugWallet('ERROR: Wallet connector is null/undefined! wallet: %O', wallet)
+ send({ type: 'ERROR', error: { code: 'WALLET_NOT_CONNECTED' } })
+ return
+ }
+
+ flowCancelledRef.current = false
+ const attemptId = ++authAttemptRef.current
+ send({ type: 'SELECT_WALLET', walletId })
+
+ try {
+ debugWallet(
+ 'Connecting to %s (id: %s, connector: %O)...',
+ wallet.name,
+ wallet.id,
+ wallet.connector
+ )
+ const result = await connectAsync({ connector: wallet.connector })
+ if (attemptId !== authAttemptRef.current || flowCancelledRef.current) return
+ debugWallet('Connected successfully: %O', result)
+ const connectedAddress = result.accounts?.[0]
+ const connectedConnector = wallet.connector
+
+ if (!connectedAddress) {
+ throw new Error('Wallet connection did not return an address')
+ }
+
+ send({
+ type: 'WALLET_CONNECTED',
+ address: connectedAddress,
+ connector: connectedConnector,
+ })
+ } catch (error) {
+ if (attemptId !== authAttemptRef.current || flowCancelledRef.current) return
+ debugWallet('Connection error: %O', error)
+ send({ type: 'ERROR', error: { code: 'WALLET_NOT_CONNECTED' } })
+ }
+ }
+
+ // Handle Safe address submission
+ const handleSubmitSafeAddress = (safeAddress: Address, chainId: number) => {
+ safeFlowStartedRef.current = true
+ send({
+ type: 'SUBMIT_SAFE_ADDRESS',
+ address: safeAddress,
+ chainId,
+ })
+ }
+
+ // Handle signature request
+ const handleSign = async () => {
+ debugWallet('handleSign called, current state: %s', state.value)
+ debugWallet(
+ 'State context: address=%s, safeInfo=%O',
+ state.context.address,
+ state.context.safeInfo
+ )
+
+ send({ type: 'SIGN_MESSAGE' })
+ flowCancelledRef.current = false
+ const attemptId = ++authAttemptRef.current
+
+ // Create AbortController with 60s timeout for signature request
+ const abortController = new AbortController()
+ const timeoutId = setTimeout(() => {
+ abortController.abort()
+ debugWallet('Signature request timed out after 60s')
+ }, 60000)
+
+ try {
+ const currentAddress = state.context.address
+ const safeAddress = state.context.safeInfo?.safeAddress
+ const safeChainId = state.context.safeInfo?.chainId
+
+ if (!currentAddress) {
+ debugWallet('ERROR: No address in state context!')
+ throw new Error('No address available')
+ }
+
+ const signingAddress = await resolveSigningAddress(
+ state.context.connector,
+ currentAddress
+ )
+
+ if (flowCancelledRef.current || attemptId !== authAttemptRef.current) return
+
+ // Begin auth flow
+ beginSiweAuthFlow()
+
+ // Get nonce from API
+ debugWallet('Fetching nonce from /api/siwe/nonce...')
+ const nonceRes = await fetch(SIWE_NONCE_PATH)
+ if (flowCancelledRef.current || attemptId !== authAttemptRef.current) return
+ debugWallet('Nonce response status: %d', nonceRes.status)
+
+ // Get response as text first to see what we're receiving
+ const nonceText = await nonceRes.text()
+ debugWallet('Nonce response text: %s', nonceText)
+
+ // Parse the nonce - the endpoint might return plain text instead of JSON
+ let nonce: string
+ try {
+ const parsed = JSON.parse(nonceText)
+ nonce = parsed.nonce || parsed
+ } catch {
+ // If it's not JSON, use the text directly as the nonce
+ nonce = nonceText
+ }
+
+ // Validate nonce format and length (SIWE spec requires minimum 8 characters)
+ if (!nonce || typeof nonce !== 'string' || nonce.trim().length < 8) {
+ debugWallet('Invalid nonce received: %s', nonce)
+ throw new Error('Invalid nonce received from server')
+ }
+
+ debugWallet('Nonce received and validated: %s', nonce)
+
+ // Use Safe chainId if available, otherwise use wagmi chainId, default to 1
+ const currentChainId = safeChainId || chainId || 1
+ debugWallet(
+ 'Using chainId: %d (Safe: %d, wagmi: %d)',
+ currentChainId,
+ safeChainId,
+ chainId
+ )
+
+ // Create SIWE message
+ const message = createSiweMessage({
+ domain: window.location.host,
+ address: signingAddress,
+ statement: safeAddress
+ ? `Sign in as owner of Safe ${safeAddress}`
+ : 'Sign in with Ethereum to the app.',
+ uri: window.location.origin,
+ version: '1',
+ chainId: currentChainId,
+ nonce,
+ })
+
+ debugWallet('SIWE message created: %s', message)
+ debugWallet('Requesting signature from wallet...')
+
+ // Sign message
+ const signature = await signMessageAsync({ message })
+
+ // Clear timeout on success
+ clearTimeout(timeoutId)
+
+ // Check if aborted (timed out)
+ if (abortController.signal.aborted) {
+ debugWallet('Signature request was aborted (timeout)')
+ send({
+ type: 'ERROR',
+ error: {
+ code: 'SIGNATURE_REJECTED',
+ message: 'Signature request timed out',
+ },
+ })
+ return
+ }
+
+ if (flowCancelledRef.current || attemptId !== authAttemptRef.current) return
+
+ debugWallet('Signature received successfully: %s', signature)
+
+ send({
+ type: 'SIGNATURE_RECEIVED',
+ signature,
+ message,
+ })
+
+ debugWallet('SIGNATURE_RECEIVED event sent to state machine')
+ } catch (error) {
+ // Clear timeout on error
+ clearTimeout(timeoutId)
+
+ // Check if aborted (timed out)
+ if (abortController.signal.aborted) {
+ debugWallet('Signature request was aborted (timeout)')
+ send({
+ type: 'ERROR',
+ error: {
+ code: 'SIGNATURE_REJECTED',
+ message: 'Signature request timed out',
+ },
+ })
+ return
+ }
+
+ if (flowCancelledRef.current || attemptId !== authAttemptRef.current) return
+
+ debugWallet('Signature error: %O', error)
+ debugWallet(
+ 'Error name: %s, message: %s',
+ (error as Error).name,
+ (error as Error).message
+ )
+ send({
+ type: 'ERROR',
+ error: {
+ code: 'SIGNATURE_REJECTED',
+ message: (error as Error).message || 'Unknown error',
+ },
+ })
+ }
+ }
+
+ // Handle cancel
+ const handleCancel = async () => {
+ flowCancelledRef.current = true
+ authAttemptRef.current += 1
+ await cleanupCancelledSafeFlow()
+ send({ type: 'CANCEL' })
+ }
+
+ // Handle back navigation
+ const handleBack = async () => {
+ flowCancelledRef.current = true
+ authAttemptRef.current += 1
+ await cleanupCancelledSafeFlow()
+ send({ type: 'BACK' })
+ }
+
+ // Handle retry
+ const handleRetry = () => {
+ send({ type: 'RETRY' })
+ }
+
+ // Handle close
+ const handleClose = async () => {
+ flowCancelledRef.current = true
+ await cleanupCancelledSafeFlow()
+ if (!state.matches('closed')) {
+ send({ type: 'CLOSE' })
+ }
+ onClose()
+ }
+
+ // Render appropriate view based on state
+ const renderContent = () => {
+ if (state.matches('selectingWallet')) {
+ return (
+ send({ type: 'SELECT_SAFE' })}
+ showSafeOption={true}
+ />
+ )
+ }
+
+ if (state.matches('enteringSafeAddress')) {
+ return (
+
+ )
+ }
+
+ if (state.matches('validatingSafe')) {
+ return
+ }
+
+ if (state.matches('selectingSafeOwnerWallet')) {
+ return (
+ {}}
+ showSafeOption={false}
+ title="Connect Safe Owner Wallet"
+ description="Choose a wallet that is an owner of this Safe to continue."
+ />
+ )
+ }
+
+ if (state.matches('connectingWallet')) {
+ const walletName = state.context.selectedWalletId
+ ? wallets.find((w) => w.id === state.context.selectedWalletId)?.name
+ : 'wallet'
+ return
+ }
+
+ if (state.matches('checkingSafeOwnership')) {
+ return
+ }
+
+ if (state.matches('switchingToSafe')) {
+ return
+ }
+
+ if (state.matches('awaitingSignature') || state.matches('signingMessage')) {
+ const walletName =
+ state.context.connector?.name ||
+ wallets.find((w) => w.id === state.context.selectedWalletId)?.name ||
+ 'wallet'
+ const signingAddress =
+ state.context.connector?.id === 'safeOwner'
+ ? getCachedSigningAddress(state.context.connector, state.context.address!)
+ : state.context.address!
+
+ if (!signingAddress) {
+ return
+ }
+
+ return (
+
+ )
+ }
+
+ if (state.matches('authenticating')) {
+ return
+ }
+
+ if (state.matches('authenticated')) {
+ return
+ }
+
+ if (state.context.error) {
+ return (
+
+ )
+ }
+
+ return
+ }
+
+ return (
+
+ {renderContent()}
+
+ )
+}
diff --git a/apps/web/src/components/WalletOption.test.tsx b/apps/web/src/components/WalletOption.test.tsx
new file mode 100644
index 000000000..8899dda92
--- /dev/null
+++ b/apps/web/src/components/WalletOption.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from '@testing-library/react'
+
+import { WalletOption } from './WalletOption'
+
+describe('WalletOption', () => {
+ it('shows a compact recent label when the wallet is recent', () => {
+ render(
+ {}}
+ />
+ )
+
+ expect(screen.getByText('Rainbow')).toBeVisible()
+ expect(screen.getByText('Recent')).toBeVisible()
+ })
+})
diff --git a/apps/web/src/components/WalletOption.tsx b/apps/web/src/components/WalletOption.tsx
new file mode 100644
index 000000000..b6d1e06b5
--- /dev/null
+++ b/apps/web/src/components/WalletOption.tsx
@@ -0,0 +1,77 @@
+'use client'
+
+import { Box, Flex, Stack, Text } from '@buildeross/zord'
+import { useState } from 'react'
+
+import { AsyncImage } from './AsyncImage'
+
+interface WalletOptionProps {
+ name: string
+ icon: string
+ iconBackground: string
+ onClick: () => void
+ recent?: boolean
+ disabled?: boolean
+}
+
+export function WalletOption({
+ name,
+ icon,
+ iconBackground,
+ onClick,
+ recent = false,
+ disabled = false,
+}: WalletOptionProps) {
+ const [isHovered, setIsHovered] = useState(false)
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ w="100%"
+ p="x2"
+ borderRadius="curved"
+ cursor={disabled ? 'not-allowed' : 'pointer'}
+ backgroundColor={isHovered && !disabled ? 'background2' : 'transparent'}
+ style={{
+ border: 'none',
+ outline: 'none',
+ opacity: disabled ? 0.5 : 1,
+ textAlign: 'left',
+ }}
+ >
+
+
+
+
+
+ {name}
+
+ {recent && (
+
+ Recent
+
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/profile/DelegateToProfileButton.tsx b/apps/web/src/components/profile/DelegateToProfileButton.tsx
index 4cf46f82a..0445203d1 100644
--- a/apps/web/src/components/profile/DelegateToProfileButton.tsx
+++ b/apps/web/src/components/profile/DelegateToProfileButton.tsx
@@ -1,7 +1,7 @@
+import { useAuthStore } from '@buildeross/stores'
import { Button } from '@buildeross/zord'
import React from 'react'
import { isOwnProfileAddress } from 'src/utils/profileDashboard'
-import { useAccount } from 'wagmi'
import { DelegateToProfileModal } from './DelegateToProfileModal'
@@ -14,7 +14,7 @@ export const DelegateToProfileButton: React.FC = (
profileAddress,
profileName,
}) => {
- const { address } = useAccount()
+ const { address } = useAuthStore()
const [isOpen, setIsOpen] = React.useState(false)
const isOwnProfile = isOwnProfileAddress(address, profileAddress)
diff --git a/apps/web/src/components/profile/DelegateToProfileModal.test.tsx b/apps/web/src/components/profile/DelegateToProfileModal.test.tsx
index 46984c132..fada3d0cb 100644
--- a/apps/web/src/components/profile/DelegateToProfileModal.test.tsx
+++ b/apps/web/src/components/profile/DelegateToProfileModal.test.tsx
@@ -37,9 +37,13 @@ vi.mock('@buildeross/ui/Modal', () => ({
vi.mock('@buildeross/ui/ContractButton', () => ({
ContractButton: ({ children }: React.PropsWithChildren) => ,
}))
+vi.mock('@buildeross/stores', () => ({
+ useAuthStore: () => ({
+ address: '0xUser000000000000000000000000000000000000',
+ }),
+}))
vi.mock('wagmi', async (importOriginal) => ({
...(await importOriginal()),
- useAccount: () => ({ address: '0xUser000000000000000000000000000000000000' }),
useConfig: () => ({}),
}))
vi.mock('wagmi/actions', () => ({
diff --git a/apps/web/src/components/profile/DelegateToProfileModal.tsx b/apps/web/src/components/profile/DelegateToProfileModal.tsx
index 3fcd44d16..93e95f8ce 100644
--- a/apps/web/src/components/profile/DelegateToProfileModal.tsx
+++ b/apps/web/src/components/profile/DelegateToProfileModal.tsx
@@ -3,10 +3,13 @@ import { ETHERSCAN_BASE_URL } from '@buildeross/constants/etherscan'
import { useUserDaos } from '@buildeross/hooks/useUserDaos'
import { tokenAbi } from '@buildeross/sdk/contract'
import { daoMembershipRequest } from '@buildeross/sdk/subgraph'
+import { executeAppTransaction } from '@buildeross/sdk/transaction'
+import { useAuthStore } from '@buildeross/stores'
import type { AddressType, CHAIN_ID } from '@buildeross/types'
import { ContractButton } from '@buildeross/ui/ContractButton'
import { FallbackImage } from '@buildeross/ui/FallbackImage'
import { AnimatedModal } from '@buildeross/ui/Modal'
+import { getSafeErrorMessage } from '@buildeross/utils'
import { walletSnippet } from '@buildeross/utils/helpers'
import { Box, Button, Flex, Icon, Text } from '@buildeross/zord'
import React from 'react'
@@ -23,8 +26,8 @@ import {
filterLabel,
} from 'src/styles/profile.css'
import useSWR from 'swr'
-import { useAccount, useConfig } from 'wagmi'
-import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions'
+import { useConfig } from 'wagmi'
+import { simulateContract } from 'wagmi/actions'
type DelegateToProfileModalProps = {
open: boolean
@@ -51,7 +54,7 @@ export const DelegateToProfileModal: React.FC = ({
profileName,
}) => {
const config = useConfig()
- const { address } = useAccount()
+ const { address } = useAuthStore()
const { daos, isLoading: isLoadingDaos } = useUserDaos({
address,
enabled: open && !!address,
@@ -156,16 +159,21 @@ export const DelegateToProfileModal: React.FC = ({
functionName: 'delegate',
args: [profileAddress],
})
- const hash = await writeContract(config, data.request)
- await waitForTransactionReceipt(config, { hash, chainId: selectedDao.chainId })
- setTxHash(hash)
+ const result = await executeAppTransaction({
+ config,
+ request: data.request,
+ chainId: selectedDao.chainId,
+ })
+ if (result.kind === 'safe-proposed') {
+ setError(
+ 'Delegation proposed to your Safe. Other owners must sign it before delegation takes effect.'
+ )
+ return
+ }
+ setTxHash(result.hash)
} catch (err) {
console.error('Failed to delegate to profile:', err)
- setError(
- err instanceof Error
- ? err.message
- : 'Delegation failed. Please check your wallet and try again.'
- )
+ setError(getSafeErrorMessage(err))
} finally {
setIsDelegating(false)
}
diff --git a/apps/web/src/components/profile/ProfileDaoChainIcon.test.tsx b/apps/web/src/components/profile/ProfileDaoChainIcon.test.tsx
index 59b23f48f..ff0b9b189 100644
--- a/apps/web/src/components/profile/ProfileDaoChainIcon.test.tsx
+++ b/apps/web/src/components/profile/ProfileDaoChainIcon.test.tsx
@@ -29,9 +29,10 @@ vi.mock('src/components/ProfileDaoList', () => ({
},
}))
-vi.mock('wagmi', async (importOriginal) => ({
- ...(await importOriginal()),
- useAccount: () => ({ address: '0xabc' }),
+vi.mock('@buildeross/stores', () => ({
+ useAuthStore: () => ({
+ address: '0xabc',
+ }),
}))
vi.mock('next/image', () => ({
diff --git a/apps/web/src/components/profile/ProfileDaoSelector.tsx b/apps/web/src/components/profile/ProfileDaoSelector.tsx
index ef3ada0e7..e84851e64 100644
--- a/apps/web/src/components/profile/ProfileDaoSelector.tsx
+++ b/apps/web/src/components/profile/ProfileDaoSelector.tsx
@@ -1,4 +1,5 @@
import type { MyDaosResponse } from '@buildeross/sdk/subgraph'
+import { useAuthStore } from '@buildeross/stores'
import { CHAIN_ID } from '@buildeross/types'
import { Button, Text } from '@buildeross/zord'
import React from 'react'
@@ -11,7 +12,6 @@ import {
profileSurface,
} from 'src/styles/profile.css'
import { createDaoKey, isOwnProfileAddress } from 'src/utils/profileDashboard'
-import { useAccount } from 'wagmi'
import { getProfileChainMetadata } from './ProfileChainIcon'
@@ -45,7 +45,7 @@ export const ProfileDaoSelector: React.FC = ({
onToggle,
onClear,
}) => {
- const { address: connectedAddress } = useAccount()
+ const { address: connectedAddress } = useAuthStore()
return (
= ({
profileAddress,
onSaved,
}) => {
- const { address } = useAccount()
+ const { address } = useAuthStore()
const [isOpen, setIsOpen] = React.useState(false)
const isOwnProfile = isOwnProfileAddress(address, profileAddress)
@@ -26,14 +27,15 @@ export const ProfileLinksEditButton: React.FC = ({
return (
<>
-
+
({
- readContractMock: vi.fn(),
- waitForTransactionReceiptMock: vi.fn(),
- writeContractMock: vi.fn(),
- })
-)
+const {
+ readContractMock,
+ simulateContractMock,
+ waitForTransactionReceiptMock,
+ writeContractMock,
+} = vi.hoisted(() => ({
+ readContractMock: vi.fn(),
+ simulateContractMock: vi.fn(),
+ waitForTransactionReceiptMock: vi.fn(),
+ writeContractMock: vi.fn(),
+}))
-vi.mock('wagmi', () => ({
+vi.mock('wagmi', async (importOriginal) => ({
+ ...(await importOriginal()),
useAccount: () => ({ chainId: 8453 }),
- useConfig: () => ({}),
- useSwitchChain: () => ({ switchChainAsync: vi.fn() }),
+ useConfig: () => ({ state: { current: null, connections: new Map() } }),
}))
vi.mock('wagmi/actions', () => ({
readContract: readContractMock,
+ simulateContract: simulateContractMock,
waitForTransactionReceipt: waitForTransactionReceiptMock,
writeContract: writeContractMock,
}))
@@ -32,6 +37,7 @@ describe('ProfileLinksEditModal', () => {
beforeEach(() => {
vi.clearAllMocks()
writeContractMock.mockResolvedValue(`0x${'1'.repeat(64)}`)
+ simulateContractMock.mockImplementation((_config, request) => ({ request }))
waitForTransactionReceiptMock.mockResolvedValue({ status: 'success' })
})
diff --git a/apps/web/src/components/profile/ProfileLinksEditModal.tsx b/apps/web/src/components/profile/ProfileLinksEditModal.tsx
index d6972e154..d722a079b 100644
--- a/apps/web/src/components/profile/ProfileLinksEditModal.tsx
+++ b/apps/web/src/components/profile/ProfileLinksEditModal.tsx
@@ -5,6 +5,7 @@ import {
PROFILE_LINK_SCHEMA,
PROFILE_LINK_SCHEMA_UID,
} from '@buildeross/constants'
+import { executeAppTransaction } from '@buildeross/sdk/transaction'
import type { AddressType } from '@buildeross/types'
import { AnimatedModal } from '@buildeross/ui/Modal'
import { Box, Button, Flex, Text } from '@buildeross/zord'
@@ -21,8 +22,8 @@ import {
validateWebsiteUrl,
} from 'src/utils/profileIdentity'
import { encodeAbiParameters, zeroHash } from 'viem'
-import { useAccount, useConfig, useSwitchChain } from 'wagmi'
-import { waitForTransactionReceipt, writeContract } from 'wagmi/actions'
+import { useAccount, useConfig } from 'wagmi'
+import { simulateContract } from 'wagmi/actions'
type ProfileLinkKey = 'website' | 'x' | 'farcaster'
@@ -48,7 +49,6 @@ export const ProfileLinksEditModal: React.FC = ({
}) => {
const config = useConfig()
const { chainId } = useAccount()
- const { switchChainAsync } = useSwitchChain()
const [website, setWebsite] = React.useState('')
const [xHandle, setXHandle] = React.useState('')
const [farcasterHandle, setFarcasterHandle] = React.useState('')
@@ -114,8 +114,8 @@ export const ProfileLinksEditModal: React.FC = ({
const attestProfileLinks = async (
easAddress: `0x${string}`,
updates: ProfileLinkUpdate[]
- ): Promise<`0x${string}`> => {
- const hash = await writeContract(config, {
+ ): Promise<'safe-proposed' | `0x${string}`> => {
+ const { request } = await simulateContract(config, {
abi: easAbi,
address: easAddress,
chainId: PROFILE_LINK_EAS_CHAIN_ID,
@@ -142,18 +142,26 @@ export const ProfileLinksEditModal: React.FC = ({
],
],
})
- await waitForTransactionReceipt(config, {
- hash,
+
+ const result = await executeAppTransaction({
+ config,
+ request,
chainId: PROFILE_LINK_EAS_CHAIN_ID,
})
- return hash
+ if (result.kind === 'safe-proposed') {
+ return 'safe-proposed'
+ }
+
+ return result.hash
}
const saveProfileLinks = async (
easAddress: `0x${string}`,
updates: ProfileLinkUpdate[]
- ): Promise<`0x${string}`[]> => [await attestProfileLinks(easAddress, updates)]
+ ): Promise<('safe-proposed' | `0x${string}`)[]> => [
+ await attestProfileLinks(easAddress, updates),
+ ]
const handleSave = async () => {
let updates: ProfileLinkUpdate[]
@@ -175,8 +183,8 @@ export const ProfileLinksEditModal: React.FC = ({
setIsSaving(true)
try {
- if (chainId !== PROFILE_LINK_EAS_CHAIN_ID && switchChainAsync) {
- await switchChainAsync({ chainId: PROFILE_LINK_EAS_CHAIN_ID })
+ if (chainId !== PROFILE_LINK_EAS_CHAIN_ID) {
+ throw new Error(`Please switch to the supported network before saving links.`)
}
const profileLinkChainId =
@@ -190,7 +198,12 @@ export const ProfileLinksEditModal: React.FC = ({
const hashes = await saveProfileLinks(easAddress, updates)
- setTxHashes(hashes)
+ // Don't show success for Safe proposals - user needs to execute via Safe UI
+ if (hashes.includes('safe-proposed')) {
+ return
+ }
+
+ setTxHashes(hashes as `0x${string}`[])
onSaved?.()
} catch (err) {
console.error('Failed to update profile links:', err)
diff --git a/apps/web/src/components/profile/ProfileTokenGallery.tsx b/apps/web/src/components/profile/ProfileTokenGallery.tsx
index d13d9c75b..4708ac5a7 100644
--- a/apps/web/src/components/profile/ProfileTokenGallery.tsx
+++ b/apps/web/src/components/profile/ProfileTokenGallery.tsx
@@ -1,4 +1,5 @@
import { tokenAbi } from '@buildeross/sdk/contract'
+import { executeAppTransactions } from '@buildeross/sdk/transaction'
import type { AddressType, CHAIN_ID } from '@buildeross/types'
import { ContractButton } from '@buildeross/ui/ContractButton'
import { FallbackImage } from '@buildeross/ui/FallbackImage'
@@ -55,9 +56,10 @@ import {
type ProfileToken,
} from 'src/utils/profileDashboard'
import type { TokenSortOption } from 'src/utils/profileIdentity'
+import type { WriteContractParameters } from 'viem'
import { isAddress } from 'viem'
import { useConfig } from 'wagmi'
-import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions'
+import { simulateContract } from 'wagmi/actions'
import { ProfileChainIcon } from './ProfileChainIcon'
@@ -154,6 +156,7 @@ const TokenTransferTray: React.FC = ({
const hashes: `0x${string}`[] = []
try {
+ const requests: WriteContractParameters[] = []
for (const token of selectedTokens) {
const data = await simulateContract(config, {
abi: tokenAbi,
@@ -162,10 +165,30 @@ const TokenTransferTray: React.FC = ({
functionName: 'safeTransferFrom',
args: [profileAddress, resolvedRecipient, BigInt(token.tokenId)],
})
- const hash = await writeContract(config, data.request)
- await waitForTransactionReceipt(config, { hash, chainId: token.chainId })
- setTxHashes((current) => [...current, hash])
- hashes.push(hash)
+ requests.push(data.request)
+ }
+
+ const result = await executeAppTransactions({
+ config,
+ requests,
+ chainId: selectedChainId,
+ })
+ if (!Array.isArray(result)) {
+ if (result.kind === 'safe-proposed') {
+ setError(
+ `${selectedTokens.length} transfer${selectedTokens.length === 1 ? '' : 's'} proposed to your Safe. Other owners must sign and execute it before the NFTs move.`
+ )
+ return
+ }
+ setTxHashes([result.hash])
+ hashes.push(result.hash)
+ } else {
+ result.forEach((item) => {
+ if (item.kind === 'mined') {
+ setTxHashes((current) => [...current, item.hash])
+ hashes.push(item.hash)
+ }
+ })
}
onTransferComplete?.()
} catch (err) {
diff --git a/apps/web/src/components/profile/ProfileWalletScannerMenu.tsx b/apps/web/src/components/profile/ProfileWalletScannerMenu.tsx
index caf89bca2..c5e03c18a 100644
--- a/apps/web/src/components/profile/ProfileWalletScannerMenu.tsx
+++ b/apps/web/src/components/profile/ProfileWalletScannerMenu.tsx
@@ -1,5 +1,6 @@
import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains'
import { ETHERSCAN_BASE_URL } from '@buildeross/constants/etherscan'
+import { useAuthStore } from '@buildeross/stores'
import type { AddressType } from '@buildeross/types'
import { Icon, Text } from '@buildeross/zord'
import React from 'react'
@@ -12,7 +13,6 @@ import {
} from 'src/styles/profile.css'
import { isOwnProfileAddress } from 'src/utils/profileDashboard'
import type { ProfileIdentity } from 'src/utils/profileIdentity'
-import { useAccount } from 'wagmi'
import { DelegateToProfileModal } from './DelegateToProfileModal'
import { ProfileLinksEditModal } from './ProfileLinksEditModal'
@@ -50,7 +50,7 @@ export const ProfileWalletScannerMenu: React.FC =
const menuRootRef = React.useRef(null)
const menuButtonRef = React.useRef(null)
const [isOpen, setIsOpen] = React.useState(false)
- const { address: connectedAddress } = useAccount()
+ const { address: connectedAddress } = useAuthStore()
const isOwnProfile = isOwnProfileAddress(connectedAddress, profileAddress)
const [isDelegateModalOpen, setIsDelegateModalOpen] = React.useState(false)
const [isEditLinksModalOpen, setIsEditLinksModalOpen] = React.useState(false)
diff --git a/apps/web/src/components/wallet/ErrorView.tsx b/apps/web/src/components/wallet/ErrorView.tsx
new file mode 100644
index 000000000..2852cf6be
--- /dev/null
+++ b/apps/web/src/components/wallet/ErrorView.tsx
@@ -0,0 +1,88 @@
+'use client'
+
+import { Box, Button, Flex, Stack, Text } from '@buildeross/zord'
+
+import type { AuthError } from '../../types/auth'
+
+interface ErrorViewProps {
+ error: AuthError
+ onRetry?: () => void
+ onBack?: () => void
+ onClose: () => void
+}
+
+function getErrorMessage(error: AuthError): { title: string; message: string } {
+ switch (error.code) {
+ case 'WALLET_NOT_CONNECTED':
+ return {
+ title: 'Connection Failed',
+ message: 'Failed to connect to your wallet. Please try again.',
+ }
+ case 'SIGNATURE_REJECTED':
+ return {
+ title: 'Signature Rejected',
+ message: error.message || 'You rejected the signature request.',
+ }
+ case 'SAFE_NOT_FOUND':
+ return {
+ title: 'Safe Not Found',
+ message:
+ error.message ||
+ `The Safe at ${error.address.slice(0, 6)}...${error.address.slice(-4)} could not be found on this network.`,
+ }
+ case 'NOT_SAFE_OWNER':
+ return {
+ title: 'Not a Safe Owner',
+ message:
+ error.message ||
+ `Address ${error.address.slice(0, 6)}...${error.address.slice(-4)} is not an owner of Safe ${error.safeAddress.slice(0, 6)}...${error.safeAddress.slice(-4)}.`,
+ }
+ case 'VERIFICATION_FAILED':
+ return {
+ title: 'Verification Failed',
+ message: error.message || 'Failed to verify your signature.',
+ }
+ case 'NETWORK_ERROR':
+ return {
+ title: 'Network Error',
+ message: error.message || 'A network error occurred. Please try again.',
+ }
+ default:
+ return {
+ title: 'Error',
+ message: 'An unexpected error occurred.',
+ }
+ }
+}
+
+export function ErrorView({ error, onRetry, onBack, onClose }: ErrorViewProps) {
+ const { title, message } = getErrorMessage(error)
+
+ return (
+
+ {title}
+
+
+
+ {message}
+
+
+
+
+ {onRetry && (
+
+ )}
+ {onBack && (
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/wallet/LoadingView.tsx b/apps/web/src/components/wallet/LoadingView.tsx
new file mode 100644
index 000000000..a00cb8baf
--- /dev/null
+++ b/apps/web/src/components/wallet/LoadingView.tsx
@@ -0,0 +1,20 @@
+'use client'
+
+import { Flex, Spinner, Stack, Text } from '@buildeross/zord'
+
+interface LoadingViewProps {
+ message?: string
+}
+
+export function LoadingView({ message = 'Loading...' }: LoadingViewProps) {
+ return (
+
+
+
+
+
+ {message}
+
+
+ )
+}
diff --git a/apps/web/src/components/wallet/SafeAddressView.tsx b/apps/web/src/components/wallet/SafeAddressView.tsx
new file mode 100644
index 000000000..060851152
--- /dev/null
+++ b/apps/web/src/components/wallet/SafeAddressView.tsx
@@ -0,0 +1,161 @@
+'use client'
+
+import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains'
+import { SAFE_SERVICE_URL } from '@buildeross/constants/safe'
+import { CHAIN_ID } from '@buildeross/types'
+import { DropdownSelect, FIELD_TYPES, SmartInput } from '@buildeross/ui'
+import { getRecentSafeWallets } from '@buildeross/utils'
+import { Box, Button, Flex, Stack, Text } from '@buildeross/zord'
+import Image from 'next/image'
+import { ChangeEvent, useState } from 'react'
+import type { Address } from 'viem'
+import { isAddress } from 'viem'
+
+interface SafeAddressViewProps {
+ onSubmit: (address: Address, chainId: number) => void
+ onBack: () => void
+ error?: string
+}
+
+// Dynamically build supported chains from SAFE_SERVICE_URL + PUBLIC_DEFAULT_CHAINS
+const getSupportedChains = () => {
+ return PUBLIC_DEFAULT_CHAINS.filter(
+ (chain) => SAFE_SERVICE_URL[chain.id as CHAIN_ID] !== undefined
+ ).map((chain) => ({
+ value: chain.id as CHAIN_ID,
+ label: chain.name,
+ }))
+}
+
+const SUPPORTED_CHAINS = getSupportedChains()
+const CHAINS_BY_ID = new Map(PUBLIC_DEFAULT_CHAINS.map((chain) => [chain.id, chain]))
+
+const formatSafeAddress = (address: Address) =>
+ `${address.slice(0, 6)}...${address.slice(-4)}`
+
+export function SafeAddressView({ onSubmit, onBack, error }: SafeAddressViewProps) {
+ const [address, setAddress] = useState('')
+ const [chainId, setChainId] = useState(CHAIN_ID.ETHEREUM)
+ const [validationError, setValidationError] = useState()
+ const recentSafeWallets = getRecentSafeWallets()
+ .filter((wallet) => CHAINS_BY_ID.has(wallet.chainId))
+ .slice(0, 3)
+
+ const handleSubmit = () => {
+ if (!address) {
+ setValidationError('Please enter a Safe address')
+ return
+ }
+
+ if (!isAddress(address)) {
+ setValidationError('Invalid address format')
+ return
+ }
+
+ setValidationError(undefined)
+ onSubmit(address as Address, chainId)
+ }
+
+ return (
+
+ Connect Safe Wallet
+
+
+ Enter your Safe address and select the network
+
+
+
+ setChainId(value)}
+ inputLabel="Network"
+ height="x12"
+ />
+
+ ) => {
+ setAddress(e.target.value)
+ setValidationError(undefined)
+ }}
+ inputLabel="Safe Address"
+ placeholder="0x..."
+ isAddress={true}
+ errorMessage={validationError}
+ />
+
+
+ {recentSafeWallets.length > 0 && (
+
+
+ Recent safes
+
+
+
+ {recentSafeWallets.map((wallet) => {
+ const chain = CHAINS_BY_ID.get(wallet.chainId)
+ if (!chain) return null
+
+ return (
+
+ )
+ })}
+
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/wallet/SignatureView.tsx b/apps/web/src/components/wallet/SignatureView.tsx
new file mode 100644
index 000000000..4b45cd173
--- /dev/null
+++ b/apps/web/src/components/wallet/SignatureView.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import { Box, Button, Flex, Stack, Text } from '@buildeross/zord'
+import type { Address } from 'viem'
+
+interface SignatureViewProps {
+ address: Address
+ walletName: string
+ mode: 'eoa' | 'safe'
+ safeAddress?: Address
+ onSign: () => void
+ onCancel: () => void
+ error?: string
+ isAuthenticating?: boolean
+}
+
+export function SignatureView({
+ address,
+ walletName,
+ mode,
+ safeAddress,
+ onSign,
+ onCancel,
+ error,
+ isAuthenticating,
+}: SignatureViewProps) {
+ return (
+
+ Sign Message
+
+
+
+
+ {mode === 'safe' ? 'Signing wallet' : 'Wallet'}
+
+ {walletName}
+
+
+
+
+ {mode === 'safe' ? 'Signing as owner' : 'Address'}
+
+
+ {address.slice(0, 6)}...{address.slice(-4)}
+
+
+
+ {mode === 'safe' && safeAddress && (
+
+
+ Safe address
+
+
+ {safeAddress.slice(0, 6)}...{safeAddress.slice(-4)}
+
+
+ )}
+
+
+
+
+ Confirm the message in {walletName} to continue
+
+
+
+ {error && (
+
+
+ {error}
+
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/wallet/WalletListView.tsx b/apps/web/src/components/wallet/WalletListView.tsx
new file mode 100644
index 000000000..d927ed88c
--- /dev/null
+++ b/apps/web/src/components/wallet/WalletListView.tsx
@@ -0,0 +1,137 @@
+'use client'
+
+import { Button, Icon, Stack, Text } from '@buildeross/zord'
+
+import type { WalletInfo } from '../../types/auth'
+import { WalletOption } from '../WalletOption'
+
+interface WalletListViewProps {
+ wallets: WalletInfo[]
+ onSelectWallet: (walletId: string) => void
+ onSelectSafe: () => void
+ showSafeOption?: boolean
+ title?: string
+ description?: string
+}
+
+export function WalletListView({
+ wallets,
+ onSelectWallet,
+ onSelectSafe,
+ showSafeOption = true,
+ title = 'Connect Wallet',
+ description,
+}: WalletListViewProps) {
+ const installedWallets = wallets.filter((w) => !w.isRainbowKitConnector)
+ const popularWallets = wallets.filter((w) => w.isRainbowKitConnector)
+
+ return (
+
+
+ {title}
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {installedWallets.length > 0 && (
+
+
+ Installed
+
+ {installedWallets.map((wallet) => (
+ onSelectWallet(wallet.id)}
+ />
+ ))}
+
+ )}
+
+
+
+ Popular
+
+ {popularWallets.map((wallet) => (
+ onSelectWallet(wallet.id)}
+ />
+ ))}
+
+
+ {showSafeOption && (
+
+
+ Multi-Sig
+
+
+
+ )}
+
+
+ By connecting a wallet, you acknowledge and agree to the Nouns Builder{' '}
+
+ Terms of Service
+ {' '}
+ and{' '}
+
+ Privacy Policy
+
+ .
+
+
+
+
+ )
+}
diff --git a/apps/web/src/connectors/safeOwnerConnector.ts b/apps/web/src/connectors/safeOwnerConnector.ts
new file mode 100644
index 000000000..da496b7d8
--- /dev/null
+++ b/apps/web/src/connectors/safeOwnerConnector.ts
@@ -0,0 +1,418 @@
+import {
+ clearSafeInfo,
+ type EIP1193Provider,
+ getSavedSafeInfo,
+ isOwnerOfSafe,
+ type SafeInfo,
+ SafeOwnerProvider,
+ setSafeInfo,
+} from '@buildeross/utils'
+import { getConnectors } from '@wagmi/core'
+import type { PublicClient } from 'viem'
+import { createPublicClient, http } from 'viem'
+import {
+ type Config,
+ type Connector,
+ createConnector,
+ type CreateConnectorFn,
+ ProviderNotFoundError,
+} from 'wagmi'
+
+createSafeOwnerConnector.type = 'safeOwner' as const
+
+// Global reference to wagmi config (set after config is created)
+let wagmiConfig: Config | null = null
+
+export function setWagmiConfig(config: Config) {
+ wagmiConfig = config
+}
+
+/**
+ * Creates a static SafeOwnerConnector that reads Safe configuration from localStorage.
+ * This connector works with wagmi's built-in persistence and reconnection system.
+ *
+ * Safe info (address, chainId, eoaConnectorId) is stored in localStorage and loaded on-demand.
+ * The EOA connector is discovered from wagmi's config by ID.
+ */
+export function createSafeOwnerConnector(): CreateConnectorFn {
+ type Provider = SafeOwnerProvider | undefined
+ type Properties = {
+ safeInfo: SafeInfo | null
+ }
+
+ let provider_: Provider | undefined
+ let safeInfo_: SafeInfo | null = null
+ let publicClient_: PublicClient | null = null
+ let eoaConnector_: Connector | null = null
+ let eoaAddress_: `0x${string}` | null = null
+
+ return createConnector((config) => {
+ /**
+ * Load Safe configuration from localStorage
+ */
+ function loadSafeConfig() {
+ return getSavedSafeInfo()
+ }
+
+ /**
+ * Find EOA connector by ID from wagmi config
+ */
+ function findEOAConnector(connectorId: string): Connector | null {
+ if (!wagmiConfig) {
+ console.error('[SafeOwnerConnector] wagmiConfig not initialized')
+ return null
+ }
+ const connectors = getConnectors(wagmiConfig)
+ return connectors.find((c: Connector) => c.id === connectorId) || null
+ }
+
+ /**
+ * Clear cached state
+ */
+ function clearCache() {
+ provider_ = undefined
+ safeInfo_ = null
+ publicClient_ = null
+ eoaConnector_ = null
+ eoaAddress_ = null
+ }
+
+ return {
+ id: 'safeOwner',
+ name: 'Safe',
+ type: createSafeOwnerConnector.type,
+
+ async setup() {
+ // Called once when connector is initialized
+ // We defer loading until needed for performance
+ },
+
+ /**
+ * Check if this connector should auto-reconnect.
+ * Returns true if:
+ * 1. Safe info exists in localStorage
+ * 2. EOA connector exists and is authorized
+ * 3. EOA is still a Safe owner
+ */
+ async isAuthorized() {
+ try {
+ const saved = loadSafeConfig()
+ if (!saved) return false
+
+ // Find the EOA connector
+ const eoaConnector = findEOAConnector(saved.eoaConnectorId)
+ if (!eoaConnector) return false
+
+ // Check if EOA connector is still authorized
+ const eoaAuthorized = await eoaConnector.isAuthorized()
+ if (!eoaAuthorized) {
+ // EOA no longer authorized, clear stale Safe info
+ clearSafeInfo()
+ return false
+ }
+
+ // Get EOA accounts
+ const eoaAccounts = await eoaConnector.getAccounts()
+ if (!eoaAccounts || eoaAccounts.length === 0) {
+ clearSafeInfo()
+ return false
+ }
+
+ // Verify EOA is still a Safe owner
+ const isOwner = await isOwnerOfSafe(
+ eoaAccounts[0],
+ saved.safeAddress,
+ saved.chainId
+ )
+
+ if (!isOwner) {
+ // No longer an owner, clear stale Safe info
+ clearSafeInfo()
+ return false
+ }
+
+ return true
+ } catch (error) {
+ console.error('[SafeOwnerConnector] isAuthorized error:', error)
+ return false
+ }
+ },
+
+ /**
+ * Connect method with type assertion for wagmi compatibility.
+ *
+ * Type assertion (as any) is necessary here due to TypeScript's limitation with
+ * generic conditional types. wagmi expects:
+ * connect(...)
+ * => Promise<{ accounts: withCapabilities extends true ? CapabilityAccount[] : Address[] }>
+ *
+ * This pattern is standard in wagmi core connectors.
+ * The runtime behavior is correct - we return Address[] (Safe doesn't support EIP-5792).
+ */
+ connect: (async (_parameters?: {
+ chainId?: number
+ isReconnecting?: boolean
+ withCapabilities?: boolean
+ }) => {
+ const saved = loadSafeConfig()
+ if (!saved) {
+ throw new Error(
+ 'No Safe configuration found. Please connect via Safe mode first.'
+ )
+ }
+
+ // Find and cache EOA connector
+ eoaConnector_ = findEOAConnector(saved.eoaConnectorId)
+ if (!eoaConnector_) {
+ throw new Error(
+ `EOA connector '${saved.eoaConnectorId}' not found. Please reconnect your wallet.`
+ )
+ }
+
+ // Verify EOA connector is authorized (should be true from isAuthorized check)
+ const eoaAuthorized = await eoaConnector_.isAuthorized()
+ if (!eoaAuthorized) {
+ throw new Error('EOA wallet is not authorized. Please reconnect.')
+ }
+
+ // Cache EOA address for synchronous access
+ const eoaAccounts = await eoaConnector_.getAccounts()
+ eoaAddress_ = eoaAccounts?.[0] || null
+
+ // Persist the resolved EOA address so the signing UI can read it back later.
+ setSafeInfo(
+ {
+ safeAddress: saved.safeAddress,
+ chainId: saved.chainId,
+ threshold: saved.threshold,
+ owners: saved.owners,
+ isReadOnly: false,
+ nonce: saved.nonce,
+ version: saved.version,
+ },
+ saved.eoaConnectorId,
+ eoaAddress_
+ )
+
+ // Load Safe info from cache if not already loaded
+ if (!safeInfo_) {
+ // Use cached SafeInfo from localStorage (already fetched during validation)
+ safeInfo_ = {
+ safeAddress: saved.safeAddress,
+ chainId: saved.chainId,
+ threshold: saved.threshold,
+ owners: saved.owners,
+ isReadOnly: false,
+ nonce: saved.nonce,
+ version: saved.version,
+ }
+ }
+
+ // Return Safe address as the connected account
+ return {
+ accounts: [saved.safeAddress],
+ chainId: Number(saved.chainId),
+ }
+ }) as any,
+
+ async disconnect() {
+ // Clean up provider
+ if (provider_) {
+ provider_.destroy()
+ }
+
+ // Clear cached state
+ clearCache()
+
+ // Clear Safe info from storage
+ clearSafeInfo()
+ },
+
+ async getAccounts() {
+ const saved = loadSafeConfig()
+ if (!saved) return []
+ return [saved.safeAddress]
+ },
+
+ async getProvider() {
+ const saved = loadSafeConfig()
+ if (!saved) {
+ throw new Error('No Safe configuration found')
+ }
+
+ if (
+ provider_ &&
+ safeInfo_ &&
+ (safeInfo_.safeAddress.toLowerCase() !== saved.safeAddress.toLowerCase() ||
+ safeInfo_.chainId !== saved.chainId ||
+ !eoaConnector_ ||
+ eoaConnector_.id !== saved.eoaConnectorId)
+ ) {
+ provider_.destroy()
+ clearCache()
+ }
+
+ if (!provider_) {
+ // Get EOA connector
+ if (!eoaConnector_) {
+ eoaConnector_ = findEOAConnector(saved.eoaConnectorId)
+ }
+ if (!eoaConnector_) {
+ throw new ProviderNotFoundError()
+ }
+
+ // Get EOA provider
+ const rawProvider = await eoaConnector_.getProvider()
+ if (!rawProvider) {
+ throw new ProviderNotFoundError()
+ }
+
+ // Type assert to EIP1193Provider
+ const eoaProvider = rawProvider as EIP1193Provider
+
+ // Create public client if not already created
+ if (!publicClient_) {
+ const chain = config.chains.find((c) => c.id === saved.chainId)
+ if (!chain) {
+ throw new Error(`Chain ${saved.chainId} not found in wagmi config`)
+ }
+
+ publicClient_ = createPublicClient({
+ chain,
+ transport: http(),
+ })
+ }
+
+ // Ensure we have Safe info
+ if (!safeInfo_) {
+ // Use cached SafeInfo from localStorage (already fetched during validation)
+ safeInfo_ = {
+ safeAddress: saved.safeAddress,
+ chainId: saved.chainId,
+ threshold: saved.threshold,
+ owners: saved.owners,
+ isReadOnly: false,
+ nonce: saved.nonce,
+ version: saved.version,
+ }
+ }
+
+ // Create SafeOwnerProvider
+ provider_ = new SafeOwnerProvider(safeInfo_, eoaProvider, publicClient_)
+ }
+
+ return provider_
+ },
+
+ async getChainId() {
+ const saved = loadSafeConfig()
+ if (!saved) {
+ throw new Error('No Safe configuration found')
+ }
+ return saved.chainId
+ },
+
+ async switchChain({ chainId: newChainId }) {
+ const saved = loadSafeConfig()
+ if (!saved) {
+ throw new Error('No Safe configuration found')
+ }
+
+ // Safes are chain-specific contracts - cannot switch chains
+ if (newChainId !== saved.chainId) {
+ throw new Error(
+ 'Cannot switch chain for Safe. Safes are chain-specific smart contracts.'
+ )
+ }
+
+ return config.chains.find((c) => c.id === saved.chainId)!
+ },
+
+ onAccountsChanged(_accounts) {
+ const saved = loadSafeConfig()
+ if (saved) {
+ // EOA accounts changed, but we still report Safe address
+ config.emitter.emit('change', { accounts: [saved.safeAddress] })
+ }
+ },
+
+ onChainChanged(newChainId) {
+ const saved = loadSafeConfig()
+ if (saved && Number(newChainId) !== saved.chainId) {
+ // Safe is on a different chain, disconnect
+ config.emitter.emit('disconnect')
+ }
+ },
+
+ onDisconnect() {
+ if (provider_) {
+ provider_.destroy()
+ }
+ clearCache()
+ clearSafeInfo()
+ config.emitter.emit('disconnect')
+ },
+
+ // Custom property to expose Safe info
+ get safeInfo() {
+ // Lazily load from localStorage if not already cached
+ if (!safeInfo_) {
+ const saved = loadSafeConfig()
+ if (saved) {
+ safeInfo_ = {
+ safeAddress: saved.safeAddress,
+ chainId: saved.chainId,
+ threshold: saved.threshold,
+ owners: saved.owners,
+ isReadOnly: false,
+ nonce: saved.nonce,
+ version: saved.version,
+ }
+ }
+ }
+ return safeInfo_
+ },
+
+ // Synchronous getter for cached EOA connector
+ get cachedEOAConnector(): Connector | null {
+ return eoaConnector_
+ },
+
+ // Synchronous getter for cached EOA address
+ get cachedEOAAddress(): `0x${string}` | null {
+ return eoaAddress_ ?? loadSafeConfig()?.eoaAddress ?? null
+ },
+
+ // Custom method to get EOA connector
+ async getEOAConnector(): Promise {
+ const saved = loadSafeConfig()
+ if (!saved) return null
+
+ if (!eoaConnector_) {
+ eoaConnector_ = findEOAConnector(saved.eoaConnectorId)
+ }
+ if (!eoaConnector_) return null
+ return eoaConnector_
+ },
+
+ // Custom method to get EOA address for signing
+ async getEOAAddress(): Promise<`0x${string}` | null> {
+ const saved = loadSafeConfig()
+ if (!saved) return null
+
+ if (saved.eoaAddress) {
+ eoaAddress_ = saved.eoaAddress
+ return saved.eoaAddress
+ }
+
+ if (!eoaConnector_) {
+ eoaConnector_ = findEOAConnector(saved.eoaConnectorId)
+ }
+ if (!eoaConnector_) return null
+
+ const eoaAccounts = await eoaConnector_.getAccounts()
+ return eoaAccounts?.[0] || null
+ },
+ }
+ })
+}
diff --git a/apps/web/src/constants/walletConfig.ts b/apps/web/src/constants/walletConfig.ts
new file mode 100644
index 000000000..5660f98e1
--- /dev/null
+++ b/apps/web/src/constants/walletConfig.ts
@@ -0,0 +1,142 @@
+export interface WalletMetadata {
+ name: string
+ icon: string
+ iconBackground: string
+ iconAccent?: string
+}
+
+export const WALLET_CONFIG: Record = {
+ // MetaMask
+ 'io.metamask': {
+ name: 'MetaMask',
+ icon: '/icons/wallets/metamask.svg',
+ iconBackground: '#fff',
+ iconAccent: '#f6851a',
+ },
+ metaMask: {
+ name: 'MetaMask',
+ icon: '/icons/wallets/metamask.svg',
+ iconBackground: '#fff',
+ iconAccent: '#f6851a',
+ },
+
+ // Rainbow
+ 'me.rainbow': {
+ name: 'Rainbow',
+ icon: '/icons/wallets/rainbow.svg',
+ iconBackground: '#001e59',
+ },
+ rainbow: {
+ name: 'Rainbow',
+ icon: '/icons/wallets/rainbow.svg',
+ iconBackground: '#001e59',
+ },
+
+ // Coinbase
+ 'com.coinbase.wallet': {
+ name: 'Coinbase Wallet',
+ icon: '/icons/wallets/coinbase.svg',
+ iconBackground: '#0052ff',
+ },
+ coinbase: {
+ name: 'Coinbase Wallet',
+ icon: '/icons/wallets/coinbase.svg',
+ iconBackground: '#0052ff',
+ },
+ coinbaseWallet: {
+ name: 'Coinbase Wallet',
+ icon: '/icons/wallets/coinbase.svg',
+ iconBackground: '#0052ff',
+ },
+
+ // WalletConnect
+ walletConnect: {
+ name: 'WalletConnect',
+ icon: '/icons/wallets/walletconnect.svg',
+ iconBackground: '#3b99fc',
+ },
+
+ // Rabby
+ 'io.rabby': {
+ name: 'Rabby Wallet',
+ icon: '/icons/wallets/rabby.svg',
+ iconBackground: '#8697ff',
+ },
+ rabby: {
+ name: 'Rabby Wallet',
+ icon: '/icons/wallets/rabby.svg',
+ iconBackground: '#8697ff',
+ },
+
+ // Ledger
+ ledger: {
+ name: 'Ledger',
+ icon: '/icons/wallets/ledger.svg',
+ iconBackground: '#000',
+ },
+ ledgerHid: {
+ name: 'Ledger',
+ icon: '/icons/wallets/ledger.svg',
+ iconBackground: '#000',
+ },
+
+ // Safe
+ safe: {
+ name: 'Safe',
+ icon: '/icons/wallets/safe.svg',
+ iconBackground: '#12ff80',
+ },
+
+ // Brave
+ 'com.brave.wallet': {
+ name: 'Brave Wallet',
+ icon: '/icons/wallets/brave.svg',
+ iconBackground: '#fff',
+ },
+ brave: {
+ name: 'Brave Wallet',
+ icon: '/icons/wallets/brave.svg',
+ iconBackground: '#fff',
+ },
+
+ // Phantom
+ 'app.phantom': {
+ name: 'Phantom',
+ icon: '/icons/wallets/phantom.svg',
+ iconBackground: '#ab9ff2',
+ },
+ phantom: {
+ name: 'Phantom',
+ icon: '/icons/wallets/phantom.svg',
+ iconBackground: '#ab9ff2',
+ },
+
+ // Trust Wallet
+ 'com.trustwallet.app': {
+ name: 'Trust Wallet',
+ icon: '/icons/wallets/trust.svg',
+ iconBackground: '#3375bb',
+ },
+ trust: {
+ name: 'Trust Wallet',
+ icon: '/icons/wallets/trust.svg',
+ iconBackground: '#3375bb',
+ },
+
+ // Generic injected
+ injected: {
+ name: 'Browser Wallet',
+ icon: '/icons/wallets/injected.svg',
+ iconBackground: '#fff',
+ },
+}
+
+export function getWalletMetadata(connectorId: string): WalletMetadata {
+ return (
+ WALLET_CONFIG[connectorId] || {
+ name: 'Wallet',
+ icon: '/icons/wallets/injected.svg',
+ iconBackground: '#fff',
+ }
+ )
+}
diff --git a/apps/web/src/hooks/useAppDisconnect.ts b/apps/web/src/hooks/useAppDisconnect.ts
new file mode 100644
index 000000000..8c99c3430
--- /dev/null
+++ b/apps/web/src/hooks/useAppDisconnect.ts
@@ -0,0 +1,59 @@
+import { useSafeAuth } from '@buildeross/hooks'
+import { useWalletDisconnect } from '@buildeross/hooks/useWalletDisconnect'
+import { useCallback, useRef } from 'react'
+import { useSWRConfig } from 'swr'
+
+import { debugSession } from '../utils/debug'
+import {
+ beginSiweLogout,
+ cancelSiweAuthFlow,
+ SIWE_LOGOUT_EVENT,
+ SIWE_ME_PATH,
+} from '../utils/siweAuthFlow'
+
+/**
+ * App-specific disconnect hook that wraps useWalletDisconnect
+ * and adds SIWE session + Safe cleanup logic
+ */
+export function useAppDisconnect(): () => Promise {
+ const baseDisconnect = useWalletDisconnect()
+ const { clearSafe } = useSafeAuth()
+ const { mutate } = useSWRConfig()
+
+ // Track if disconnect is in progress
+ const disconnectInProgressRef = useRef(false)
+
+ return useCallback(async () => {
+ // Prevent concurrent disconnect operations
+ if (disconnectInProgressRef.current) {
+ debugSession('Disconnect already in progress, skipping')
+ return
+ }
+
+ try {
+ disconnectInProgressRef.current = true
+
+ // 1) Cancel any pending SIWE auth flow
+ beginSiweLogout()
+ cancelSiweAuthFlow()
+
+ // 2) Clear Safe wallet info
+ clearSafe()
+
+ // 3) Optimistically clear SWR cache to prevent showing stale session data
+ // Use mutate with revalidate=false to immediately update cache without refetching
+ mutate(SIWE_ME_PATH, null, false)
+
+ // 4) Ask the app-level session machine to perform logout
+ window.dispatchEvent(new Event(SIWE_LOGOUT_EVENT))
+
+ // 5) Disconnect wallet only after logout has been initiated
+ await baseDisconnect()
+ } finally {
+ // Reset flag after a delay to prevent rapid re-triggers
+ setTimeout(() => {
+ disconnectInProgressRef.current = false
+ }, 500)
+ }
+ }, [baseDisconnect, clearSafe, mutate])
+}
diff --git a/apps/web/src/hooks/useAsyncImage.ts b/apps/web/src/hooks/useAsyncImage.ts
new file mode 100644
index 000000000..59647be70
--- /dev/null
+++ b/apps/web/src/hooks/useAsyncImage.ts
@@ -0,0 +1,57 @@
+import { useEffect, useReducer } from 'react'
+
+export type AsyncImageSrc = () => Promise
+
+const cachedUrls = new Map()
+const cachedRequestPromises = new Map>()
+
+async function loadAsyncImage(asyncImage: () => Promise) {
+ const cachedRequestPromise = cachedRequestPromises.get(asyncImage)
+
+ // Don't fetch if we already have a request in progress / completed
+ if (cachedRequestPromise) {
+ return cachedRequestPromise
+ }
+
+ const load = async () =>
+ asyncImage().then(async (url: string) => {
+ cachedUrls.set(asyncImage, url)
+ return url
+ })
+
+ const requestPromise = load().catch(() => {
+ // Retry once if the request failed
+ return load().catch(() => {
+ // Ignore failed retry, remove failed request from promise cache
+ cachedRequestPromises.delete(asyncImage)
+ })
+ })
+
+ cachedRequestPromises.set(asyncImage, requestPromise)
+
+ return requestPromise
+}
+
+export async function loadImages(...urls: (string | AsyncImageSrc)[]) {
+ return await Promise.all(
+ urls.map((url) => (typeof url === 'function' ? loadAsyncImage(url) : url))
+ )
+}
+
+function useForceUpdate() {
+ const [, forceUpdate] = useReducer((x) => x + 1, 0)
+ return forceUpdate
+}
+
+export function useAsyncImage(url?: string | AsyncImageSrc): string | undefined {
+ const cachedUrl = typeof url === 'function' ? cachedUrls.get(url) : undefined
+ const forceUpdate = useForceUpdate()
+
+ useEffect(() => {
+ if (typeof url === 'function' && !cachedUrl) {
+ loadAsyncImage(url).then(forceUpdate)
+ }
+ }, [url, cachedUrl, forceUpdate])
+
+ return typeof url === 'function' ? cachedUrl : url
+}
diff --git a/apps/web/src/hooks/useSession.ts b/apps/web/src/hooks/useSession.ts
new file mode 100644
index 000000000..682a3c191
--- /dev/null
+++ b/apps/web/src/hooks/useSession.ts
@@ -0,0 +1,46 @@
+import useSWR from 'swr'
+
+import type { SessionData } from '../types/auth'
+import { debugSession } from '../utils/debug'
+import { SIWE_ME_PATH } from '../utils/siweAuthFlow'
+
+const fetcher = async (url: string): Promise => {
+ debugSession('Fetching session from %s', url)
+ const res = await fetch(url, { cache: 'no-store' })
+
+ if (!res.ok) {
+ debugSession('Session fetch failed: %d', res.status)
+ // Return empty for auth errors (expected case - not authenticated)
+ if (res.status === 401 || res.status === 403) {
+ return {}
+ }
+ // Throw for server errors so SWR can handle retry
+ throw new Error(`Session fetch failed: ${res.status}`)
+ }
+
+ const data = await res.json()
+ debugSession('Session data: %O', data)
+ return data
+}
+
+export function useSession() {
+ const { data, error, mutate, isLoading } = useSWR(SIWE_ME_PATH, fetcher, {
+ refreshInterval: 60000, // Auto-refresh every minute
+ revalidateOnFocus: true, // Check when tab focused
+ revalidateOnReconnect: true, // Check when network restored
+ shouldRetryOnError: false, // Don't retry 401s
+ dedupingInterval: 5000, // Dedupe within 5 seconds
+ })
+
+ return {
+ session: data,
+ address: data?.safeAddress || data?.address,
+ eoaAddress: data?.eoaAddress,
+ safeAddress: data?.safeAddress,
+ isAuthenticated: !!(data?.address || data?.safeAddress),
+ isSafeMode: !!data?.safeAddress,
+ isLoading,
+ error,
+ refresh: mutate,
+ }
+}
diff --git a/apps/web/src/hooks/useWalletConnectors.ts b/apps/web/src/hooks/useWalletConnectors.ts
new file mode 100644
index 000000000..14ad3a754
--- /dev/null
+++ b/apps/web/src/hooks/useWalletConnectors.ts
@@ -0,0 +1,172 @@
+import { type Connector, useConnect } from 'wagmi'
+
+import { getRecentWalletIds } from '../utils/recentWalletIds'
+
+// Simplified WalletInstance type (from RainbowKit)
+interface WalletInstance extends Connector {
+ isRainbowKitConnector?: boolean
+ groupName?: string
+ groupIndex?: number
+ rdns?: string
+ iconUrl?: string
+ iconBackground?: string
+ installed?: boolean
+ rkDetails?: {
+ isRainbowKitConnector?: boolean
+ groupName?: string
+ groupIndex?: number
+ rdns?: string
+ iconUrl?: string
+ iconBackground?: string
+ installed?: boolean
+ }
+}
+
+export interface WalletConnector {
+ id: string
+ name: string
+ uid?: string
+ icon?: string
+ iconUrl: string
+ iconBackground: string
+ ready: boolean
+ groupName: string
+ groupIndex: number
+ recent: boolean
+ isRainbowKitConnector: boolean
+ connect: () => Promise
+ connector: Connector
+}
+
+function isEIP6963Connector(wallet: WalletInstance): boolean {
+ return !!(
+ !wallet.isRainbowKitConnector &&
+ !wallet.rkDetails?.isRainbowKitConnector &&
+ wallet.icon?.replace(/\n/g, '').startsWith('data:image') &&
+ wallet.uid &&
+ wallet.name
+ )
+}
+
+function isRainbowKitConnector(wallet: WalletInstance): boolean {
+ return !!(wallet.isRainbowKitConnector || wallet.rkDetails?.isRainbowKitConnector)
+}
+
+export function useWalletConnectors(): WalletConnector[] {
+ const { connectAsync, connectors: defaultConnectors_untyped } = useConnect()
+ const defaultCreatedConnectors = defaultConnectors_untyped as WalletInstance[]
+
+ // Merge connector with its rkDetails (RainbowKit injects details via this property)
+ const defaultConnectors = defaultCreatedConnectors.map((connector) => ({
+ ...connector,
+ ...(connector.rkDetails || {}),
+ })) as WalletInstance[]
+
+ // Connect function that adds to recent wallets
+ async function connectWallet(connector: Connector): Promise {
+ const result = await connectAsync({
+ connector,
+ })
+ return result
+ }
+
+ // Separate EIP-6963 (auto-discovered browser extensions)
+ const eip6963Connectors = defaultConnectors
+ .filter(isEIP6963Connector)
+ .map((connector) => ({
+ ...connector,
+ groupIndex: 0,
+ groupName: 'Installed',
+ }))
+
+ // Separate RainbowKit configured wallets
+ const seenIds = new Set()
+ const rainbowKitConnectors = defaultConnectors
+ .filter(isRainbowKitConnector)
+ .filter((wallet) => wallet.id !== 'safe') // Exclude Safe
+ .filter((wallet) => {
+ // Deduplicate: remove RainbowKit connector if EIP-6963 version exists
+ const existsInEIP6963 = eip6963Connectors.some(
+ (eip6963) => eip6963.id === wallet.rdns
+ )
+ if (existsInEIP6963) return false
+
+ // Deduplicate by connector ID (e.g., multiple walletConnect instances)
+ if (seenIds.has(wallet.id)) {
+ return false
+ }
+ seenIds.add(wallet.id)
+ return true
+ })
+
+ const combinedConnectors = [...eip6963Connectors, ...rainbowKitConnectors]
+
+ // Create index by wallet ID for recent wallet lookup
+ const walletInstanceById: Record = {}
+ for (const wallet of combinedConnectors) {
+ walletInstanceById[wallet.id] = wallet
+ }
+
+ // Get recent wallets
+ const MAX_RECENT_WALLETS = 3
+ const recentWallets: WalletInstance[] = getRecentWalletIds()
+ .map((walletId) => walletInstanceById[walletId])
+ .filter(Boolean)
+ .slice(0, MAX_RECENT_WALLETS)
+
+ // Combine with recent wallets at the front
+ const combinedWithRecent = [
+ ...recentWallets,
+ ...combinedConnectors.filter(
+ (wallet) => !recentWallets.some((recent) => recent.id === wallet.id)
+ ),
+ ]
+
+ // Map to WalletConnector format
+ const walletConnectors: WalletConnector[] = []
+
+ for (const wallet of combinedWithRecent) {
+ if (!wallet) continue
+
+ const eip6963 = isEIP6963Connector(wallet)
+ const recent = recentWallets.some((recentWallet) => recentWallet.id === wallet.id)
+
+ if (eip6963) {
+ // EIP-6963 connectors (browser extensions)
+ walletConnectors.push({
+ id: wallet.id,
+ name: wallet.name || 'Wallet',
+ uid: wallet.uid,
+ icon: wallet.icon,
+ iconUrl: wallet.icon || '/icons/wallets/injected.svg',
+ iconBackground: wallet.iconBackground || '#fff',
+ ready: true,
+ groupName: 'Installed',
+ groupIndex: 0,
+ recent,
+ isRainbowKitConnector: false,
+ connect: () => connectWallet(wallet),
+ connector: wallet,
+ })
+ } else {
+ // RainbowKit configured wallets
+ walletConnectors.push({
+ id: wallet.id,
+ name: wallet.name || 'Wallet',
+ uid: wallet.uid,
+ icon: wallet.icon,
+ iconUrl: wallet.iconUrl || wallet.icon || '/icons/wallets/injected.svg',
+ iconBackground: wallet.iconBackground || '#fff',
+ ready: wallet.installed ?? true,
+ groupName: wallet.groupName || 'Popular',
+ groupIndex: wallet.groupIndex || 999,
+ recent,
+ isRainbowKitConnector: true,
+ connect: () => connectWallet(wallet),
+ connector: wallet,
+ })
+ }
+ }
+
+ return walletConnectors
+}
diff --git a/apps/web/src/layouts/BaseLayout/BaseLayout.tsx b/apps/web/src/layouts/BaseLayout/BaseLayout.tsx
index ff64d4eab..6584f7326 100644
--- a/apps/web/src/layouts/BaseLayout/BaseLayout.tsx
+++ b/apps/web/src/layouts/BaseLayout/BaseLayout.tsx
@@ -9,6 +9,7 @@ import {
getCandidateStore,
getProposalStore,
ProposalStoreProvider,
+ useAuthStore,
useChainStore,
useDaoStore,
} from '@buildeross/stores'
@@ -18,8 +19,9 @@ import { Box } from '@buildeross/zord'
import { useConnectModal } from '@rainbow-me/rainbowkit'
import React, { ReactNode, useMemo } from 'react'
import { zeroAddress as ZERO_ADDRESS } from 'viem'
-import { useAccount } from 'wagmi'
+import { ErrorBoundary } from '../../components/ErrorBoundary'
+import { SafeTransactionHandler } from '../../components/SafeTransactionHandler'
import { Nav as DefaultLayoutNav } from '../DefaultLayout/Nav'
type BoxProps = React.ComponentProps
@@ -59,6 +61,9 @@ export function BaseLayout({
{footer}
+
+
+
@@ -69,7 +74,7 @@ export function BaseLayout({
function DraftStoreProviders({ children }: { children: ReactNode }) {
const chain = useChainStore((state) => state.chain)
const addresses = useDaoStore((state) => state.addresses)
- const { address } = useAccount()
+ const { address } = useAuthStore()
const walletAddress = address ?? ZERO_ADDRESS
const tokenAddress = addresses.token ?? ZERO_ADDRESS
diff --git a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx
index 94352e6b5..6335d3427 100644
--- a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx
+++ b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx
@@ -1,16 +1,23 @@
-import { Flex } from '@buildeross/zord'
+import { useAuthStore } from '@buildeross/stores'
+import { Button, Flex } from '@buildeross/zord'
import { ConnectButton as RKConnectButton } from '@rainbow-me/rainbowkit'
-import React from 'react'
-import { useAccount } from 'wagmi'
+import dynamic from 'next/dynamic'
+import React, { useState } from 'react'
import { connectButtonWrapper } from './Nav.styles.css'
-export const ConnectButton = () => {
- const { address, chain: wagmiChain } = useAccount()
+// Lazy load WalletConnectDialog for better performance
+const WalletConnectDialog = dynamic(
+ () =>
+ import('src/components/WalletConnectDialog').then((mod) => ({
+ default: mod.WalletConnectDialog,
+ })),
+ { ssr: false }
+)
- if (address || wagmiChain) {
- return null
- }
+export const ConnectButton = () => {
+ const [showModal, setShowModal] = useState(false)
+ const { isSafeMode } = useAuthStore()
return (
{
justify="center"
cursor={'pointer'}
>
-
+
+ {({
+ account,
+ chain,
+ openAccountModal,
+ openChainModal,
+ mounted,
+ authenticationStatus,
+ }) => {
+ // SSR safety
+ if (!mounted) {
+ return (
+
+ )
+ }
+
+ // Not connected OR not authenticated - show custom connect button
+ if (!account || authenticationStatus === 'unauthenticated') {
+ return (
+ <>
+
+ setShowModal(false)}
+ />
+ >
+ )
+ }
+
+ // Still loading authentication
+ if (authenticationStatus === 'loading') {
+ return (
+
+ )
+ }
+
+ // Connected but wrong chain
+ if (chain?.unsupported) {
+ return (
+
+ )
+ }
+
+ // Connected - wagmi automatically shows Safe address when using SafeOwnerConnector
+ return (
+
+
+
+
+ )
+ }}
+
)
}
diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx
index 11c641a71..af21050c0 100644
--- a/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx
+++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx
@@ -1,11 +1,11 @@
import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains'
-import { useWalletDisconnect } from '@buildeross/hooks/useWalletDisconnect'
import { useChainStore } from '@buildeross/stores'
import { CHAIN_ID } from '@buildeross/types'
import { Box, Flex, Icon, PopUp, Stack, Text, vars } from '@buildeross/zord'
import Image from 'next/image'
import { useRouter } from 'next/router'
import React, { useCallback, useEffect, useMemo } from 'react'
+import { useAppDisconnect } from 'src/hooks/useAppDisconnect'
import { useAccount, useSwitchChain } from 'wagmi'
import {
@@ -30,9 +30,9 @@ export const ChainMenu: React.FC = ({
}) => {
const [isChainInitialized, setIsChainInitialized] = React.useState(false)
const router = useRouter()
- const { address, chain: wagmiChain, connector } = useAccount()
+ const { isConnected, chain: wagmiChain } = useAccount()
const { switchChain } = useSwitchChain()
- const onDisconnect = useWalletDisconnect()
+ const onDisconnect = useAppDisconnect()
const { chain: selectedChain, setChain, hasHydrated } = useChainStore()
@@ -40,17 +40,21 @@ export const ChainMenu: React.FC = ({
const onSwitchChain = useCallback(
(chainId: number) => {
- if (!connector?.getChainId) return onDisconnect()
switchChain(
{ chainId },
{
onError(error) {
console.error(`Failed to switch chain:`, error)
+ // Only disconnect if critical error (not user rejection)
+ if (error.message?.includes('User rejected') === false) {
+ console.warn('Chain switch failed critically, disconnecting')
+ onDisconnect()
+ }
},
}
)
},
- [switchChain, onDisconnect, connector]
+ [switchChain, onDisconnect]
)
const onChainChange = useCallback(
@@ -61,11 +65,11 @@ export const ChainMenu: React.FC = ({
onSetActiveDropdown(undefined)
const selected = PUBLIC_DEFAULT_CHAINS.find((x) => x.id === chainId)
if (selected) setChain(selected)
- if (address) {
+ if (isConnected) {
onSwitchChain(chainId)
}
},
- [onSetActiveDropdown, setChain, hasNetwork, onSwitchChain, address]
+ [onSetActiveDropdown, setChain, hasNetwork, onSwitchChain, isConnected]
)
const isSelectedChain = useCallback(
@@ -74,8 +78,8 @@ export const ChainMenu: React.FC = ({
)
const isWrongNetwork = useMemo(
- () => hasNetwork && !!address && wagmiChain?.id !== selectedChain.id,
- [address, wagmiChain?.id, selectedChain.id, hasNetwork]
+ () => hasNetwork && !!isConnected && wagmiChain?.id !== selectedChain.id,
+ [isConnected, wagmiChain?.id, selectedChain.id, hasNetwork]
)
// Handle route change start events
@@ -98,25 +102,21 @@ export const ChainMenu: React.FC = ({
const handleRouteChangeComplete = () => {
if (selectedChain) {
- switchChain(
- { chainId: selectedChain.id },
- {
- onError(error) {
- console.error(`Failed to automatically switch chain:`, error)
- },
- }
- )
+ setIsChainInitialized(true)
}
- setIsChainInitialized(true)
}
+ // Only run on mount when hydrated (initial setup)
handleRouteChangeComplete()
+
router.events.on('routeChangeComplete', handleRouteChangeComplete)
return () => {
router.events.off('routeChangeComplete', handleRouteChangeComplete)
}
- }, [router, hasHydrated, selectedChain, switchChain])
+ // Only depend on hasHydrated and router, not selectedChain or switchChain
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [router, hasHydrated])
if (!hasHydrated || !isChainInitialized) {
return null
diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/NavMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/NavMenu.tsx
index bed0a1c2d..d12d7f46e 100644
--- a/apps/web/src/layouts/DefaultLayout/NavMenu/NavMenu.tsx
+++ b/apps/web/src/layouts/DefaultLayout/NavMenu/NavMenu.tsx
@@ -1,6 +1,6 @@
+import { useAuthStore } from '@buildeross/stores'
import { Box, Flex } from '@buildeross/zord'
import React from 'react'
-import { useAccount } from 'wagmi'
import { ConnectButton } from '../ConnectButton'
import { ChainMenu } from './ChainMenu'
@@ -15,7 +15,7 @@ interface NavMenuProps {
export const NavMenu = ({ hideChainMenu = false }: NavMenuProps) => {
const [activeDropdown, setActiveDropdown] = React.useState()
- const { address } = useAccount()
+ const { address } = useAuthStore()
const onOpenMenu = React.useCallback(
(open: boolean, menuType: MenuType) => {
diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx
index e37307952..25d70288c 100644
--- a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx
+++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx
@@ -1,9 +1,9 @@
import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains'
import { MOBILE_PROFILE_MENU_LAYER, NAV_BUTTON_LAYER } from '@buildeross/constants/layers'
+import { SAFE_HOME_URL } from '@buildeross/constants/safe'
import { useEnsData } from '@buildeross/hooks/useEnsData'
import { useUserDaos } from '@buildeross/hooks/useUserDaos'
-import { useWalletDisconnect } from '@buildeross/hooks/useWalletDisconnect'
-import { useChainStore } from '@buildeross/stores'
+import { useAuthStore, useChainStore } from '@buildeross/stores'
import { CHAIN_ID } from '@buildeross/types'
import { Avatar, DaoAvatar } from '@buildeross/ui/Avatar'
import { CopyButton } from '@buildeross/ui/CopyButton'
@@ -16,6 +16,7 @@ import NextImage from 'next/image'
import Link from 'next/link'
import React from 'react'
import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure'
+import { useAppDisconnect } from 'src/hooks/useAppDisconnect'
import { useDaoListPreferences } from 'src/hooks/useDaoListPreferences'
import { profileStatBadge } from 'src/styles/profile.css'
import { formatUnits } from 'viem'
@@ -130,9 +131,13 @@ export const ProfileMenu: React.FC = ({
onOpenMenu,
onSetActiveDropdown,
}) => {
- const { address } = useAccount()
+ const { address, isAuthenticated, isSafeMode, eoaAddress, safeChainId } = useAuthStore()
const { chain: selectedChain } = useChainStore()
+ const { connector } = useAccount()
+
const { displayName, ensAvatar } = useEnsData(address || '')
+ const eoaEnsData = useEnsData(eoaAddress || '')
+
const { data: balance } = useBalance({
address: address!,
chainId: selectedChain.id,
@@ -189,13 +194,18 @@ export const ProfileMenu: React.FC = ({
const [isMobile, setIsMobile] = React.useState(false)
React.useEffect(() => {
+ // Check if window is defined (SSR safety)
+ if (typeof window === 'undefined') return
+
const handleResize = () => {
setIsMobile(window.innerWidth <= 768)
}
- if (!!window) {
- window.addEventListener('resize', handleResize)
- handleResize()
- }
+
+ // Set initial value
+ handleResize()
+
+ // Add listener
+ window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
@@ -214,7 +224,7 @@ export const ProfileMenu: React.FC = ({
}
}, [isMobile, activeDropdown])
- const onDisconnect = useWalletDisconnect()
+ const onDisconnect = useAppDisconnect()
const renderConnectedUserCommon = ({ isStatic = false }: { isStatic?: boolean }) => (
<>
@@ -239,7 +249,7 @@ export const ProfileMenu: React.FC = ({
aria-label="Open profile"
>
-
+
{displayName}
{userBalance}
@@ -250,6 +260,73 @@ export const ProfileMenu: React.FC = ({
+ {/* Safe Details Card - Only shown in Safe mode */}
+ {isSafeMode &&
+ connector &&
+ 'safeInfo' in connector &&
+ safeChainId &&
+ SAFE_HOME_URL[safeChainId] && (
+
+
+ {/* Safe Header - Clickable link to Safe app */}
+
+
+
+
+ Safe Multisig
+
+
+
+
+
+ {/* Threshold Info */}
+ {(connector as any).safeInfo?.threshold && (
+
+ {(connector as any).safeInfo.threshold} of{' '}
+ {(connector as any).safeInfo.owners.length} signatures required
+
+ )}
+
+ {/* Owner Info */}
+ {eoaAddress && (
+
+
+
+ Owner:
+
+
+
+ {eoaEnsData.displayName}
+
+
+
+
+ )}
+
+
+ )}
+