0 && fundedPct < 100 ? '2px solid var(--ink)' : undefined,
+ boxSizing: 'border-box',
}}
/>
({
+ useRouter: () => ({ replace: mockReplace, push: vi.fn() }),
+ usePathname: () => mockPathname,
+ useSearchParams: () => new URLSearchParams(mockSearch),
+}))
+
+let walletState = { connected: false, restoring: false }
+
+vi.mock('./WalletProvider', () => ({
+ useWallet: () => walletState,
+}))
+
+import { RequireWallet } from './RequireWallet'
+
+const Protected = () =>
balances
+
+describe('RequireWallet', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockPathname = '/portfolio'
+ mockSearch = ''
+ walletState = { connected: false, restoring: false }
+ })
+
+ it('renders the gated content for a connected wallet', () => {
+ walletState = { connected: true, restoring: false }
+ render(
+
+
+ ,
+ )
+ expect(screen.getByTestId('protected')).toBeInTheDocument()
+ expect(mockReplace).not.toHaveBeenCalled()
+ })
+
+ it('redirects an unconnected visitor to Connect', () => {
+ render(
+
+
+ ,
+ )
+ expect(mockReplace).toHaveBeenCalledWith('/connect?next=%2Fportfolio')
+ })
+
+ it('never renders the gated content to an unconnected visitor', () => {
+ render(
+
+
+ ,
+ )
+ // The redirect is asynchronous from the DOM's point of view, so the guard
+ // must withhold the content itself rather than rely on the navigation
+ // winning the race. Otherwise balances flash before the redirect lands.
+ expect(screen.queryByTestId('protected')).not.toBeInTheDocument()
+ })
+
+ /**
+ * The regression that motivated the `restoring` flag: the wallet session is
+ * read back from localStorage inside an effect, so a genuinely connected user
+ * looks disconnected on the first render. Redirecting then would eject them
+ * from the money routes on every page refresh.
+ */
+ it('waits for the session to rehydrate before deciding', () => {
+ walletState = { connected: false, restoring: true }
+ render(
+
+
+ ,
+ )
+ expect(mockReplace).not.toHaveBeenCalled()
+ expect(screen.queryByTestId('protected')).not.toBeInTheDocument()
+ })
+
+ it('preserves the visitor’s intent, query string included', () => {
+ mockPathname = '/withdraw'
+ mockSearch = 'amount=250'
+ render(
+
+
+ ,
+ )
+ expect(mockReplace).toHaveBeenCalledWith(
+ `/connect?next=${encodeURIComponent('/withdraw?amount=250')}`,
+ )
+ })
+
+ it('honours a custom redirect target', () => {
+ render(
+
+
+ ,
+ )
+ expect(mockReplace).toHaveBeenCalledWith('/?next=%2Fportfolio')
+ })
+
+ it('shows the fallback while the decision is pending', () => {
+ walletState = { connected: false, restoring: true }
+ render(
+
…}>
+
+ ,
+ )
+ expect(screen.getByTestId('pending')).toBeInTheDocument()
+ })
+})
diff --git a/src/wallet/RequireWallet.tsx b/src/wallet/RequireWallet.tsx
new file mode 100644
index 00000000..f699944f
--- /dev/null
+++ b/src/wallet/RequireWallet.tsx
@@ -0,0 +1,65 @@
+'use client'
+
+import { useEffect, type ReactNode } from 'react'
+import { usePathname, useRouter, useSearchParams } from 'next/navigation'
+import { useWallet } from './WalletProvider'
+
+/**
+ * RequireWallet — the gate in front of the money routes.
+ *
+ * `/portfolio`, `/deposit` and `/withdraw` all assume a connection: they show
+ * balances, or move value. Rendering them for an unconnected visitor produces a
+ * screen that is either empty or misleading, and any action on it fails. Only
+ * `/connect` redirected; these did not.
+ *
+ * Two details make this correct rather than merely present:
+ *
+ * 1. **It waits for rehydration.** The wallet session is restored from
+ * localStorage inside an effect, so on the first render `connected` is
+ * false even for a user who is connected. Redirecting on that first render
+ * would throw a connected user out to Connect on every page refresh. The
+ * guard holds while `restoring` is true and only then decides.
+ *
+ * 2. **It preserves intent.** Where the visitor was going is carried to
+ * Connect as `?next=`, so finishing the connection returns them to the page
+ * they asked for instead of the default landing spot.
+ *
+ * `router.replace` — not `push` — so Back does not bounce the user between the
+ * gated route and Connect.
+ */
+export interface RequireWalletProps {
+ children: ReactNode
+ /** Where to send an unconnected visitor. */
+ redirectTo?: string
+ /** Rendered while the session rehydrates or the redirect is in flight. */
+ fallback?: ReactNode
+}
+
+export function RequireWallet({
+ children,
+ redirectTo = '/connect',
+ fallback = null,
+}: RequireWalletProps) {
+ const { connected, restoring } = useWallet()
+ const router = useRouter()
+ const pathname = usePathname()
+ const searchParams = useSearchParams()
+
+ // The full path the visitor asked for, query string included, so intent
+ // survives the detour through Connect.
+ const query = searchParams.toString()
+ const intent = query ? `${pathname}?${query}` : pathname
+
+ const shouldRedirect = !restoring && !connected
+
+ useEffect(() => {
+ if (!shouldRedirect) return
+ router.replace(`${redirectTo}?next=${encodeURIComponent(intent)}`)
+ }, [shouldRedirect, router, redirectTo, intent])
+
+ // Hold the gated content while we do not yet know, and while the redirect is
+ // in flight, so it never flashes to someone who is not entitled to it.
+ if (restoring || !connected) return <>{fallback}>
+
+ return <>{children}>
+}
diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx
index b2b4eb86..22afb553 100644
--- a/src/wallet/WalletProvider.tsx
+++ b/src/wallet/WalletProvider.tsx
@@ -15,6 +15,7 @@ interface WalletContextValue {
connected: boolean
connecting: boolean
isDemo: boolean
+ restoring: boolean
connectionError: string | null
retryCount: number
connect: () => Promise
@@ -48,6 +49,7 @@ export function WalletProvider({ children }: { children: ReactNode }) {
const [address, setAddress] = useState(null)
const [connecting, setConnecting] = useState(false)
const [isDemo, setIsDemo] = useState(false)
+ const [restoring, setRestoring] = useState(true)
const [connectionError, setConnectionError] = useState(null)
const [retryCount, setRetryCount] = useState(0)
@@ -77,9 +79,13 @@ export function WalletProvider({ children }: { children: ReactNode }) {
} catch {
/* ignore */
}
- if (!saved) return
+ if (!saved) {
+ setRestoring(false)
+ return
+ }
setAddress(saved)
setIsDemo(savedWallet === 'demo')
+ setRestoring(false)
if (savedWallet && savedWallet !== 'demo') {
void (async () => {
@@ -191,6 +197,7 @@ export function WalletProvider({ children }: { children: ReactNode }) {
connected: address !== null,
connecting,
isDemo,
+ restoring,
connectionError,
retryCount,
connect,