Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Nav } from '@/components/nav'
import { SwRegistrar } from '@/components/sw-registrar'
import { BackendHealthCheck } from '@/components/backend-health-check'
import { SyncStatusBanner } from '@/components/ui/sync-status-banner'
import { UnsupportedChainBanner } from '@/components/wallet/unsupported-chain-banner'

export const metadata: Metadata = {
title: {
Expand Down Expand Up @@ -33,6 +34,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{/* Offline/Degraded status banner */}
<SyncStatusBanner className="mb-4 w-full" />
<Nav />
<UnsupportedChainBanner />
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
</RootProviders>
</body>
Expand Down
114 changes: 114 additions & 0 deletions components/wallet/unsupported-chain-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client'

/**
* components/wallet/unsupported-chain-banner.tsx
*
* Persistent, dismissible banner shown when the connected wallet is on a
* chain that is not in the configured supported set. Provides a "Switch"
* button that calls wagmi's `switchChainAsync` and falls back to manual
* instructions when programmatic switching fails.
*/

import { useState, useCallback } from 'react'
import { useUnsupportedChain } from '@/lib/wallet/useUnsupportedChain'
import { Button } from '@/components/ui/button'

export function UnsupportedChainBanner() {
const {
isUnsupported,
currentChainLabel,
targetChainLabel,
targetChainId,
supportedChainNames,
switchToSupportedChain,
manualInstruction,
} = useUnsupportedChain()

const [switchState, setSwitchState] = useState<
'idle' | 'switching' | 'failed' | 'manual'
>('idle')

const handleSwitch = useCallback(async () => {
setSwitchState('switching')
try {
await switchToSupportedChain()
// On success the wallet will emit a `chainChanged` event, causing
// `isUnsupported` to become `false` on the next render — the banner
// will automatically disappear.
setSwitchState('idle')
} catch {
// Wallet refused or doesn't support EIP-3085
setSwitchState('failed')
}
}, [switchToSupportedChain])

const resetSwitch = useCallback(() => {
setSwitchState('idle')
}, [])

const isRetrying =
switchState === 'switching'

if (!isUnsupported) {
// Reset internal state when the user manually switches away
if (switchState !== 'idle') setSwitchState('idle')
return null
}

return (
<div
role="alert"
aria-live="assertive"
className="border-b border-yellow-300 bg-yellow-50 px-4 py-3 dark:border-yellow-700/40 dark:bg-yellow-900/20"
>
<div className="mx-auto flex max-w-6xl flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-yellow-900 dark:text-yellow-100">
Unsupported network detected
</p>
<p className="text-xs text-yellow-800 dark:text-yellow-200">
Your wallet is connected to{' '}
<strong>{currentChainLabel}</strong>, but this application only
supports{' '}
{supportedChainNames.length === 1
? supportedChainNames[0]
: supportedChainNames
.slice(0, -1)
.join(', ') +
' or ' +
supportedChainNames.slice(-1)}
.
{switchState === 'failed' && (
<span className="mt-1 block">{manualInstruction}</span>
)}
</p>
</div>

<div className="flex shrink-0 items-center gap-2">
{switchState === 'failed' ? (
<Button
size="sm"
variant="outline"
onClick={resetSwitch}
className="border-yellow-400 text-yellow-900 hover:bg-yellow-100 dark:border-yellow-600 dark:text-yellow-100 dark:hover:bg-yellow-800/30"
>
Dismiss
</Button>
) : (
<Button
size="sm"
onClick={handleSwitch}
disabled={isRetrying}
aria-busy={isRetrying}
className="bg-yellow-600 text-white hover:bg-yellow-700 focus-visible:ring-yellow-500 dark:bg-yellow-500 dark:text-yellow-950 dark:hover:bg-yellow-400"
>
{isRetrying
? `Switching to ${targetChainLabel}…`
: `Switch to ${targetChainLabel}`}
</Button>
)}
</div>
</div>
</div>
)
}
71 changes: 71 additions & 0 deletions lib/wallet/chains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* lib/wallet/chains.ts
*
* Pure utilities for wallet chain validation — no wagmi/viem/React deps,
* so these can be unit-tested without a test renderer or type declarations.
*/

import { walletConfig } from './config'

// ── Lookup helpers ───────────────────────────────────────────────────────────

/**
* Return the chain IDs of every supported chain.
*/
export function getSupportedChainIds(): number[] {
return walletConfig.chains.map((c) => c.id)
}

/**
* Return the display names of every supported chain (e.g. "Ethereum", "Base").
*/
export function getSupportedChainNames(): string[] {
return walletConfig.chains.map((c) => c.name)
}

// ── Detection ────────────────────────────────────────────────────────────────

/**
* Returns true when `chainId` is one of the IDs listed in the runtime
* wallet configuration.
*
* Pure — can be called from any context (React hook, effect, test).
*/
export function isChainSupported(chainId: number): boolean {
return getSupportedChainIds().includes(chainId)
}

/**
* Human-readable label for a chain ID (e.g. "Ethereum Mainnet" for 1).
* Falls back to "Chain {id}" when the ID isn't recognised.
*/
export function chainLabel(chainId: number): string {
// First check the runtime config
const match = walletConfig.chains.find((c) => c.id === chainId)
if (match) return match.name
// Known non-supported chain IDs — provide best-effort names
const known: Record<number, string> = {
56: 'BNB Smart Chain',
100: 'Gnosis',
137: 'Polygon',
250: 'Fantom',
42161: 'Arbitrum One',
43114: 'Avalanche C-Chain',
10: 'Optimism',
324: 'zkSync Era',
534352: 'Scroll',
}
return known[chainId] ?? `Chain ${chainId}`
}

// ── Manual-switch instructions ───────────────────────────────────────────────

/**
* Human-readable action text shown when the wallet does not support
* programmatic chain switching (e.g. an injected browser wallet via
* older EIP-3085). The user is directed to open their wallet UI and
* switch manually.
*/
export function manualSwitchInstruction(targetChainName: string): string {
return `Please open your browser wallet extension and switch the connected network to ${targetChainName} manually.`
}
91 changes: 91 additions & 0 deletions lib/wallet/useUnsupportedChain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* lib/wallet/useUnsupportedChain.ts
*
* Detects when the wallet is connected to a chain that is not in the
* configured supported set and exposes a `switchChain` function (via
* wagmi's useSwitchChain) so callers can prompt the user to switch.
*
* When the wallet doesn't support EIP-3085 (wallet_switchEthereumChain),
* useSwitchChain throws; the caller should fall back to the manual
* instructions returned by `manualSwitchInstruction()` in `chains.ts`.
*/

'use client'

import { useCallback } from 'react'
import { useAccount, useChainId, useSwitchChain } from 'wagmi'
import { isChainSupported, chainLabel, manualSwitchInstruction } from './chains'
import { walletConfig } from './config'

export interface UnsupportedChainState {
/** True when the wallet is connected and `chainId` is NOT in the supported set. */
isUnsupported: boolean
/** The current wallet chain ID (always present when connected). */
chainId: number | undefined
/** Human-readable name of the currently connected (unsupported) chain. */
currentChainLabel: string
/** The *first* supported chain — used as the default target for switching. */
targetChainId: number
/** Human-readable name of the default target chain. */
targetChainLabel: string
/** All supported chain IDs (for multi‑choice UIs). */
supportedChainIds: number[]
/** All supported chain names (for multi‑choice UIs). */
supportedChainNames: string[]
/**
* Attempt a programmatic chain switch to the first supported chain.
* Returns a promise that resolves on success or rejects on failure
* (wallet refusal, missing EIP‑3085 support, etc.).
*/
switchToSupportedChain: () => Promise<void>
/**
* Human-readable instruction for manual switching, shown when
* `switchToSupportedChain()` fails or is unavailable.
*/
manualInstruction: string
}

/**
* Hook that monitors the wallet's active chain and returns state
* describing whether it is unsupported, along with actions to switch.
*
* When the wallet is not connected, `isUnsupported` is always `false`.
*/
export function useUnsupportedChain(): UnsupportedChainState {
const { isConnected } = useAccount()
const chainId = useChainId()

// Wagmi v2 switchChain — throws when the wallet doesn't support EIP-3085
const { switchChainAsync } = useSwitchChain()

const supportedChains = walletConfig.chains

const targetChainId = supportedChains[0]!.id
const targetChainLabel = chainLabel(targetChainId)

const isUnsupported = isConnected && !isChainSupported(chainId)

const currentChainLabel = chainId != null ? chainLabel(chainId) : ''

const supportedChainIds = supportedChains.map((c) => c.id)

const supportedChainNames = supportedChains.map((c) => c.name)

const switchToSupportedChain = useCallback(async () => {
await switchChainAsync({ chainId: targetChainId })
}, [switchChainAsync, targetChainId])

const manualInstruction = manualSwitchInstruction(targetChainLabel)

return {
isUnsupported,
chainId,
currentChainLabel,
targetChainId,
targetChainLabel,
supportedChainIds,
supportedChainNames,
switchToSupportedChain,
manualInstruction,
}
}
1 change: 1 addition & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"../lib/rate-limit.ts",
"../lib/api/mock.ts",
"../lib/wallet/address.ts",
"../lib/wallet/chains.ts",
"../lib/wallet/config.ts",
"../lib/wallet/connectors.ts",
"../lib/wallet/siwe-session.ts",
Expand Down
38 changes: 38 additions & 0 deletions test/unsupported-chain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, test } from 'node:test'
import * as assert from 'node:assert/strict'
import './setup-env'
import { chainLabel, manualSwitchInstruction } from '../lib/wallet/chains'

// We test the pure utility functions from chains.ts (no wagmi/React deps).
// The React hook (useUnsupportedChain) and banner component are tested via
// integration/e2e tests — see playwright.config.ts.
//
// chainLabel() is pure — it falls back to a hard-coded known-chain lookup
// table for non-supported IDs and "Chain {id}" for truly unknown ones.
// walletConfig.chains is resolved at import time from env vars which are
// set to mock-safe defaults by setup-env.ts.

describe('chainLabel()', () => {
test('returns a non-empty string for a supported chain ID (1 = mainnet)', () => {
const label = chainLabel(1)
assert.equal(typeof label, 'string')
assert.ok(label.length > 0)
})

test('returns "Polygon" for chain ID 137', () => {
assert.equal(chainLabel(137), 'Polygon')
})

test('returns "Chain {id}" for a completely unknown chain', () => {
assert.equal(chainLabel(999), 'Chain 999')
})
})

describe('manualSwitchInstruction()', () => {
test('returns a sentence naming the target chain', () => {
const instruction = manualSwitchInstruction('Ethereum')
assert.match(instruction, /Ethereum/)
assert.match(instruction, /switch/)
assert.match(instruction, /manually/)
})
})