From 694d533ddddcf9b5dc1215df4eb25c0bfe97f76f Mon Sep 17 00:00:00 2001 From: panditdhamdhere Date: Thu, 30 Jul 2026 09:34:17 +0530 Subject: [PATCH] Implement Aegis dashboard wallet network guard Adds a per-action wallet network guard that blocks signing actions and warns on dashboard-local actions when Freighter is on a different network than the dashboard targets. Freighter emits no network-change event and the wallet store only captured the network at connect time, so a mid-session switch went undetected. A polling watcher mounted at the app shell keeps the stored network current, which also fixes the stale reading behind the existing page-level environment screen. Closes #180 --- README.md | 1 + docs/README.md | 1 + docs/architecture.md | 7 +- docs/transaction-components.md | 12 +- docs/wallet-network-guard.md | 251 ++++++++++++++++++ .../transactions/TransactionReview.tsx | 14 +- .../transactions/TransactionReviewModal.tsx | 8 + src/features/admin/components/AdminPanel.tsx | 10 +- .../components/ComplianceUpdateModal.tsx | 6 + .../components/AssetCreationWizard.tsx | 11 + .../components/WhitelistActionModal.test.tsx | 47 +++- .../components/WhitelistActionModal.tsx | 6 + .../investor/components/TransferModal.tsx | 21 +- .../minting/components/MintWorkflow.tsx | 19 +- .../components/NetworkGuardNotice.test.tsx | 69 +++++ .../wallet/components/NetworkGuardNotice.tsx | 98 +++++++ src/features/wallet/fixtures.ts | 107 ++++++++ src/features/wallet/index.ts | 9 + src/features/wallet/networkGuard.test.ts | 110 ++++++++ src/features/wallet/networkGuard.ts | 169 ++++++++++++ src/features/wallet/types.ts | 90 +++++++ src/features/wallet/useNetworkGuard.test.tsx | 141 ++++++++++ src/features/wallet/useNetworkGuard.ts | 80 ++++++ src/hooks/useWallet.ts | 40 ++- src/lib/environment.test.ts | 28 ++ src/lib/environment.ts | 24 ++ src/pages/_app.tsx | 13 + 27 files changed, 1382 insertions(+), 10 deletions(-) create mode 100644 docs/wallet-network-guard.md create mode 100644 src/features/wallet/components/NetworkGuardNotice.test.tsx create mode 100644 src/features/wallet/components/NetworkGuardNotice.tsx create mode 100644 src/features/wallet/fixtures.ts create mode 100644 src/features/wallet/index.ts create mode 100644 src/features/wallet/networkGuard.test.ts create mode 100644 src/features/wallet/networkGuard.ts create mode 100644 src/features/wallet/types.ts create mode 100644 src/features/wallet/useNetworkGuard.test.tsx create mode 100644 src/features/wallet/useNetworkGuard.ts diff --git a/README.md b/README.md index 28b333b..4403fee 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Key resources for contributors: - [Transaction Review Modal](docs/transaction-review-modal.md) — Pre-signature review modal, operation summary mapper, and risk notes (Issue #177) - [Admin Action Receipts](docs/admin-action-receipts.md) — Privileged action status, target, hash, explorer link, and next-step guidance (Issue #179) - [Environment Mismatch Blocking Screen](docs/environment-mismatch-blocking.md) — Full-page blocking screen when the wallet network does not match the dashboard target network +- [Wallet Network Guard](docs/wallet-network-guard.md) — Per-action network guard: live Freighter network detection, block-versus-warn policy, and network assumptions (Issue #180) - [Investor Onboarding Eligibility](docs/investor-onboarding-eligibility.md) — Investor onboarding eligibility page, evaluation precedence, and SDK mapping - [Performance Budget Review](docs/performance-budget-review.md) — Typed budget threshold evaluation, edge cases, and reviewer checklist diff --git a/docs/README.md b/docs/README.md index 84dc499..fd9e316 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,7 @@ Reference material for contributors implementing new functionality. | [form-idempotency.md](form-idempotency.md) | Content-derived idempotency key, double-submit guard, TTL | | [sdk-error-recovery.md](sdk-error-recovery.md) | Error categories, retry policy, compliance wording | | [environment-mismatch-blocking.md](environment-mismatch-blocking.md) | Full-page blocking screen for wallet network mismatch, data model, edge cases, reviewer checklist | +| [wallet-network-guard.md](wallet-network-guard.md) | Per-action wallet network guard, live network detection, block-versus-warn policy, network assumptions (Issue #180) | | [bulk-compliance-review.md](bulk-compliance-review.md) | Bulk compliance table engine, KYC import CSV template | | [kyc-bulk-import-design.md](kyc-bulk-import-design.md) | KYC bulk import design, field mapping, validation rules | | [kyc-bulk-import-template.csv](kyc-bulk-import-template.csv) | Example CSV for the bulk import flow | diff --git a/docs/architecture.md b/docs/architecture.md index 8749f03..072fc24 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,4 +34,9 @@ The UI is strictly separated into pages and domain-specific features: - `src/features/admin/receipts/` encapsulates: - admin operation receipt types for whitelist, mint, asset registration, and role changes - SDK/local-outcome mapping onto shared transaction status and explorer helpers - - next-action guidance and fixtures for all receipt states \ No newline at end of file + - next-action guidance and fixtures for all receipt states +- `src/features/wallet/` encapsulates the per-action wallet network guard: + - `evaluateNetworkGuard` and the block-versus-warn policy per guarded action + - `useWalletNetworkWatcher` (live Freighter network detection) and `useNetworkGuard` + - `NetworkGuardNotice` plus fixtures for every mismatch state + - reuses the passphrase helpers in `src/lib/environment.ts` shared with the app-shell check \ No newline at end of file diff --git a/docs/transaction-components.md b/docs/transaction-components.md index bf292ac..6ed1db6 100644 --- a/docs/transaction-components.md +++ b/docs/transaction-components.md @@ -43,6 +43,8 @@ The components are layout-agnostic: `TransferModal` renders them inside a modal, onConfirm={handleConfirm} onCancel={() => setState('idle')} isSubmitting={false} // optional — disables both buttons + canConfirm={true} // optional — disables Confirm only + notice={} // optional /> ``` @@ -55,6 +57,12 @@ the wallet-signature reminder so every sensitive action surfaces the same pre-sign safety information. Prefer `buildTransferSummary`, `buildMintSummary`, `buildWhitelistSummary`, or `buildComplianceUpdateSummary` over hand-built rows. +`canConfirm` and `notice` exist for conditions the user can still walk away +from — a wrong wallet network, for example. Unlike `isSubmitting`, `canConfirm` +leaves Cancel enabled, and `notice` renders above the buttons so the reason is +visible next to the disabled action. Both are forwarded by +`TransactionReviewModal`. See [wallet-network-guard.md](wallet-network-guard.md). + ### `TransactionReviewModal` ```tsx @@ -62,7 +70,9 @@ pre-sign safety information. Prefer `buildTransferSummary`, `buildMintSummary`, details={details} onConfirm={handleConfirm} onCancel={onClose} - footer={COMPLIANCE_DISCLAIMER} // optional + canConfirm={!networkGuard.isBlocked} // optional + notice={} // optional + footer={COMPLIANCE_DISCLAIMER} // optional /> ``` diff --git a/docs/wallet-network-guard.md b/docs/wallet-network-guard.md new file mode 100644 index 0000000..706060d --- /dev/null +++ b/docs/wallet-network-guard.md @@ -0,0 +1,251 @@ +# Wallet Network Guard + +Issue: [#180](https://github.com/Axionvera/aegis-dashboard/issues/180) + +Freighter lets a user switch networks at any moment, including while a review +modal is open. A signature produced on the wrong network either fails or lands +on a ledger the operator did not intend. This module compares the wallet's +network against the dashboard's target network **per action**, immediately +before the action can be started or signed. + +> **Important:** This is a protocol-level network check. It is not legal, +> regulatory, or financial advice, and it makes no determination about the +> user's wallet, jurisdiction, or eligibility. + +--- + +## How this differs from the environment mismatch screen + +[Environment Mismatch Blocking](environment-mismatch-blocking.md) (#36) blocks +whole **pages** at the app shell. The network guard blocks individual +**actions**. They compare the same two values and share the passphrase +resolution helpers in `src/lib/environment.ts`, so they can never disagree. + +| | Environment mismatch screen (#36) | Wallet network guard (#180) | +|---|---|---| +| Scope | Whole page | One action | +| Rendered by | `EnvironmentGuard` in `_app.tsx` | The flow that owns the action | +| Outcome | Replaces page content | Disables the confirm button, shows an inline notice | +| Policy | Always blocks | Blocks or warns, per action | + +The guard is not redundant with the page-level screen. The screen can be +bypassed in mock mode, only re-evaluates against stored state, and says nothing +about which action is at risk. The guard also stays correct when the user +switches networks *after* opening a review modal. + +--- + +## Live network detection + +`useWallet` captures the network once at connect time. Freighter emits no +"network changed" event, so without help that value goes stale the moment the +user switches, and every check downstream would compare against the old network. + +Two pieces fix that: + +| Export | File | Purpose | +|---|---|---| +| `toStoredNetwork(walletNetwork)` | `src/lib/environment.ts` | Collapses Freighter's object/string payload into a stable string (short name preferred). | +| `refreshNetwork()` | `src/hooks/useWallet.ts` | Re-reads `getNetwork()` without prompting the user and updates the store only when the resolved passphrase changed. | +| `useWalletNetworkWatcher(pollMs?)` | `src/features/wallet/useNetworkGuard.ts` | Calls `refreshNetwork` on mount, every `pollMs` (default 5000), on window focus, and when the tab becomes visible. | + +The watcher is mounted once in `src/pages/_app.tsx`. Polling pauses while the +tab is hidden and runs immediately on refocus, so a user returning from the +Freighter popup sees the new network without a manual refresh. Because the +watcher writes to the shared store, the app-shell environment screen benefits +from the same live detection. + +Freighter returns a fresh object on every `getNetwork()` call, so the refresh +compares by resolved passphrase rather than by reference. Without that, every +poll would rewrite the store and re-render the app even when the user had not +switched. Connect, auto-reconnect, and refresh all run the payload through +`toStoredNetwork` first — the store holds a string, never the raw Freighter +object, so callers that call `.trim()` (review rows, explorer links) stay safe. + +A failed read leaves the previous value in place rather than clearing it. A +transient Freighter failure must not wipe a known-good network and falsely +unlock a signing action that was correctly blocked a moment earlier. + +--- + +## Policy: block versus warn + +Each guarded action declares a sensitivity in +`GUARDED_ACTIONS` (`src/features/wallet/networkGuard.ts`): + +| Sensitivity | Meaning | On mismatch | +|---|---|---| +| `signing` | Asks the wallet for a signature and writes to chain. | **Block** | +| `local` | Recorded in the dashboard only; never reaches the wallet. | **Warn** | + +| Action | Sensitivity | Where it is enforced | +|---|---|---| +| `transfer` | `signing` | `src/features/investor/components/TransferModal.tsx` | +| `mint` | `signing` | `src/features/minting/components/MintWorkflow.tsx`, legacy path in `src/features/admin/components/AdminPanel.tsx` | +| `whitelist-add` | `signing` | `src/features/compliance/components/WhitelistActionModal.tsx` | +| `whitelist-remove` | `signing` | `src/features/compliance/components/WhitelistActionModal.tsx` | +| `compliance-update` | `local` | `src/features/admin/components/ComplianceUpdateModal.tsx` | +| `asset-registration` | `local` | `src/features/asset-creation/components/AssetCreationWizard.tsx` | + +### Decision matrix + +| Status | Meaning | `signing` | `local` | +|---|---|---|---| +| `match` | Wallet network equals the target. | allow | allow | +| `mismatch` | Wallet is on a different network. | block | warn | +| `unknown` | Wallet connected, network unreadable. | block | allow | +| `disconnected` | No wallet connected. | block | allow | +| `mock` | Mock mode is active. | allow | allow | + +Signing actions **fail closed**: an unresolved network is treated exactly like a +wrong one, because a signature sent to an unverified network cannot be recalled. +Local actions never block — nothing reaches the wallet, so stopping the operator +would be a false obstacle — but a mismatch is still surfaced, since the record +is attributed to a network label. + +Mock mode is exempt. The synthetic `LOCAL_MOCK` network would never match a real +passphrase, and `MockModeBanner` already warns developers. `assertMockModeSafe()` +prevents mock mode from being enabled outside development. + +--- + +## Network assumptions + +- **The dashboard targets exactly one network**, read from + `NEXT_PUBLIC_NETWORK_PASSPHRASE`. When it is unset, `getTargetNetwork()` + falls back to the Stellar testnet passphrase. There is no multi-network mode. +- **The passphrase is the identity of a network.** Short names (`TESTNET`, + `PUBLIC`) are resolved to their full passphrase by `resolvePassphrase`, which + also accepts both the object and the bare-string shapes Freighter has + returned across versions. +- **An unrecognised passphrase is a valid network**, not an error. It is + displayed verbatim and compared by exact string equality, so custom and + standalone networks work without a code change. +- **The dashboard cannot switch the wallet's network.** Freighter owns that + selector, so the guard can only explain the mismatch and tell the user what to + change. +- **The guard is a UI safety net, not an authorisation boundary.** It reduces + wrong-network mistakes; it does not replace on-chain checks, and a determined + caller can always reach the contract directly. + +--- + +## Usage + +```tsx +import { NetworkGuardNotice, useNetworkGuard } from '@/features/wallet'; + +const networkGuard = useNetworkGuard('transfer'); + +// 1. Stop the action from starting. + + +// 2. Explain why. Renders nothing when the decision is `allow`. + + +// 3. Re-check inside the handler, so a mid-flow switch cannot slip through. +const handleConfirm = async () => { + if (networkGuard.isBlocked) return; + // … +}; +``` + +Flows built on the shared review components pass the guard straight through: + +```tsx +} +/> +``` + +`canConfirm` disables only the confirm button, leaving Cancel available — a +blocked user must always be able to walk away. `TransactionReviewModal` accepts +and forwards both props. + +Every guarded surface checks in three places: the entry button is disabled, the +handler re-checks before doing work, and the review screen re-checks before the +signature. The last one matters most, because the user can switch networks +between opening the review screen and pressing Confirm. + +--- + +## User guidance + +Each non-`allow` result carries three strings, so the notice always answers +"what happened", "why", and "what now": + +| Field | Content | +|---|---| +| `title` | What the guard found, e.g. `Wrong wallet network`. | +| `message` | What it means for this action, naming it explicitly, and stating that nothing was submitted when blocked. | +| `guidance` | The single next step, e.g. `Switch Freighter to Stellar Testnet (TESTNET), then reopen this action.` | + +`NetworkGuardNotice` renders both network labels, a **Recheck network** button +that calls `refreshNetwork` for users who do not want to wait for the next poll, +and `NETWORK_GUARD_DISCLAIMER`. Blocked results use red styling and a shield +icon; warnings use amber. The notice carries `role="alert"` and +`aria-live="polite"`. + +--- + +## Edge Cases and Failure States + +| Case | Behaviour | Rationale | +|---|---|---| +| User switches network while a review modal is open | Next poll or refocus updates the store; the confirm button disables and the notice appears. | The signature must be judged against the network in force now, not at connect time. | +| Freighter locked or `getNetwork()` throws | Previous value retained; signing already blocks on `unknown`. | Guessing a network is more dangerous than admitting the read failed. | +| Wallet connected, network unreadable | `unknown` → signing blocked, local allowed. | Fail closed only where a signature is at stake. | +| No wallet connected | `disconnected` → signing blocked. | Nothing can be signed anyway; the copy points at connecting rather than switching. | +| Mock mode active | `mock` → always allowed. | No real network is involved; `MockModeBanner` covers the warning. | +| Custom or standalone passphrase | Compared exactly, displayed verbatim. | Works with any Stellar network without a code change. | +| `NEXT_PUBLIC_NETWORK_PASSPHRASE` unset | Defaults to testnet. | Safe local-development default, matching #36. | +| Tab hidden | Polling pauses, resumes with an immediate read on return. | Avoids waking Freighter for a background tab. | +| Local action on the wrong network | Warns, still submits. | Nothing reaches the wallet, so blocking would be a false obstacle. | +| Guard blocks after a failure already occurred | The reactive path in [SDK Error Recovery](sdk-error-recovery.md) still handles `network_mismatch`. | The guard is preventive; recovery stays as the backstop. | + +--- + +## Tests and Fixtures + +| File | What it covers | +|---|---| +| `src/features/wallet/networkGuard.test.ts` | Every fixture, block-versus-warn per sensitivity, fail-closed on `unknown`, disconnected copy, mock bypass, short network names, custom passphrases, empty copy when allowed. | +| `src/features/wallet/useNetworkGuard.test.tsx` | Guard re-evaluation on store change, polling only while connected, detecting a post-connect switch, refresh on focus, cleanup on unmount, retaining the last network when the read fails. | +| `src/features/wallet/components/NetworkGuardNotice.test.tsx` | Renders nothing when allowed, block versus warn copy, both network labels, disclaimer, recheck button. | +| `src/features/compliance/components/WhitelistActionModal.test.tsx` | A wrong-network signature is refused and Cancel stays usable. | + +`src/features/wallet/fixtures.ts` exports `NETWORK_GUARD_FIXTURES`, covering +every status/decision pair for both sensitivities, including the object and +bare-string network shapes. + +### Reviewer Checklist + +Use alongside the general [Reviewer Checklist](reviewer-checklist.md). + +- [ ] Every new signing action declares a policy in `GUARDED_ACTIONS`. +- [ ] The entry button, the handler, and the review screen all consult the guard. +- [ ] `canConfirm` is used rather than `isSubmitting` to block on network state, + so Cancel stays available. +- [ ] Blocked copy states that nothing was submitted. +- [ ] Guidance names the target network and the concrete next step. +- [ ] The protocol-level disclaimer is present on any custom guard surface. +- [ ] Mock mode is not blocked. +- [ ] Component tests that render a signing flow set a connected wallet on the + target network, otherwise the guard blocks them. + +--- + +## Related + +- [Environment Mismatch Blocking](environment-mismatch-blocking.md) — Page-level + network blocking (#36). +- [SDK Error Recovery](sdk-error-recovery.md) — Reactive handling once a call + has already failed with `network_mismatch`. +- [Transaction Review Modal](transaction-review-modal.md) — The review surface + the guard renders into. +- [Mock Mode](mock-mode.md) — Why mock mode is exempt. +- [Compliance-Safe Wording](compliance-safe-wording.md) — Disclaimer guidance. diff --git a/src/components/transactions/TransactionReview.tsx b/src/components/transactions/TransactionReview.tsx index 8dd77c2..72ca2d4 100644 --- a/src/components/transactions/TransactionReview.tsx +++ b/src/components/transactions/TransactionReview.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { AlertTriangle, ShieldCheck } from 'lucide-react'; import { TRANSACTION_ACTION_LABELS, type TransactionDetails } from './types'; @@ -7,6 +8,13 @@ interface TransactionReviewProps { onCancel: () => void; /** Disables both buttons while the confirmation is being handled. */ isSubmitting?: boolean; + /** + * Blocks the signature without disabling Cancel, for conditions the user can + * still walk away from — a wrong wallet network, for example. + */ + canConfirm?: boolean; + /** Rendered above the buttons, for guards that must be read before signing. */ + notice?: ReactNode; } /** @@ -18,6 +26,8 @@ export default function TransactionReview({ onConfirm, onCancel, isSubmitting = false, + canConfirm = true, + notice, }: TransactionReviewProps) { const riskNotes = details.riskNotes ?? []; @@ -82,6 +92,8 @@ export default function TransactionReview({

+ {notice} +
diff --git a/src/features/admin/components/AdminPanel.tsx b/src/features/admin/components/AdminPanel.tsx index 99f64de..35ab568 100644 --- a/src/features/admin/components/AdminPanel.tsx +++ b/src/features/admin/components/AdminPanel.tsx @@ -16,6 +16,7 @@ import type { TransactionState, } from '@/components/transactions/types'; import MintWorkflow from '@/features/minting/components/MintWorkflow'; +import { NetworkGuardNotice, useNetworkGuard } from '@/features/wallet'; const MINT_AMOUNT = 1000; @@ -32,6 +33,7 @@ function LegacyMintPanel() { const [whitelistMessage, setWhitelistMessage] = useState(null); const cleanAddress = address.trim(); + const networkGuard = useNetworkGuard('mint'); const details = buildMintSummary({ amount: MINT_AMOUNT, @@ -46,6 +48,8 @@ function LegacyMintPanel() { }; const handleConfirmMint = async () => { + if (networkGuard.isBlocked) return; + setState('signing'); try { setResult( @@ -90,6 +94,8 @@ function LegacyMintPanel() { details={details} onConfirm={handleConfirmMint} onCancel={reset} + canConfirm={!networkGuard.isBlocked} + notice={} /> ); } @@ -113,6 +119,8 @@ function LegacyMintPanel() { /> + {networkGuard.decision !== 'allow' && } + {whitelistMessage && (
setState('review')} - disabled={isLoading || !cleanAddress} + disabled={isLoading || !cleanAddress || networkGuard.isBlocked} className="flex-1 bg-aegis-dark hover:bg-slate-800 text-white py-2 rounded font-medium transition disabled:opacity-50" > Mint Asset diff --git a/src/features/admin/components/ComplianceUpdateModal.tsx b/src/features/admin/components/ComplianceUpdateModal.tsx index 4182926..5dd6664 100644 --- a/src/features/admin/components/ComplianceUpdateModal.tsx +++ b/src/features/admin/components/ComplianceUpdateModal.tsx @@ -4,6 +4,7 @@ import TransactionReviewModal from '@/components/transactions/TransactionReviewM import { buildComplianceUpdateSummary } from '@/components/transactions/operationSummary'; import { getExplorerUrl } from '@/components/transactions/explorerLink'; import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview'; +import { NetworkGuardNotice, useNetworkGuard } from '@/features/wallet'; import type { ComplianceSubject, BulkAction } from '@/lib/complianceReview'; import type { TransactionResult } from '@/components/transactions/types'; @@ -42,6 +43,10 @@ export default function ComplianceUpdateModal({ actionLabel, }); + // Warn-only: this update is applied in the dashboard and never reaches the + // wallet, so a mismatch is worth flagging but must not stop the reviewer. + const networkGuard = useNetworkGuard('compliance-update'); + const handleConfirm = () => { const txResult = onConfirm(); setResult(txResult); @@ -54,6 +59,7 @@ export default function ComplianceUpdateModal({ details={details} onConfirm={handleConfirm} onCancel={onClose} + notice={} footer={COMPLIANCE_DISCLAIMER} ariaLabel="Compliance update review" /> diff --git a/src/features/asset-creation/components/AssetCreationWizard.tsx b/src/features/asset-creation/components/AssetCreationWizard.tsx index dcd9041..ead9e2e 100644 --- a/src/features/asset-creation/components/AssetCreationWizard.tsx +++ b/src/features/asset-creation/components/AssetCreationWizard.tsx @@ -14,6 +14,7 @@ import { mapAdminActionReceipt, } from '@/features/admin/receipts'; import { getTargetNetwork, formatNetworkLabel } from '@/lib/environment'; +import { NetworkGuardNotice, useNetworkGuard } from '@/features/wallet'; import type { IssuanceRequest } from '@/fixtures/issuer'; type WizardStep = 'form' | 'review' | 'success'; @@ -67,6 +68,10 @@ export default function AssetCreationWizard({ onCreate, onCancel, }: AssetCreationWizardProps) { + // Warn-only: the request is recorded in the dashboard and never signed, so a + // network mismatch is worth naming but must not stop the issuer. + const networkGuard = useNetworkGuard('asset-registration'); + const [step, setStep] = useState('form'); const [assetName, setAssetName] = useState(''); const [ticker, setTicker] = useState(''); @@ -272,6 +277,12 @@ export default function AssetCreationWizard({ + {networkGuard.decision !== 'allow' && ( +
+ +
+ )} +

Submitting sends this asset for compliance review. This is a protocol-level compliance check only and is not legal or financial advice. diff --git a/src/features/compliance/components/WhitelistActionModal.test.tsx b/src/features/compliance/components/WhitelistActionModal.test.tsx index 95a13f8..c0b6fea 100644 --- a/src/features/compliance/components/WhitelistActionModal.test.tsx +++ b/src/features/compliance/components/WhitelistActionModal.test.tsx @@ -1,11 +1,17 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; import WhitelistActionModal from './WhitelistActionModal'; import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview'; +import { useWallet } from '@/hooks/useWallet'; const ADDRESS = 'GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37'; describe('WhitelistActionModal', () => { + // Whitelist changes are signed, so the network guard blocks them unless a + // wallet is connected on the network the dashboard targets. + beforeEach(() => { + useWallet.setState({ address: ADDRESS, network: 'TESTNET' }); + }); it('shows operation summary, network, target, expected result, and risk notes before signing', () => { render( { expect(screen.getByText(/whitelist update rejected/i)).toBeInTheDocument(); }); }); + + it('blocks signing while the wallet is on another network', () => { + const onSubmit = vi.fn(); + useWallet.setState({ address: ADDRESS, network: 'PUBLIC' }); + + render( + , + ); + + expect(screen.getByText('Wrong wallet network')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /confirm & sign/i })).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: /confirm & sign/i })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('leaves Cancel usable while the network blocks the signature', () => { + const onClose = vi.fn(); + useWallet.setState({ address: ADDRESS, network: 'PUBLIC' }); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalledWith(false); + }); }); diff --git a/src/features/compliance/components/WhitelistActionModal.tsx b/src/features/compliance/components/WhitelistActionModal.tsx index 8e7255c..3771699 100644 --- a/src/features/compliance/components/WhitelistActionModal.tsx +++ b/src/features/compliance/components/WhitelistActionModal.tsx @@ -8,6 +8,7 @@ import { type AdminActionReceipt, } from '@/features/admin/receipts'; import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview'; +import { NetworkGuardNotice, useNetworkGuard } from '@/features/wallet'; import type { TransactionPhase } from '@/components/transactions/types'; export type WhitelistAction = 'add' | 'remove'; @@ -45,8 +46,11 @@ export default function WhitelistActionModal({ const [receipt, setReceipt] = useState(null); const details = buildWhitelistSummary({ action, address, note, network }); + const networkGuard = useNetworkGuard(action === 'add' ? 'whitelist-add' : 'whitelist-remove'); const handleConfirm = async () => { + if (networkGuard.isBlocked) return; + setPhase('signing'); try { const outcome = await onSubmit((nextPhase) => setPhase(nextPhase)); @@ -82,6 +86,8 @@ export default function WhitelistActionModal({ details={details} onConfirm={handleConfirm} onCancel={() => onClose(false)} + canConfirm={!networkGuard.isBlocked} + notice={} footer={COMPLIANCE_DISCLAIMER} /> ); diff --git a/src/features/investor/components/TransferModal.tsx b/src/features/investor/components/TransferModal.tsx index 11672dd..cd12276 100644 --- a/src/features/investor/components/TransferModal.tsx +++ b/src/features/investor/components/TransferModal.tsx @@ -21,6 +21,7 @@ import { type ClassifiedSdkError, type RecoveryPlan, } from '@/features/sdk-recovery'; +import { NetworkGuardNotice, useNetworkGuard } from '@/features/wallet'; import type { PortfolioAsset } from '@/lib/aegis/types'; interface TransferModalProps { @@ -46,6 +47,10 @@ export default function TransferModal({ asset, onClose }: TransferModalProps) { // must not assume the caller already validated eligibility. const isEligible = asset.isDataAvailable && asset.transferEligibility.state === 'eligible'; + // Re-read on every render: the user can switch networks in Freighter while + // this modal is open, including between the review screen and the signature. + const networkGuard = useNetworkGuard('transfer'); + // Pasted Stellar addresses often carry surrounding whitespace, which would // otherwise reach the compliance check and the transaction itself. const cleanRecipient = recipient.trim(); @@ -78,6 +83,10 @@ export default function TransferModal({ asset, onClose }: TransferModalProps) { const handleReview = async () => { setError(''); + // Checked again here rather than relying on the disabled button alone, so + // a network switch mid-form cannot slip a transfer onto the wrong ledger. + if (networkGuard.isBlocked) return; + // Covers missing fields, malformed addresses, self-transfer, non-positive // amounts, balance overflow, and decimal precision beyond what the asset // supports. See src/lib/transferRequest.ts and @@ -108,6 +117,8 @@ export default function TransferModal({ asset, onClose }: TransferModalProps) { }; const handleConfirm = async () => { + if (networkGuard.isBlocked) return; + setFailure(null); setState('signing'); @@ -244,6 +255,8 @@ export default function TransferModal({ asset, onClose }: TransferModalProps) { // The guard already blocks a duplicate submission; disabling the // button stops the user from having to discover that. isSubmitting={submission.isSubmitting} + canConfirm={!networkGuard.isBlocked} + notice={} /> ); } @@ -254,6 +267,12 @@ export default function TransferModal({ asset, onClose }: TransferModalProps) { {error &&

{error}
} + {networkGuard.decision !== 'allow' && ( +
+ +
+ )} +
@@ -288,7 +307,7 @@ export default function TransferModal({ asset, onClose }: TransferModalProps) { + )} + +

{NETWORK_GUARD_DISCLAIMER}

+
+
+
+ ); +} diff --git a/src/features/wallet/fixtures.ts b/src/features/wallet/fixtures.ts new file mode 100644 index 0000000..64d9e17 --- /dev/null +++ b/src/features/wallet/fixtures.ts @@ -0,0 +1,107 @@ +/** + * Wallet network guard fixtures (Issue #180). + * + * Every combination the guard can produce, so tests and any future preview + * gallery exercise the same set. Expectations assume the default target + * network (Stellar testnet) that `getTargetNetwork()` falls back to when + * `NEXT_PUBLIC_NETWORK_PASSPHRASE` is unset. + */ + +import type { GuardedActionId, NetworkGuardDecision, NetworkGuardStatus } from './types'; + +export const TESTNET_PASSPHRASE = 'Test SDF Network ; September 2015'; +export const PUBLIC_PASSPHRASE = 'Public Global Stellar Network ; September 2015'; + +export interface NetworkGuardFixture { + id: string; + label: string; + walletNetwork: unknown; + isWalletConnected: boolean; + isMockMode: boolean; + action: GuardedActionId; + expectedStatus: NetworkGuardStatus; + expectedDecision: NetworkGuardDecision; +} + +export const NETWORK_GUARD_FIXTURES: NetworkGuardFixture[] = [ + { + id: 'signing-match', + label: 'Transfer with wallet on the target network', + walletNetwork: { network: 'TESTNET', networkPassphrase: TESTNET_PASSPHRASE }, + isWalletConnected: true, + isMockMode: false, + action: 'transfer', + expectedStatus: 'match', + expectedDecision: 'allow', + }, + { + id: 'signing-mismatch', + label: 'Transfer with wallet on mainnet while the dashboard targets testnet', + walletNetwork: { network: 'PUBLIC', networkPassphrase: PUBLIC_PASSPHRASE }, + isWalletConnected: true, + isMockMode: false, + action: 'transfer', + expectedStatus: 'mismatch', + expectedDecision: 'block', + }, + { + id: 'signing-mismatch-string', + label: 'Mint with the network reported as a bare string', + walletNetwork: 'PUBLIC', + isWalletConnected: true, + isMockMode: false, + action: 'mint', + expectedStatus: 'mismatch', + expectedDecision: 'block', + }, + { + id: 'signing-unknown', + label: 'Whitelist addition with an unreadable wallet network', + walletNetwork: {}, + isWalletConnected: true, + isMockMode: false, + action: 'whitelist-add', + expectedStatus: 'unknown', + expectedDecision: 'block', + }, + { + id: 'signing-disconnected', + label: 'Whitelist removal with no wallet connected', + walletNetwork: null, + isWalletConnected: false, + isMockMode: false, + action: 'whitelist-remove', + expectedStatus: 'disconnected', + expectedDecision: 'block', + }, + { + id: 'signing-mock', + label: 'Mint in mock mode, where no real network is involved', + walletNetwork: 'LOCAL_MOCK', + isWalletConnected: true, + isMockMode: true, + action: 'mint', + expectedStatus: 'mock', + expectedDecision: 'allow', + }, + { + id: 'local-mismatch', + label: 'Compliance update with the wallet on the wrong network', + walletNetwork: { networkPassphrase: PUBLIC_PASSPHRASE }, + isWalletConnected: true, + isMockMode: false, + action: 'compliance-update', + expectedStatus: 'mismatch', + expectedDecision: 'warn', + }, + { + id: 'local-unknown', + label: 'Asset registration with an unreadable wallet network', + walletNetwork: null, + isWalletConnected: true, + isMockMode: false, + action: 'asset-registration', + expectedStatus: 'unknown', + expectedDecision: 'allow', + }, +]; diff --git a/src/features/wallet/index.ts b/src/features/wallet/index.ts new file mode 100644 index 0000000..adcad1d --- /dev/null +++ b/src/features/wallet/index.ts @@ -0,0 +1,9 @@ +export { default as NetworkGuardNotice } from './components/NetworkGuardNotice'; +export { + evaluateNetworkGuard, + GUARDED_ACTIONS, + NETWORK_GUARD_DISCLAIMER, +} from './networkGuard'; +export { useNetworkGuard, useWalletNetworkWatcher, WALLET_NETWORK_POLL_MS } from './useNetworkGuard'; +export * from './fixtures'; +export * from './types'; diff --git a/src/features/wallet/networkGuard.test.ts b/src/features/wallet/networkGuard.test.ts new file mode 100644 index 0000000..ffb0460 --- /dev/null +++ b/src/features/wallet/networkGuard.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { evaluateNetworkGuard, GUARDED_ACTIONS } from './networkGuard'; +import { NETWORK_GUARD_FIXTURES, PUBLIC_PASSPHRASE, TESTNET_PASSPHRASE } from './fixtures'; +import type { GuardedActionId } from './types'; + +const onPublic = { networkPassphrase: PUBLIC_PASSPHRASE }; +const onTestnet = { networkPassphrase: TESTNET_PASSPHRASE }; + +function guard(action: GuardedActionId, walletNetwork: unknown, isWalletConnected = true) { + return evaluateNetworkGuard({ + action, + walletNetwork, + isWalletConnected, + isMockMode: false, + }); +} + +describe('evaluateNetworkGuard', () => { + it.each(NETWORK_GUARD_FIXTURES)('$label', (fixture) => { + const result = evaluateNetworkGuard({ + walletNetwork: fixture.walletNetwork, + isWalletConnected: fixture.isWalletConnected, + action: fixture.action, + isMockMode: fixture.isMockMode, + }); + + expect(result.status).toBe(fixture.expectedStatus); + expect(result.decision).toBe(fixture.expectedDecision); + expect(result.isBlocked).toBe(fixture.expectedDecision === 'block'); + }); + + it('blocks a signing action on the wrong network and names both networks', () => { + const result = guard('transfer', onPublic); + + expect(result.isBlocked).toBe(true); + expect(result.walletNetwork).toBe('Stellar Mainnet (PUBLIC)'); + expect(result.targetNetwork).toBe('Stellar Testnet (TESTNET)'); + expect(result.message).toContain('was not submitted'); + expect(result.guidance).toContain('Switch Freighter'); + }); + + it('warns but still allows a local action on the wrong network', () => { + const result = guard('compliance-update', onPublic); + + expect(result.decision).toBe('warn'); + expect(result.isBlocked).toBe(false); + expect(result.message).toContain('not submitted to the network'); + }); + + it('fails closed for signing when the wallet network cannot be read', () => { + expect(guard('mint', {}).isBlocked).toBe(true); + expect(guard('mint', null).isBlocked).toBe(true); + expect(guard('mint', undefined).status).toBe('unknown'); + }); + + it('reports a disconnected wallet rather than a mismatch', () => { + const result = guard('transfer', null, false); + + expect(result.status).toBe('disconnected'); + expect(result.walletNetwork).toBeUndefined(); + expect(result.title).toBe('Wallet not connected'); + }); + + it('skips the comparison entirely in mock mode', () => { + const result = evaluateNetworkGuard({ + action: 'transfer', + walletNetwork: 'LOCAL_MOCK', + isWalletConnected: true, + isMockMode: true, + }); + + expect(result.status).toBe('mock'); + expect(result.decision).toBe('allow'); + expect(result.title).toBe(''); + }); + + it('accepts the short network name Freighter sometimes returns', () => { + expect(guard('transfer', 'TESTNET').status).toBe('match'); + expect(guard('transfer', { network: 'TESTNET' }).status).toBe('match'); + }); + + it('produces no copy when the action is allowed', () => { + const result = guard('transfer', onTestnet); + + expect(result.decision).toBe('allow'); + expect(result.title).toBe(''); + expect(result.message).toBe(''); + expect(result.guidance).toBe(''); + }); + + it('names the guarded action in its copy', () => { + expect(guard('whitelist-add', onPublic).message).toContain('whitelist addition'); + expect(guard('whitelist-remove', onPublic).message).toContain('whitelist removal'); + }); + + it('treats an unrecognised passphrase as a mismatch and shows it verbatim', () => { + const result = guard('transfer', { networkPassphrase: 'Standalone Network ; February 2017' }); + + expect(result.status).toBe('mismatch'); + expect(result.walletNetwork).toBe('Standalone Network ; February 2017'); + }); + + it('blocks every signing action and never blocks a local one', () => { + for (const policy of Object.values(GUARDED_ACTIONS)) { + const result = guard(policy.id, onPublic); + + expect(result.decision).toBe(policy.sensitivity === 'signing' ? 'block' : 'warn'); + } + }); +}); diff --git a/src/features/wallet/networkGuard.ts b/src/features/wallet/networkGuard.ts new file mode 100644 index 0000000..cf9e0ab --- /dev/null +++ b/src/features/wallet/networkGuard.ts @@ -0,0 +1,169 @@ +/** + * Pure evaluation logic for the wallet network guard (Issue #180). + * + * Reuses the passphrase resolution and labelling helpers from + * `src/lib/environment.ts` so the guard and the app-shell blocking screen can + * never disagree about which network the wallet is on. + */ + +import { isMockModeEnabled } from '@/config/mockMode'; +import { formatNetworkLabel, getTargetNetwork, resolvePassphrase } from '@/lib/environment'; +import type { + GuardedActionId, + GuardedActionPolicy, + NetworkGuardDecision, + NetworkGuardInput, + NetworkGuardResult, + NetworkGuardStatus, +} from './types'; + +/** + * Disclaimer that must accompany any surface rendering a guard result. Kept + * here so the wording stays identical across every guarded flow. + */ +export const NETWORK_GUARD_DISCLAIMER = + 'This is a protocol-level network check. It does not make a legal, regulatory, or ' + + 'financial determination about your wallet or jurisdiction.'; + +export const GUARDED_ACTIONS: Record = { + transfer: { id: 'transfer', label: 'Transfer', sensitivity: 'signing' }, + mint: { id: 'mint', label: 'Mint', sensitivity: 'signing' }, + 'whitelist-add': { id: 'whitelist-add', label: 'Whitelist addition', sensitivity: 'signing' }, + 'whitelist-remove': { id: 'whitelist-remove', label: 'Whitelist removal', sensitivity: 'signing' }, + 'compliance-update': { + id: 'compliance-update', + label: 'Compliance status update', + sensitivity: 'local', + }, + 'asset-registration': { + id: 'asset-registration', + label: 'Asset registration', + sensitivity: 'local', + }, +}; + +/** + * Decision matrix. + * + * Signing actions fail closed on anything other than a confirmed match: an + * unresolved network is treated the same as a wrong one, because a signature + * sent to an unverified network cannot be recalled. Local actions never block, + * since nothing reaches the wallet, but a mismatch is still surfaced so the + * operator knows which network the record will be attributed to. + */ +const DECISIONS: Record<'signing' | 'local', Record> = { + signing: { + match: 'allow', + mismatch: 'block', + unknown: 'block', + disconnected: 'block', + mock: 'allow', + }, + local: { + match: 'allow', + mismatch: 'warn', + unknown: 'allow', + disconnected: 'allow', + mock: 'allow', + }, +}; + +function resolveStatus( + walletNetwork: unknown, + isWalletConnected: boolean, + isMockMode: boolean, + target: string, +): { status: NetworkGuardStatus; passphrase: string | null } { + if (isMockMode) return { status: 'mock', passphrase: null }; + if (!isWalletConnected) return { status: 'disconnected', passphrase: null }; + + const passphrase = resolvePassphrase(walletNetwork); + if (!passphrase) return { status: 'unknown', passphrase: null }; + + return { status: passphrase === target ? 'match' : 'mismatch', passphrase }; +} + +interface CopyInput { + status: NetworkGuardStatus; + decision: NetworkGuardDecision; + action: GuardedActionPolicy; + targetLabel: string; + walletLabel?: string; +} + +function buildCopy({ status, decision, action, targetLabel, walletLabel }: CopyInput): { + title: string; + message: string; + guidance: string; +} { + const lowerLabel = action.label.toLowerCase(); + + switch (status) { + case 'mismatch': + return decision === 'block' + ? { + title: 'Wrong wallet network', + message: + `Your wallet is connected to ${walletLabel}, but this dashboard targets ` + + `${targetLabel}. This ${lowerLabel} was not submitted.`, + guidance: `Switch Freighter to ${targetLabel}, then reopen this action.`, + } + : { + title: 'Wallet is on a different network', + message: + `Your wallet is connected to ${walletLabel} while this dashboard targets ` + + `${targetLabel}. This ${lowerLabel} is recorded in the dashboard only and is ` + + 'not submitted to the network, so it can still proceed.', + guidance: `Switch Freighter to ${targetLabel} if you expected an on-chain result.`, + }; + + case 'unknown': + return { + title: 'Wallet network not confirmed', + message: + `The dashboard could not read which network your wallet is on, so it cannot ` + + `confirm this ${lowerLabel} would reach ${targetLabel}.`, + guidance: 'Unlock Freighter and reconnect your wallet, then try again.', + }; + + case 'disconnected': + return { + title: 'Wallet not connected', + message: `Connect a wallet on ${targetLabel} to sign this ${lowerLabel}.`, + guidance: `Connect Freighter on ${targetLabel}.`, + }; + + default: + return { title: '', message: '', guidance: '' }; + } +} + +/** + * Evaluate whether a specific sensitive action may proceed on the wallet's + * current network. + */ +export function evaluateNetworkGuard({ + walletNetwork, + isWalletConnected, + action, + isMockMode = isMockModeEnabled(), +}: NetworkGuardInput): NetworkGuardResult { + const policy = GUARDED_ACTIONS[action]; + const target = getTargetNetwork(); + const targetLabel = formatNetworkLabel(target); + + const { status, passphrase } = resolveStatus(walletNetwork, isWalletConnected, isMockMode, target); + const walletLabel = passphrase ? formatNetworkLabel(passphrase) : undefined; + const decision = DECISIONS[policy.sensitivity][status]; + const copy = buildCopy({ status, decision, action: policy, targetLabel, walletLabel }); + + return { + status, + decision, + isBlocked: decision === 'block', + ...copy, + targetNetwork: targetLabel, + walletNetwork: walletLabel, + action: policy, + }; +} diff --git a/src/features/wallet/types.ts b/src/features/wallet/types.ts new file mode 100644 index 0000000..bf5ae9d --- /dev/null +++ b/src/features/wallet/types.ts @@ -0,0 +1,90 @@ +/** + * Wallet network guard types (Issue #180). + * + * The guard sits in front of individual dashboard actions, unlike the + * app-shell environment check in `src/lib/environment.ts` which blocks whole + * pages. Both compare the same two values — the wallet's connected network and + * the dashboard's target network — but the guard decides per action whether a + * mismatch should block a signature or only warn. + * + * IMPORTANT: This is a protocol-level network check. It makes no legal, + * regulatory, or financial determination about the user or their wallet. + */ + +/** Where the wallet stands relative to the dashboard's target network. */ +export type NetworkGuardStatus = + /** Wallet network equals the dashboard target network. */ + | 'match' + /** Wallet is on a different network than the dashboard targets. */ + | 'mismatch' + /** Wallet is connected but its network could not be resolved yet. */ + | 'unknown' + /** No wallet is connected, so no network can be compared. */ + | 'disconnected' + /** Mock mode is active; no real network is involved. */ + | 'mock'; + +/** What the calling flow should do with the action it is guarding. */ +export type NetworkGuardDecision = + /** Safe to proceed. */ + | 'allow' + /** Proceed is permitted, but the user must be told first. */ + | 'warn' + /** The action must not be submitted. */ + | 'block'; + +/** + * How strictly an action reacts to a network problem. + * + * - `signing` — the action asks the wallet for a signature and writes to + * chain. A wrong network means the transaction lands on the wrong ledger or + * fails outright, so these fail closed. + * - `local` — the action is recorded in the dashboard only and never reaches + * the wallet. Network state is still worth surfacing (the record is captured + * against a network label) but it must not stop the operator. + */ +export type NetworkGuardSensitivity = 'signing' | 'local'; + +/** Sensitive dashboard actions that run through the guard. */ +export type GuardedActionId = + | 'transfer' + | 'mint' + | 'whitelist-add' + | 'whitelist-remove' + | 'compliance-update' + | 'asset-registration'; + +export interface GuardedActionPolicy { + id: GuardedActionId; + /** Human-readable action name used in guard copy. */ + label: string; + sensitivity: NetworkGuardSensitivity; +} + +export interface NetworkGuardResult { + status: NetworkGuardStatus; + decision: NetworkGuardDecision; + /** Convenience flag: `decision === 'block'`. */ + isBlocked: boolean; + /** Headline for the guard notice. Empty when the decision is `allow`. */ + title: string; + /** Plain-language explanation of what the guard found. */ + message: string; + /** The single next step the user should take. Empty when `allow`. */ + guidance: string; + /** Human-readable label of the network the dashboard targets. */ + targetNetwork: string; + /** Human-readable label of the wallet's network, when it is known. */ + walletNetwork?: string; + /** The policy that produced this decision. */ + action: GuardedActionPolicy; +} + +export interface NetworkGuardInput { + /** Raw value from Freighter's `getNetwork()` — string or object. */ + walletNetwork: unknown; + isWalletConnected: boolean; + action: GuardedActionId; + /** Defaults to the live `isMockModeEnabled()` reading when omitted. */ + isMockMode?: boolean; +} diff --git a/src/features/wallet/useNetworkGuard.test.tsx b/src/features/wallet/useNetworkGuard.test.tsx new file mode 100644 index 0000000..978f243 --- /dev/null +++ b/src/features/wallet/useNetworkGuard.test.tsx @@ -0,0 +1,141 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWallet } from '@/hooks/useWallet'; +import { useNetworkGuard, useWalletNetworkWatcher } from './useNetworkGuard'; +import { PUBLIC_PASSPHRASE, TESTNET_PASSPHRASE } from './fixtures'; + +const getNetwork = vi.fn(); + +vi.mock('@stellar/freighter-api', () => ({ + isConnected: vi.fn(async () => true), + isAllowed: vi.fn(async () => true), + requestAccess: vi.fn(async () => 'GTEST'), + getPublicKey: vi.fn(async () => 'GTEST'), + getNetwork: (...args: unknown[]) => getNetwork(...args), +})); + +const ADDRESS = 'GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37'; + +beforeEach(() => { + getNetwork.mockReset(); + getNetwork.mockResolvedValue(TESTNET_PASSPHRASE); + useWallet.setState({ address: null, network: null }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useNetworkGuard', () => { + it('reports a block when the connected wallet is on another network', () => { + useWallet.setState({ address: ADDRESS, network: PUBLIC_PASSPHRASE }); + + const { result } = renderHook(() => useNetworkGuard('transfer')); + + expect(result.current.status).toBe('mismatch'); + expect(result.current.isBlocked).toBe(true); + }); + + it('re-evaluates when the stored wallet network changes', () => { + useWallet.setState({ address: ADDRESS, network: PUBLIC_PASSPHRASE }); + + const { result } = renderHook(() => useNetworkGuard('transfer')); + expect(result.current.isBlocked).toBe(true); + + act(() => { + useWallet.setState({ network: TESTNET_PASSPHRASE }); + }); + + expect(result.current.status).toBe('match'); + expect(result.current.isBlocked).toBe(false); + }); +}); + +describe('useWalletNetworkWatcher', () => { + it('does not poll while no wallet is connected', async () => { + renderHook(() => useWalletNetworkWatcher(50)); + + await new Promise((resolve) => setTimeout(resolve, 120)); + expect(getNetwork).not.toHaveBeenCalled(); + }); + + it('reads the network once on mount for a connected wallet', async () => { + useWallet.setState({ address: ADDRESS, network: PUBLIC_PASSPHRASE }); + + renderHook(() => useWalletNetworkWatcher(10_000)); + + await waitFor(() => expect(getNetwork).toHaveBeenCalled()); + }); + + it('picks up a network switch the user made in Freighter after connecting', async () => { + useWallet.setState({ address: ADDRESS, network: 'TESTNET' }); + getNetwork.mockResolvedValue({ + network: 'PUBLIC', + networkPassphrase: PUBLIC_PASSPHRASE, + }); + + renderHook(() => useWalletNetworkWatcher(20)); + + await waitFor(() => expect(useWallet.getState().network).toBe('PUBLIC')); + }); + + it('does not rewrite the store when Freighter returns a fresh object for the same network', async () => { + useWallet.setState({ address: ADDRESS, network: 'TESTNET' }); + getNetwork.mockResolvedValue({ + network: 'TESTNET', + networkPassphrase: TESTNET_PASSPHRASE, + }); + + const { result: store } = renderHook(() => useWallet((s) => s.network)); + const valueBefore = store.current; + + renderHook(() => useWalletNetworkWatcher(20)); + await waitFor(() => expect(getNetwork).toHaveBeenCalled()); + + // Wait through at least one poll cycle. A reference compare would call set() + // every time Freighter returns a new object; passphrase compare must not. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(useWallet.getState().network).toBe(valueBefore); + expect(useWallet.getState().network).toBe('TESTNET'); + }); + + it('refreshes when the tab regains focus, so a switch is caught immediately', async () => { + useWallet.setState({ address: ADDRESS, network: 'TESTNET' }); + + renderHook(() => useWalletNetworkWatcher(10_000)); + await waitFor(() => expect(getNetwork).toHaveBeenCalledTimes(1)); + + getNetwork.mockResolvedValue({ + network: 'PUBLIC', + networkPassphrase: PUBLIC_PASSPHRASE, + }); + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + await waitFor(() => expect(useWallet.getState().network).toBe('PUBLIC')); + }); + + it('stops polling once the hook unmounts', async () => { + useWallet.setState({ address: ADDRESS, network: TESTNET_PASSPHRASE }); + + const { unmount } = renderHook(() => useWalletNetworkWatcher(20)); + await waitFor(() => expect(getNetwork).toHaveBeenCalled()); + + unmount(); + const callsAtUnmount = getNetwork.mock.calls.length; + + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(getNetwork).toHaveBeenCalledTimes(callsAtUnmount); + }); + + it('keeps the last known network when the wallet read fails', async () => { + useWallet.setState({ address: ADDRESS, network: TESTNET_PASSPHRASE }); + getNetwork.mockRejectedValue(new Error('Freighter is locked')); + + renderHook(() => useWalletNetworkWatcher(10_000)); + + await waitFor(() => expect(getNetwork).toHaveBeenCalled()); + expect(useWallet.getState().network).toBe(TESTNET_PASSPHRASE); + }); +}); diff --git a/src/features/wallet/useNetworkGuard.ts b/src/features/wallet/useNetworkGuard.ts new file mode 100644 index 0000000..0539c20 --- /dev/null +++ b/src/features/wallet/useNetworkGuard.ts @@ -0,0 +1,80 @@ +/** + * React bindings for the wallet network guard (Issue #180). + */ + +import { useEffect } from 'react'; +import { isMockModeEnabled } from '@/config/mockMode'; +import { useWallet } from '@/hooks/useWallet'; +import { evaluateNetworkGuard } from './networkGuard'; +import type { GuardedActionId, NetworkGuardResult } from './types'; + +/** How often the watcher re-reads the wallet network while the tab is active. */ +export const WALLET_NETWORK_POLL_MS = 5_000; + +/** + * Keeps `useWallet().network` in step with Freighter. + * + * Freighter has no "network changed" event, and the store only captures the + * network at connect time, so a user who switches networks mid-session would + * otherwise be measured against a stale value. Mount this once at the app + * shell: every guard, plus the app-shell environment check, reads the store. + * + * Polling pauses while the tab is hidden and runs immediately on refocus, so a + * user returning from the Freighter popup sees the new network at once. + */ +export function useWalletNetworkWatcher(pollMs: number = WALLET_NETWORK_POLL_MS): void { + const address = useWallet((s) => s.address); + const refreshNetwork = useWallet((s) => s.refreshNetwork); + + useEffect(() => { + if (!address || isMockModeEnabled()) return; + + let timer: ReturnType | null = null; + + const stop = () => { + if (timer !== null) { + clearInterval(timer); + timer = null; + } + }; + + const start = () => { + if (timer === null) timer = setInterval(refreshNetwork, pollMs); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + stop(); + return; + } + refreshNetwork(); + start(); + }; + + refreshNetwork(); + start(); + window.addEventListener('focus', refreshNetwork); + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + stop(); + window.removeEventListener('focus', refreshNetwork); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [address, refreshNetwork, pollMs]); +} + +/** + * Evaluate the network guard for one sensitive action against live wallet + * state. Re-renders whenever the wallet network or connection changes. + */ +export function useNetworkGuard(action: GuardedActionId): NetworkGuardResult { + const address = useWallet((s) => s.address); + const network = useWallet((s) => s.network); + + return evaluateNetworkGuard({ + walletNetwork: network, + isWalletConnected: address !== null, + action, + }); +} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 01ae36f..769da12 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -1,8 +1,14 @@ import { create } from 'zustand'; import { isConnected, isAllowed, requestAccess, getPublicKey, getNetwork } from '@stellar/freighter-api'; +import { resolvePassphrase, toStoredNetwork } from '@/lib/environment'; interface WalletState { address: string | null; + /** + * Stable network string for the connected wallet — Freighter's short name + * (`TESTNET` / `PUBLIC`) when available, otherwise the passphrase. Never the + * raw Freighter object: callers treat this as a string (e.g. `.trim()`). + */ network: string | null; isConnecting: boolean; /** Set when connection fails; cleared on a successful connect or disconnect. */ @@ -11,9 +17,15 @@ interface WalletState { disconnect: () => void; /** Silently restore a previously-granted Freighter session on page load. */ tryAutoReconnect: () => Promise; + /** + * Re-read the wallet's current network without prompting the user. Freighter + * does not emit an event when the user switches networks, so callers must + * poll this to notice a switch that happened after connect. + */ + refreshNetwork: () => Promise; } -export const useWallet = create((set) => ({ +export const useWallet = create((set, get) => ({ address: null, network: null, isConnecting: false, @@ -36,7 +48,7 @@ export const useWallet = create((set) => ({ set({ address: access, - network: networkDetails, + network: toStoredNetwork(networkDetails), isConnecting: false, connectionError: null, }); @@ -69,12 +81,34 @@ export const useWallet = create((set) => ({ if (!publicKey) return; const networkDetails = await getNetwork(); - set({ address: publicKey, network: networkDetails, connectionError: null }); + set({ + address: publicKey, + network: toStoredNetwork(networkDetails), + connectionError: null, + }); } catch { // Auto-reconnect is best-effort — never surface errors to the user. } }, + refreshNetwork: async () => { + if (!get().address) return; + + try { + const networkDetails = await getNetwork(); + const next = toStoredNetwork(networkDetails); + // Freighter returns a fresh object every call, so compare by resolved + // passphrase rather than by reference — otherwise every poll would + // rewrite the store and re-render the app for no reason. + if (resolvePassphrase(next) !== resolvePassphrase(get().network)) { + set({ network: next }); + } + } catch { + // Leave the previous value in place. A transient Freighter failure must + // not wipe a known-good network and falsely unlock a signing action. + } + }, + disconnect: () => { set({ address: null, network: null, connectionError: null }); }, diff --git a/src/lib/environment.test.ts b/src/lib/environment.test.ts index 322fe88..47a35a8 100644 --- a/src/lib/environment.test.ts +++ b/src/lib/environment.test.ts @@ -4,6 +4,7 @@ import { getTargetNetwork, formatNetworkLabel, resolvePassphrase, + toStoredNetwork, } from './environment'; import { ENVIRONMENT_MISMATCH_FIXTURES } from './__fixtures__/environment'; @@ -116,3 +117,30 @@ describe('resolvePassphrase', () => { expect(resolvePassphrase({})).toBeNull(); }); }); + +describe('toStoredNetwork', () => { + it('prefers Freighter\'s short network name over the passphrase', () => { + expect( + toStoredNetwork({ + network: 'TESTNET', + networkPassphrase: 'Test SDF Network ; September 2015', + }), + ).toBe('TESTNET'); + }); + + it('falls back to the passphrase when the short name is missing', () => { + expect( + toStoredNetwork({ networkPassphrase: 'Test SDF Network ; September 2015' }), + ).toBe('Test SDF Network ; September 2015'); + }); + + it('passes a bare string through unchanged', () => { + expect(toStoredNetwork('PUBLIC')).toBe('PUBLIC'); + }); + + it('returns null for empty or unusable values', () => { + expect(toStoredNetwork(null)).toBeNull(); + expect(toStoredNetwork({})).toBeNull(); + expect(toStoredNetwork('')).toBeNull(); + }); +}); diff --git a/src/lib/environment.ts b/src/lib/environment.ts index 496efad..7ca77b5 100644 --- a/src/lib/environment.ts +++ b/src/lib/environment.ts @@ -70,6 +70,30 @@ export function resolvePassphrase(walletNetwork: unknown): string | null { return null; } +/** + * Collapse Freighter's `getNetwork()` payload into a stable string for the + * wallet store. Prefer the short name (`TESTNET` / `PUBLIC`) so explorer links + * and review rows keep working; fall back to the passphrase when that is all + * Freighter returns. Returns `null` when nothing usable is present. + */ +export function toStoredNetwork(walletNetwork: unknown): string | null { + if (!walletNetwork) return null; + + if (typeof walletNetwork === 'string') { + return walletNetwork || null; + } + + if (typeof walletNetwork === 'object') { + const record = walletNetwork as Record; + if (typeof record.network === 'string' && record.network) return record.network; + if (typeof record.networkPassphrase === 'string' && record.networkPassphrase) { + return record.networkPassphrase; + } + } + + return null; +} + /** * Evaluate whether the connected wallet's network matches the dashboard target. * diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f3d4c05..2ce0dca 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -10,6 +10,7 @@ import { useWallet } from '@/hooks/useWallet'; import { isMockModeEnabled } from '@/config/mockMode'; import { validateDashboardConfig } from '@/config/validate'; import { evaluateEnvironmentMismatch, type EnvironmentMismatchResult } from '@/lib/environment'; +import { useWalletNetworkWatcher } from '@/features/wallet'; function WalletAutoReconnect() { const tryAutoReconnect = useWallet((s) => s.tryAutoReconnect); @@ -23,6 +24,17 @@ function WalletAutoReconnect() { return null; } +/** + * Keeps the stored wallet network current. Freighter fires no event when the + * user switches networks, so without this the store would keep the network + * captured at connect time and both the guard below and the per-action wallet + * network guard would judge against a stale value. + */ +function WalletNetworkWatcher() { + useWalletNetworkWatcher(); + return null; +} + /** * Blocks rendering when the dashboard's own env config (RPC URL, passphrase, * contract ID) is malformed. Runs before EnvironmentGuard: there's no point @@ -70,6 +82,7 @@ export default function App({ Component, pageProps }: AppProps) { return (
+