Skip to content

fix(wallet): mount network-mismatch banner app-wide and gate every write on one shared guard - #1585

Open
sommydregent wants to merge 1 commit into
Disciplr-Org:mainfrom
sommydregent:feat/network-mismatch-write-guard-1510
Open

sommydregent wants to merge 1 commit into
Disciplr-Org:mainfrom
sommydregent:feat/network-mismatch-write-guard-1510

Conversation

@sommydregent

Copy link
Copy Markdown

Closes #1510

What and why

Disciplr ships a complete NetworkMismatchBanner (component, telemetry and tests) that was never rendered anywhere outside its test, and the wallet network checks for write actions were ad hoc: CreateVault had an inline check, VaultDetail was covered by evalVaultActionAuth, and the verifier pages (ValidationDetail, PendingValidations) checked nothing before approve/reject. Acting on mainnet while the app is pinned to testnet (or the reverse) is the classic way to lose funds or produce confusing failures in a Stellar dApp — and a guard that exists on one page but not another only gives a false sense of safety.

This PR mounts the banner app-wide and enforces one shared write guard on every write surface.

Changes

App-wide banner (src/components/Layout.tsx)

  • Renders <NetworkMismatchBanner /> directly after <TrustlineBanner />, before <main>: an app-wide role="alert" region, so with the wallet on PUBLIC and the app on TESTNET the warning is visible on every route and announced to screen readers (role="alert"). No banner shows when networks match, and dismissing still hides it until the mismatch key (address, network) changes — that behaviour already lived in the component and is covered by its existing tests.

Shared guard (src/hooks/useWriteGuard.ts, new)

  • useWriteGuard() returns { canWrite, reason } derived from the wallet context: session connected, address present and checksum-valid (isValidStellarAddress), and network matching APP_EXPECTED_NETWORK.
  • Refusal reasons are human-readable and reused as both aria-describedby content and error messages.
  • Considerate to the wallet lifecycle, per the issue's edge cases:
    • While restoring/connecting the network is often still null: unknown is treated as "not yet known", not a mismatch (no banner flash), but writes remain disabled until the state is known.
    • Demo mode is TESTNET, so it passes the guard whenever the app expects TESTNET.
    • No wallet context at all → fail closed.
  • The guard is exported as a pure evaluateWriteGuard(snapshot, expectedNetwork) plus the hook, so the state matrix is testable without mounting Freighter.

Write surfaces wired to the guard

  • CreateVault.tsx — replaces the ad-hoc inline isNetworkMismatch check in handleConfirm with the shared guard, and the review screen's Confirm Vault button is disabled with the reason as its accessible description (CreateVaultReview.tsx gained confirmDisabled / confirmDisabledReason props wired via aria-describedby + screen-reader text).
  • VaultDetail.tsx — action buttons stay gated by evalVaultActionAuth (which already explains network mismatches via disabled + reason text), and handleActionConfirm now re-checks the guard at submit time, so a wallet that flips networks after the modal opens can never reach submitVaultAction.
  • ValidationDetail.tsx — Approve / Reject buttons are disabled while the guard refuses, with the reason exposed as aria-describedby screen-reader text; handleOpenModal and executeAction both re-check, so calling them directly is a no-op (error surfaced, no store call).
  • PendingValidations.tsx — batch Approve / Reject Selected buttons are disabled with aria-describedby reason; openBatch and handleConfirm re-check so confirm-after-flip is a no-op and never calls batchApprove / batchReject.

Environment contract (src/vite-env.d.ts)

  • Declares VITE_DISCIPLR_NETWORK in ImportMetaEnv — networkMismatch.ts already reads it, but it was undocumented, contradicting the file's "declare everything" contract.

Implementation notes / decisions

  • The guard accepts a partial wallet snapshot and falls back to isConnecting + address presence when status is absent, so legacy store shapes and test fixtures keep working; status stays authoritative when present.
  • Out of scope items from the issue were respected: no programmatic network switching (Freighter doesn't support it), no changes to wallet state detection.

Testing

New tests (all passing):

  • src/hooks/__tests__/useWriteGuard.test.tsx — the full state matrix: no context, disconnected, restoring (null network ≠ mismatch), connecting, invalid address, network not yet known, PUBLIC-on-TESTNET mismatch, unknown network string ("FUTURENET"), OK state, demo mode, and a state-change/rerender case.
  • src/components/__tests__/Layout.networkBanner.test.tsx — the banner renders inside Layout with a mocked mismatched wallet as a role="alert" region positioned before <main>, and stays hidden for matching/disconnected wallets.
  • One RTL test per write surface, each asserting the disabled state and that the service is never called when the write is forced:
    • CreateVault.networkGuard.test.tsx — fires the real form submit, review opens, Confirm Vault disabled + aria-describedby reason, createVault never called.
    • VaultDetail.networkGuard.test.tsx — opens the action modal in a good state, then flips the wallet to PUBLIC and confirms: submitVaultAction never called, mismatch error shown.
    • ValidationDetail.networkGuard.test.tsx — buttons disabled with reason; opening-and-forcing confirm after a network flip never reaches the decision store.
    • PendingValidations.networkGuard.test.tsx — batch buttons disabled with reason; confirm-after-flip never reaches batchApprove/batchReject; the matched-network path still works end-to-end.

Existing suites updated only where their wallet mocks now need to satisfy the guard (ValidationDetail.test.tsx, verifierFlow.integration.test.tsx gained a good-state WalletContext mock; the four CreateVault.* suites expose useOptionalWallet; Layout.a11y.test.tsx stubs the banner like its other chrome stubs).

Verification: fullvitest run suite green (162 files, 2078 tests), tsc --noEmit diffed against upstream/main produces no new errors (only pre-existing ones with shifted line numbers), and eslint on all touched files reports no new issues.

Screenshots

⚠️ Not included — this environment has no browser available, so I couldn't capture the before/after desktop (1440px) and mobile (390px) screenshots the issue asks for. What visual reviewers should see:

  • Before: wallet on PUBLIC with the app on TESTNET — no warning anywhere; Create confirm was only checked inline on one page; verifier queue approved/rejected with no network check at all.
  • After: the same state shows the danger banner above <main> on every route, and Create confirm / Validate / Cancel / Approve / Reject / batch actions are disabled with a screen-reader explanation; when the network matches, nothing visible changes from before.

…ite on one shared guard (Disciplr-Org#1510)

- Mount the existing NetworkMismatchBanner in Layout (role="alert"
  region before <main>) so a wrong-network wallet is announced on every
  route.
- Add src/hooks/useWriteGuard.ts: one shared guard returning
  { canWrite, reason } that refuses submits while the wallet is
  disconnected / restoring, the address is missing or invalid, the
  network is still unknown, or the wallet network mismatches
  APP_EXPECTED_NETWORK. Unknown is treated as "not yet known", not as a
  mismatch, but writes stay disabled. Demo mode (TESTNET) passes when
  the app expects TESTNET.
- Wire the guard into every write surface: CreateVault confirm
  (replaces the ad-hoc inline network check), VaultDetail action
  confirms (re-check at submit time; buttons stay gated by
  evalVaultActionAuth), ValidationDetail approve/reject and
  PendingValidations batch approve/reject (buttons disabled with the
  reason as an aria-describedby description, and handlers are no-ops
  when called directly with a bad wallet).
- Declare VITE_DISCIPLR_NETWORK in ImportMetaEnv.

Tests: useWriteGuard state matrix, Layout banner mount, and one RTL
test per write surface asserting the disabled state and that the
service is never called when the write is forced (including a wallet
that flips networks after the modal opens). Existing suites updated
where their wallet mocks now need to satisfy the guard.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[wallet] Mount the network-mismatch banner app-wide and block every write while the wallet is on the wrong network

1 participant