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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 6 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- 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
12 changes: 11 additions & 1 deletion docs/transaction-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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={<NetworkGuardNotice guard={networkGuard} />} // optional
/>
```

Expand All @@ -55,14 +57,22 @@ 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
<TransactionReviewModal
details={details}
onConfirm={handleConfirm}
onCancel={onClose}
footer={COMPLIANCE_DISCLAIMER} // optional
canConfirm={!networkGuard.isBlocked} // optional
notice={<NetworkGuardNotice guard={networkGuard} />} // optional
footer={COMPLIANCE_DISCLAIMER} // optional
/>
```

Expand Down
251 changes: 251 additions & 0 deletions docs/wallet-network-guard.md
Original file line number Diff line number Diff line change
@@ -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.
<button onClick={handleReview} disabled={networkGuard.isBlocked}>Review</button>

// 2. Explain why. Renders nothing when the decision is `allow`.
<NetworkGuardNotice guard={networkGuard} />

// 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
<TransactionReview
details={details}
onConfirm={handleConfirm}
onCancel={handleCancel}
canConfirm={!networkGuard.isBlocked}
notice={<NetworkGuardNotice guard={networkGuard} />}
/>
```

`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.
14 changes: 13 additions & 1 deletion src/components/transactions/TransactionReview.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { AlertTriangle, ShieldCheck } from 'lucide-react';
import { TRANSACTION_ACTION_LABELS, type TransactionDetails } from './types';

Expand All @@ -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;
}

/**
Expand All @@ -18,6 +26,8 @@ export default function TransactionReview({
onConfirm,
onCancel,
isSubmitting = false,
canConfirm = true,
notice,
}: TransactionReviewProps) {
const riskNotes = details.riskNotes ?? [];

Expand Down Expand Up @@ -82,6 +92,8 @@ export default function TransactionReview({
</span>
</p>

{notice}

<div className="flex flex-col-reverse gap-3 sm:flex-row sm:space-x-3 sm:gap-0">
<button
type="button"
Expand All @@ -94,7 +106,7 @@ export default function TransactionReview({
<button
type="button"
onClick={onConfirm}
disabled={isSubmitting}
disabled={isSubmitting || !canConfirm}
className="flex-1 rounded bg-aegis-brand py-2 font-medium text-white transition hover:bg-blue-600 disabled:opacity-50"
>
{isSubmitting ? 'Confirming...' : 'Confirm & Sign'}
Expand Down
8 changes: 8 additions & 0 deletions src/components/transactions/TransactionReviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export interface TransactionReviewModalProps {
onCancel: () => void;
/** Disables review actions while a confirmation is in flight. */
isSubmitting?: boolean;
/** Blocks the signature while leaving Cancel available. */
canConfirm?: boolean;
/** Rendered above the review buttons — used for the wallet network guard. */
notice?: ReactNode;
/**
* Optional footer beneath the review body — typically a compliance
* disclaimer for whitelist / compliance-update actions.
Expand All @@ -31,6 +35,8 @@ export default function TransactionReviewModal({
onConfirm,
onCancel,
isSubmitting = false,
canConfirm = true,
notice,
footer,
ariaLabel,
}: TransactionReviewModalProps) {
Expand Down Expand Up @@ -63,6 +69,8 @@ export default function TransactionReviewModal({
onConfirm={onConfirm}
onCancel={onCancel}
isSubmitting={isSubmitting}
canConfirm={canConfirm}
notice={notice}
/>
{footer ? <div className="mt-4 text-center text-xs text-slate-400">{footer}</div> : null}
</div>
Expand Down
Loading
Loading