Skip to content

Commit ba65e2c

Browse files
committed
feat(wallet): block UI on network mismatch with a11y overlay and settings link
1 parent 9500a85 commit ba65e2c

11 files changed

Lines changed: 437 additions & 36 deletions

File tree

frontend/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { Metadata, Viewport } from "next";
22
import { headers } from "next/headers";
33

44
import "./globals.css";
5+
import { AnalyticsScript } from "@/components/analytics-script";
56
import { ThemeProvider } from "@/components/theme-provider";
67
import { Toaster } from "@/components/ui/toaster";
8+
import { WalletProvider, NetworkMismatchModal } from "@/features/wallet";
79
import { inter, ibmPlexMono } from "@/lib/fonts";
810

911
export const viewport: Viewport = {

frontend/src/components/settings/settings-panel.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
77
import { Input } from '@/components/ui/input'
88
import { useSettings } from '@/hooks/use-settings'
99
import { useWallet } from '@/hooks/use-wallet'
10+
import { SETTINGS_NETWORK_SECTION_ID } from '@/features/wallet/constants'
1011
import { getContracts } from '@/lib/network-manifest'
1112
import { validateRpcUrl, PUBLIC_RPC, STATUS_PAGES, type AppSettings } from '@/lib/settings-store'
1213
import type { Network } from '@/lib/network-manifest'
@@ -15,14 +16,15 @@ const NETWORKS: Network[] = ['testnet', 'public']
1516

1617
export function SettingsPanel() {
1718
const { settings, update, reset } = useSettings()
18-
const { disconnect } = useWallet()
19+
const { disconnect, setAppNetwork } = useWallet()
1920
const [advancedOpen, setAdvancedOpen] = useState(false)
2021
const [rpcInput, setRpcInput] = useState(settings.customRpcUrl ?? '')
2122
const [rpcError, setRpcError] = useState<string | null>(null)
2223
const [isPending, startTransition] = useTransition()
2324

2425
function handleNetworkChange(network: Network) {
2526
update('network', network)
27+
setAppNetwork(network === 'public' ? 'mainnet' : 'testnet')
2628
// Pull fresh contract manifests for the new network
2729
startTransition(() => {
2830
getContracts(network) // re-reads registry; triggers any dependent queries
@@ -58,7 +60,7 @@ export function SettingsPanel() {
5860
return (
5961
<div className="space-y-6 max-w-xl">
6062
{/* Network */}
61-
<Card>
63+
<Card id={SETTINGS_NETWORK_SECTION_ID} tabIndex={-1}>
6264
<CardHeader>
6365
<CardTitle>Network</CardTitle>
6466
<CardDescription>

frontend/src/components/ui/dialog.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,15 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
3131

3232
const DialogContent = React.forwardRef<
3333
React.ElementRef<typeof DialogPrimitive.Content>,
34-
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
35-
>(({ className, children, ...props }, ref) => (
34+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
35+
/** When true, omit the default close (X) control — for blocking flows */
36+
hideCloseButton?: boolean
37+
/** Merged onto the overlay (e.g. higher z-index for full-screen blocking) */
38+
overlayClassName?: string
39+
}
40+
>(({ className, children, hideCloseButton = false, overlayClassName, ...props }, ref) => (
3641
<DialogPortal>
37-
<DialogOverlay />
42+
<DialogOverlay className={overlayClassName} />
3843
<DialogPrimitive.Content
3944
ref={ref}
4045
className={cn(
@@ -55,10 +60,12 @@ const DialogContent = React.forwardRef<
5560
{...props}
5661
>
5762
{children}
58-
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground min-h-[44px] min-w-[44px] flex items-center justify-center">
59-
<X className="h-4 w-4" />
60-
<span className="sr-only">Close</span>
61-
</DialogPrimitive.Close>
63+
{!hideCloseButton && (
64+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground min-h-[44px] min-w-[44px] flex items-center justify-center">
65+
<X className="h-4 w-4" />
66+
<span className="sr-only">Close</span>
67+
</DialogPrimitive.Close>
68+
)}
6269
</DialogPrimitive.Content>
6370
</DialogPortal>
6471
))

frontend/src/config/networkManifest.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,21 @@ export function getManifest(network: AppNetwork): NetworkManifest {
5252
return NETWORK_MANIFESTS[network]
5353
}
5454

55-
/** Maps a wallet-reported network passphrase to our AppNetwork key. */
55+
/**
56+
* Maps the exact network passphrase string returned by the wallet (via
57+
* `StellarWalletsKit.getNetwork()`) to our `AppNetwork` key.
58+
*
59+
* Comparison is **case-sensitive, full-string equality** against each manifest’s
60+
* `networkPassphrase` (Stellar’s canonical strings, e.g. Test SDF Network).
61+
*
62+
* **Custom RPC:** Changing the Soroban/Horizon RPC in Settings does not change
63+
* the wallet passphrase; mismatch detection is driven only by what the wallet
64+
* reports for the active network.
65+
*
66+
* **Unknown / private networks:** If the passphrase is not one of our three
67+
* manifests, this returns `null`. Callers should treat that as “wallet network
68+
* not supported by this app” (see `computeNetworkMismatch` in wallet utils).
69+
*/
5670
export function passphraseToAppNetwork(passphrase: string): AppNetwork | null {
5771
for (const [key, manifest] of Object.entries(NETWORK_MANIFESTS)) {
5872
if (manifest.networkPassphrase === passphrase) return key as AppNetwork
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Wallet vs app network mismatch
2+
3+
## What we compare
4+
5+
1. **App network**`WalletContext` state (`AppNetwork`: `testnet` | `mainnet` | `futurenet`), persisted under `niffyinsure:appNetwork` and passed into `StellarWalletsKit.init` / `setNetwork`.
6+
2. **Wallet network** — The `network` string from `StellarWalletsKit.getNetwork()` (Stellar network passphrase), mapped with `passphraseToAppNetwork()` in `src/config/networkManifest.ts`.
7+
8+
We re-fetch the wallet passphrase on connect, reconnect, app network changes, successful `signTransaction`, and on `KitEventType.STATE_UPDATED` when an address is present so extension network switches are picked up quickly.
9+
10+
## When the blocking overlay shows
11+
12+
The full-screen overlay is shown only on routes that are **not** read-only (see `isReadOnlyWalletInteractionPath` in `utils/networkMismatch.ts`): excluded paths include `/`, `/docs` (and subpaths), `/privacy`, and `/support`, with optional locale prefixes (`/en`, `/es`).
13+
14+
`computeNetworkMismatch` returns true when:
15+
16+
- The wallet is **connected**, and
17+
- The last `getNetwork()` call **succeeded** (`resolution.status === 'ok'`), and
18+
- Either the passphrase did **not** match any manifest (`mappedNetwork === null`), or `mappedNetwork !== appNetwork`.
19+
20+
## Edge cases
21+
22+
| Situation | Behavior |
23+
|-----------|----------|
24+
| Custom Soroban RPC in Settings | Does not affect passphrase comparison; only the wallet’s reported passphrase matters. |
25+
| Wallet on a private / unknown chain | Passphrase does not match manifests → `mappedNetwork === null` → mismatch overlay (when on a wallet-required route). |
26+
| `getNetwork()` throws or fails | Resolution stays non-`ok` → overlay hidden (we do not block on unknown read failure). |
27+
| Settings “Network” (testnet / mainnet) | `SettingsPanel` calls `setAppNetwork` so the kit and app passphrase stay aligned with the same control users reach via “Switch Network”. The Network card uses `SETTINGS_NETWORK_SECTION_ID` (`settings-network`) so `/settings#settings-network` scrolls/focuses the right block. |
28+
29+
## Accessibility
30+
31+
The overlay uses a Radix `Dialog` with `role="alertdialog"`, `aria-modal`, labelled title/description, and an `aria-live="assertive"` region so assistive tech announces the mismatch when the dialog opens.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import { render, screen, waitFor } from '@testing-library/react'
5+
import React from 'react'
6+
7+
import {
8+
NetworkMismatchOverlayView,
9+
buildMismatchCopy,
10+
} from '@/features/wallet/components/NetworkMismatchModal'
11+
12+
jest.mock('@/i18n/navigation', () => ({
13+
Link: ({
14+
href,
15+
children,
16+
...rest
17+
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
18+
<a href={href} {...rest}>
19+
{children}
20+
</a>
21+
),
22+
}))
23+
24+
describe('buildMismatchCopy', () => {
25+
it('describes standard mismatch', () => {
26+
const copy = buildMismatchCopy('mainnet', { status: 'ok', mappedNetwork: 'testnet' })
27+
expect(copy.announcement).toContain('Mainnet')
28+
expect(copy.announcement).toContain('Testnet')
29+
})
30+
31+
it('describes unsupported wallet passphrase', () => {
32+
const copy = buildMismatchCopy('mainnet', { status: 'ok', mappedNetwork: null })
33+
expect(copy.title).toContain('Unsupported')
34+
expect(copy.announcement).toMatch(/unsupported|expect/i)
35+
})
36+
})
37+
38+
describe('NetworkMismatchOverlayView', () => {
39+
const base = {
40+
appNetwork: 'mainnet' as const,
41+
resolution: { status: 'ok' as const, mappedNetwork: 'testnet' as const },
42+
switchNetworkHref: '/settings#settings-network',
43+
}
44+
45+
it('does not render alert dialog when closed', () => {
46+
const { container } = render(<NetworkMismatchOverlayView open={false} {...base} />)
47+
expect(container.querySelector('[role="alertdialog"]')).toBeNull()
48+
})
49+
50+
it('renders blocking overlay and Switch Network when open', () => {
51+
render(<NetworkMismatchOverlayView open {...base} />)
52+
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
53+
expect(screen.getByRole('link', { name: /switch network/i })).toHaveAttribute(
54+
'href',
55+
'/settings#settings-network',
56+
)
57+
})
58+
59+
it('dismisses alert dialog when open flips to false', async () => {
60+
const { rerender } = render(<NetworkMismatchOverlayView open {...base} />)
61+
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
62+
63+
rerender(<NetworkMismatchOverlayView open={false} {...base} />)
64+
await waitFor(() => {
65+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
66+
})
67+
})
68+
69+
it('exposes assertive live region for screen reader announcement', () => {
70+
render(<NetworkMismatchOverlayView open {...base} />)
71+
const live = document.querySelector('[aria-live="assertive"]')
72+
expect(live).toBeTruthy()
73+
expect(live?.textContent?.length).toBeGreaterThan(10)
74+
})
75+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { passphraseToAppNetwork } from '@/config/networkManifest'
2+
import {
3+
computeNetworkMismatch,
4+
isReadOnlyWalletInteractionPath,
5+
stripLocalePrefix,
6+
} from '@/features/wallet/utils/networkMismatch'
7+
8+
describe('stripLocalePrefix', () => {
9+
it('removes optional locale segment', () => {
10+
expect(stripLocalePrefix('/es/quote')).toBe('/quote')
11+
expect(stripLocalePrefix('/en/docs/voting')).toBe('/docs/voting')
12+
expect(stripLocalePrefix('/es')).toBe('/')
13+
})
14+
15+
it('leaves default-locale paths unchanged', () => {
16+
expect(stripLocalePrefix('/quote')).toBe('/quote')
17+
expect(stripLocalePrefix('/')).toBe('/')
18+
})
19+
})
20+
21+
describe('isReadOnlyWalletInteractionPath', () => {
22+
it('treats landing, docs, privacy, support as read-only', () => {
23+
expect(isReadOnlyWalletInteractionPath('/')).toBe(true)
24+
expect(isReadOnlyWalletInteractionPath('/docs')).toBe(true)
25+
expect(isReadOnlyWalletInteractionPath('/docs/voting')).toBe(true)
26+
expect(isReadOnlyWalletInteractionPath('/privacy')).toBe(true)
27+
expect(isReadOnlyWalletInteractionPath('/support')).toBe(true)
28+
})
29+
30+
it('treats wallet-interaction routes as not read-only', () => {
31+
expect(isReadOnlyWalletInteractionPath('/quote')).toBe(false)
32+
expect(isReadOnlyWalletInteractionPath('/settings')).toBe(false)
33+
expect(isReadOnlyWalletInteractionPath('/dashboard')).toBe(false)
34+
})
35+
36+
it('respects locale prefix', () => {
37+
expect(isReadOnlyWalletInteractionPath('/es')).toBe(true)
38+
expect(isReadOnlyWalletInteractionPath('/es/docs/contracts')).toBe(true)
39+
expect(isReadOnlyWalletInteractionPath('/es/quote')).toBe(false)
40+
})
41+
})
42+
43+
describe('computeNetworkMismatch', () => {
44+
it('is false when disconnected or resolution not ok', () => {
45+
expect(
46+
computeNetworkMismatch('disconnected', 'mainnet', { status: 'ok', mappedNetwork: 'testnet' }),
47+
).toBe(false)
48+
expect(
49+
computeNetworkMismatch('connected', 'mainnet', { status: 'idle' }),
50+
).toBe(false)
51+
expect(
52+
computeNetworkMismatch('connected', 'mainnet', { status: 'error' }),
53+
).toBe(false)
54+
})
55+
56+
it('is true when mapped network differs from app network', () => {
57+
expect(
58+
computeNetworkMismatch('connected', 'mainnet', { status: 'ok', mappedNetwork: 'testnet' }),
59+
).toBe(true)
60+
})
61+
62+
it('is false when networks match', () => {
63+
expect(
64+
computeNetworkMismatch('connected', 'testnet', { status: 'ok', mappedNetwork: 'testnet' }),
65+
).toBe(false)
66+
})
67+
68+
it('is true when passphrase is unknown (unmapped)', () => {
69+
expect(
70+
computeNetworkMismatch('connected', 'mainnet', { status: 'ok', mappedNetwork: null }),
71+
).toBe(true)
72+
})
73+
})
74+
75+
describe('passphraseToAppNetwork', () => {
76+
it('maps known Stellar passphrases', () => {
77+
expect(passphraseToAppNetwork('Test SDF Network ; September 2015')).toBe('testnet')
78+
expect(
79+
passphraseToAppNetwork('Public Global Stellar Network ; September 2015'),
80+
).toBe('mainnet')
81+
})
82+
83+
it('returns null for unknown passphrases', () => {
84+
expect(passphraseToAppNetwork('Private Custom Network')).toBeNull()
85+
})
86+
})

0 commit comments

Comments
 (0)