diff --git a/docs/config-validation.md b/docs/config-validation.md
new file mode 100644
index 0000000..303df1a
--- /dev/null
+++ b/docs/config-validation.md
@@ -0,0 +1,87 @@
+# Configuration Validation Guardrails
+
+Related: Issue #8 — "Add dashboard network and contract configuration guardrails".
+See also [`docs/environment-mismatch-blocking.md`](./environment-mismatch-blocking.md)
+for the separate wallet-vs-target-network check, and
+[`docs/mock-mode.md`](./mock-mode.md) for local development without a live RPC
+endpoint.
+
+## Why this exists
+
+The dashboard is configured entirely through build-time env vars:
+
+| Variable | Purpose |
+| ---------------------------------- | ---------------------------------------------------- |
+| `NEXT_PUBLIC_RPC_URL` | Soroban RPC endpoint the SDK talks to |
+| `NEXT_PUBLIC_NETWORK_PASSPHRASE` | Which Stellar network the dashboard targets |
+| `NEXT_PUBLIC_AEGIS_CONTRACT_ID` | The contract the dashboard reads from and signs to |
+| `NEXT_PUBLIC_MOCK_MODE` | Bypasses all of the above for local frontend dev |
+
+A typo or copy-paste mistake in any of these (a malformed contract ID, a
+placeholder value left over from `.env.example`, an unreachable-looking RPC
+URL) previously only surfaced as an opaque runtime error once someone tried to
+load data or sign a transaction — or worse, silently pointed the dashboard at
+the wrong contract without any error at all.
+
+`src/config/validate.ts` checks the **shape** of these variables at app
+startup and fails loudly, before any page renders, if something's wrong.
+
+## What is and isn't checked
+
+Validation is a shape/format check only:
+
+- **RPC URL** — must parse as a URL. Must be HTTPS unless it's `localhost` /
+ `127.0.0.1` (dev only).
+- **Network passphrase** — must be present. Warns (non-blocking) if it isn't
+ one of the two known Stellar passphrases, since custom standalone networks
+ are a legitimate use case.
+- **Contract ID** — must be present and match the 56-character Soroban
+ contract ID shape (`C` followed by 55 base32 characters).
+
+It does **not** check that the RPC endpoint is reachable, that the contract is
+actually deployed, or that the contract ID belongs to the network you think
+it does — those are runtime concerns, not config concerns, and are surfaced
+separately (e.g. failed SDK calls, or the wallet-network mismatch screen).
+
+## Errors vs. warnings
+
+- **Errors** block the dashboard entirely via `ConfigErrorScreen` — nothing
+ else renders until they're fixed. Missing/malformed RPC URL, missing
+ passphrase, and missing/malformed contract ID are all errors.
+- **Warnings** are informational only and never block. Non-HTTPS remote RPC
+ URLs and unrecognized (custom) passphrases are warnings, since they can be
+ intentional.
+
+## Mock mode bypasses validation entirely
+
+When `NEXT_PUBLIC_MOCK_MODE="true"`, `validateDashboardConfig()` returns
+valid with no issues regardless of what else is set. Mock mode already makes
+no real RPC or contract calls, so there's nothing to validate — see
+`docs/mock-mode.md`.
+
+## Where to see it
+
+- **Startup block**: `ConfigGuard` in `src/pages/_app.tsx` renders
+ `ConfigErrorScreen` when validation fails, before `EnvironmentGuard` (the
+ wallet-network mismatch check) even runs.
+- **Always-on banner**: `EnvironmentBanner` (`src/components/EnvironmentBanner.tsx`)
+ shows the current target network and a redacted contract ID on every page,
+ so you always know which environment you're pointed at — separate from the
+ mock-mode banner and the mismatch-blocking screen.
+- **Diagnostics**: the "Config Validation" card in `DiagnosticsPanel` shows a
+ non-sensitive summary (valid/invalid, issue counts, and which field names
+ have issues — never raw values) that's safe to include in a copied
+ diagnostics report.
+
+## Fixing a validation error
+
+1. Open the `ConfigErrorScreen` message — it names the exact env var and what's
+ wrong with it.
+2. Update the value in your `.env.local` (see `.env.example` for the expected
+ format of each variable).
+3. Restart the dev server (Next.js inlines `NEXT_PUBLIC_*` vars at build time,
+ so changes to `.env.local` require a restart to take effect).
+
+If you don't have real RPC/contract config yet (e.g. you're just working on
+UI), set `NEXT_PUBLIC_MOCK_MODE="true"` instead of trying to fill these in
+with placeholder values.
\ No newline at end of file
diff --git a/src/components/ConfigErrorScreen.test.tsx b/src/components/ConfigErrorScreen.test.tsx
new file mode 100644
index 0000000..634363b
--- /dev/null
+++ b/src/components/ConfigErrorScreen.test.tsx
@@ -0,0 +1,58 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import ConfigErrorScreen from './ConfigErrorScreen';
+import type { ConfigValidationResult } from '@/config/validate';
+
+const resultWithErrorsAndWarnings: ConfigValidationResult = {
+ valid: false,
+ issues: [
+ { field: 'NEXT_PUBLIC_RPC_URL', level: 'error', message: 'RPC URL is not set.' },
+ {
+ field: 'NEXT_PUBLIC_NETWORK_PASSPHRASE',
+ level: 'warning',
+ message: 'Passphrase is not one of the known Stellar PUBLIC/TESTNET passphrases.',
+ },
+ ],
+};
+
+const resultErrorsOnly: ConfigValidationResult = {
+ valid: false,
+ issues: [
+ {
+ field: 'NEXT_PUBLIC_AEGIS_CONTRACT_ID',
+ level: 'error',
+ message: 'Contract ID is not a valid Soroban contract ID (expected 56 characters, starting with "C").',
+ },
+ ],
+};
+
+describe('ConfigErrorScreen', () => {
+ it('renders the heading', () => {
+ render();
+ expect(screen.getByText('Dashboard configuration is invalid')).toBeInTheDocument();
+ });
+
+ it('lists error-level issues under an Errors heading', () => {
+ render();
+ expect(screen.getByText('Errors')).toBeInTheDocument();
+ expect(screen.getByText('NEXT_PUBLIC_RPC_URL')).toBeInTheDocument();
+ expect(screen.getByText(/RPC URL is not set\./)).toBeInTheDocument();
+ });
+
+ it('lists warning-level issues under a Warnings heading', () => {
+ render();
+ expect(screen.getByText('Warnings')).toBeInTheDocument();
+ expect(screen.getByText('NEXT_PUBLIC_NETWORK_PASSPHRASE')).toBeInTheDocument();
+ });
+
+ it('omits the Warnings heading when there are no warnings', () => {
+ render();
+ expect(screen.queryByText('Warnings')).not.toBeInTheDocument();
+ });
+
+ it('points to the docs and mock mode as a way forward', () => {
+ render();
+ expect(screen.getByText(/docs\/config-validation\.md/)).toBeInTheDocument();
+ expect(screen.getByText(/NEXT_PUBLIC_MOCK_MODE/)).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/components/ConfigErrorScreen.tsx b/src/components/ConfigErrorScreen.tsx
new file mode 100644
index 0000000..9b70695
--- /dev/null
+++ b/src/components/ConfigErrorScreen.tsx
@@ -0,0 +1,88 @@
+/**
+ * ConfigErrorScreen
+ *
+ * Renders when the dashboard's own environment configuration fails
+ * validation (see src/config/validate.ts, Issue #8). This is deliberately a
+ * hard stop, not a dismissible banner — a misconfigured RPC URL or contract
+ * ID could otherwise let a user sign an action against the wrong network or
+ * contract without ever realizing it.
+ *
+ * Only ever shown for "error"-level issues; "warning"-level issues are
+ * listed here too for visibility but never block on their own.
+ */
+
+import { AlertTriangle, AlertCircle } from 'lucide-react';
+import type { ConfigValidationResult, ConfigIssue } from '@/config/validate';
+
+interface ConfigErrorScreenProps {
+ result: ConfigValidationResult;
+}
+
+function IssueRow({ issue }: { issue: ConfigIssue }) {
+ const isError = issue.level === 'error';
+ return (
+
+ {isError ? (
+
+ ) : (
+
+ )}
+
+ {issue.field}: {issue.message}
+
+
+ );
+}
+
+export default function ConfigErrorScreen({ result }: ConfigErrorScreenProps) {
+ const errors = result.issues.filter((issue) => issue.level === 'error');
+ const warnings = result.issues.filter((issue) => issue.level === 'warning');
+
+ return (
+
+
+
+
+
Dashboard configuration is invalid
+
+ Fix the issues below in your environment configuration before continuing.
+
+
+
+ {errors.length > 0 && (
+
+
Errors
+
+ {errors.map((issue) => (
+
+ ))}
+
+
+ )}
+
+ {warnings.length > 0 && (
+
+
Warnings
+
+ {warnings.map((issue) => (
+
+ ))}
+
+
+ )}
+
+
+ See docs/config-validation.md for setup instructions, or set{' '}
+ NEXT_PUBLIC_MOCK_MODE="true" for local frontend
+ development without real RPC/contract config.
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/EnvironmentBanner.test.tsx b/src/components/EnvironmentBanner.test.tsx
new file mode 100644
index 0000000..699afe9
--- /dev/null
+++ b/src/components/EnvironmentBanner.test.tsx
@@ -0,0 +1,39 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import EnvironmentBanner from './EnvironmentBanner';
+
+const ORIGINAL_ENV = process.env;
+const VALID_CONTRACT_ID = 'C' + 'A'.repeat(55);
+
+beforeEach(() => {
+ process.env = { ...ORIGINAL_ENV };
+ process.env.NEXT_PUBLIC_MOCK_MODE = 'false';
+ process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID = VALID_CONTRACT_ID;
+});
+
+describe('EnvironmentBanner', () => {
+ it('renders nothing when mock mode is active', () => {
+ process.env.NEXT_PUBLIC_MOCK_MODE = 'true';
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('shows the testnet label for the TESTNET passphrase', () => {
+ process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015';
+ render();
+ expect(screen.getByText('Stellar Testnet (TESTNET)')).toBeInTheDocument();
+ });
+
+ it('shows a distinct LIVE MAINNET label for the PUBLIC passphrase', () => {
+ process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Public Global Stellar Network ; September 2015';
+ render();
+ expect(screen.getByText('LIVE MAINNET')).toBeInTheDocument();
+ });
+
+ it('shows a redacted contract id, never the full id', () => {
+ process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015';
+ render();
+ expect(screen.queryByText(VALID_CONTRACT_ID)).not.toBeInTheDocument();
+ expect(screen.getByText(`${VALID_CONTRACT_ID.slice(0, 4)}...${VALID_CONTRACT_ID.slice(-4)}`)).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/components/EnvironmentBanner.tsx b/src/components/EnvironmentBanner.tsx
new file mode 100644
index 0000000..5af8382
--- /dev/null
+++ b/src/components/EnvironmentBanner.tsx
@@ -0,0 +1,46 @@
+/**
+ * EnvironmentBanner
+ *
+ * Always-visible strip that shows which network and contract the dashboard
+ * is currently pointed at (Issue #8). This is distinct from:
+ * - MockModeBanner, which only renders when mock mode is active, and
+ * - EnvironmentMismatchScreen, which blocks on a *wallet* network mismatch.
+ *
+ * EnvironmentBanner renders unconditionally (outside of mock mode) so a user
+ * can tell which environment they're in at a glance, even before connecting
+ * a wallet. Mainnet gets a visually distinct, harder-to-miss treatment since
+ * real funds are at stake there.
+ */
+
+import { getTargetNetwork, formatNetworkLabel } from '@/lib/environment';
+import { redactContractId } from '@/lib/diagnostics/redact';
+import { isMockModeEnabled } from '@/config/mockMode';
+
+const MAINNET_PASSPHRASE = 'Public Global Stellar Network ; September 2015';
+
+export default function EnvironmentBanner() {
+ // Mock mode has its own, more prominent banner — avoid showing both.
+ if (isMockModeEnabled()) return null;
+
+ const passphrase = getTargetNetwork();
+ const label = formatNetworkLabel(passphrase);
+ const contractId = process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID;
+ const isMainnet = passphrase === MAINNET_PASSPHRASE;
+
+ return (
+
+ {isMainnet ? 'LIVE MAINNET' : label}
+
+ •
+
+ {redactContractId(contractId)}
+
+ );
+}
\ No newline at end of file
diff --git a/src/config/validate.test.ts b/src/config/validate.test.ts
new file mode 100644
index 0000000..58d4945
--- /dev/null
+++ b/src/config/validate.test.ts
@@ -0,0 +1,141 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { validateDashboardConfig } from './validate';
+
+const ORIGINAL_ENV = process.env;
+
+const VALID_CONTRACT_ID = 'C' + 'A'.repeat(55);
+
+beforeEach(() => {
+ process.env = { ...ORIGINAL_ENV };
+ process.env.NEXT_PUBLIC_MOCK_MODE = 'false';
+ process.env.NEXT_PUBLIC_RPC_URL = 'https://soroban-testnet.stellar.org';
+ process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015';
+ process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID = VALID_CONTRACT_ID;
+});
+
+describe('validateDashboardConfig', () => {
+ it('is valid when all fields are well-formed', () => {
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(true);
+ expect(result.issues).toHaveLength(0);
+ });
+
+ it('skips all validation when mock mode is enabled, even with missing config', () => {
+ process.env.NEXT_PUBLIC_MOCK_MODE = 'true';
+ delete process.env.NEXT_PUBLIC_RPC_URL;
+ delete process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE;
+ delete process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID;
+
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(true);
+ expect(result.issues).toHaveLength(0);
+ });
+
+ describe('RPC URL', () => {
+ it('errors when missing', () => {
+ delete process.env.NEXT_PUBLIC_RPC_URL;
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(false);
+ expect(result.issues).toContainEqual(
+ expect.objectContaining({ field: 'NEXT_PUBLIC_RPC_URL', level: 'error' }),
+ );
+ });
+
+ it('errors when not a valid URL', () => {
+ process.env.NEXT_PUBLIC_RPC_URL = 'not-a-url';
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(false);
+ expect(result.issues).toContainEqual(
+ expect.objectContaining({ field: 'NEXT_PUBLIC_RPC_URL', level: 'error' }),
+ );
+ });
+
+ it('warns (but does not invalidate) on non-HTTPS remote URLs', () => {
+ process.env.NEXT_PUBLIC_RPC_URL = 'http://soroban-testnet.stellar.org';
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(true);
+ expect(result.issues).toContainEqual(
+ expect.objectContaining({ field: 'NEXT_PUBLIC_RPC_URL', level: 'warning' }),
+ );
+ });
+
+ it('allows plain HTTP on localhost', () => {
+ process.env.NEXT_PUBLIC_RPC_URL = 'http://localhost:8000/soroban/rpc';
+ const result = validateDashboardConfig();
+ expect(result.issues.filter((i) => i.field === 'NEXT_PUBLIC_RPC_URL')).toHaveLength(0);
+ });
+ });
+
+ describe('Network passphrase', () => {
+ it('errors when missing', () => {
+ delete process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE;
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(false);
+ expect(result.issues).toContainEqual(
+ expect.objectContaining({ field: 'NEXT_PUBLIC_NETWORK_PASSPHRASE', level: 'error' }),
+ );
+ });
+
+ it('warns (but does not invalidate) on an unrecognized passphrase', () => {
+ process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Standalone Network ; February 2017';
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(true);
+ expect(result.issues).toContainEqual(
+ expect.objectContaining({ field: 'NEXT_PUBLIC_NETWORK_PASSPHRASE', level: 'warning' }),
+ );
+ });
+
+ it('accepts the known PUBLIC passphrase with no issues', () => {
+ process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Public Global Stellar Network ; September 2015';
+ const result = validateDashboardConfig();
+ expect(result.issues.filter((i) => i.field === 'NEXT_PUBLIC_NETWORK_PASSPHRASE')).toHaveLength(0);
+ });
+ });
+
+ describe('Contract ID', () => {
+ it('errors when missing', () => {
+ delete process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID;
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(false);
+ expect(result.issues).toContainEqual(
+ expect.objectContaining({ field: 'NEXT_PUBLIC_AEGIS_CONTRACT_ID', level: 'error' }),
+ );
+ });
+
+ it('errors on the placeholder value from .env.example', () => {
+ process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID = 'CABC123...';
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(false);
+ expect(result.issues).toContainEqual(
+ expect.objectContaining({ field: 'NEXT_PUBLIC_AEGIS_CONTRACT_ID', level: 'error' }),
+ );
+ });
+
+ it('errors when too short', () => {
+ process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID = 'CSHORT';
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(false);
+ });
+
+ it('errors when it does not start with C', () => {
+ process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID = 'G' + 'A'.repeat(55);
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(false);
+ });
+
+ it('accepts a well-formed 56-character contract ID', () => {
+ const result = validateDashboardConfig();
+ expect(result.issues.filter((i) => i.field === 'NEXT_PUBLIC_AEGIS_CONTRACT_ID')).toHaveLength(0);
+ });
+ });
+
+ it('reports multiple issues at once rather than stopping at the first', () => {
+ delete process.env.NEXT_PUBLIC_RPC_URL;
+ delete process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE;
+ delete process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID;
+
+ const result = validateDashboardConfig();
+ expect(result.valid).toBe(false);
+ expect(result.issues.length).toBeGreaterThanOrEqual(3);
+ });
+});
\ No newline at end of file
diff --git a/src/config/validate.ts b/src/config/validate.ts
new file mode 100644
index 0000000..5657ed0
--- /dev/null
+++ b/src/config/validate.ts
@@ -0,0 +1,140 @@
+/**
+ * Dashboard configuration validation (Issue #8).
+ *
+ * Validates the shape of the environment variables that tell the dashboard
+ * which network, RPC endpoint, and contract to talk to. The goal is to fail
+ * loudly and clearly at startup when a variable is missing or malformed,
+ * instead of surfacing as a confusing runtime error deep inside an SDK call
+ * or — worse — silently sending signed actions to the wrong contract.
+ *
+ * Scope: this module only checks *shape* (is it a URL? is it contract-ID
+ * shaped?). It does not verify that the RPC endpoint is reachable or that
+ * the contract is actually deployed on-chain — that's a runtime/network
+ * concern, not a config concern.
+ *
+ * See docs/config-validation.md for the full guardrail write-up.
+ */
+
+import { isMockModeEnabled } from './mockMode';
+
+export type ConfigIssueLevel = 'error' | 'warning';
+
+export interface ConfigIssue {
+ /** The env var this issue relates to, e.g. "NEXT_PUBLIC_RPC_URL". */
+ field: string;
+ /** "error" blocks the dashboard; "warning" is surfaced but non-blocking. */
+ level: ConfigIssueLevel;
+ /** Human-readable, non-sensitive explanation (never includes the raw value). */
+ message: string;
+}
+
+export interface ConfigValidationResult {
+ /** False when at least one "error"-level issue is present. */
+ valid: boolean;
+ issues: ConfigIssue[];
+}
+
+/**
+ * Soroban/Stellar contract IDs are 56-character strkey-encoded strings that
+ * start with "C" (StrKey version byte for CONTRACT), using base32 alphabet
+ * [A-Z2-7]. This is a shape check only — it does not verify the checksum.
+ */
+const CONTRACT_ID_PATTERN = /^C[A-Z2-7]{55}$/;
+
+const KNOWN_PASSPHRASES = new Set([
+ 'Public Global Stellar Network ; September 2015',
+ 'Test SDF Network ; September 2015',
+]);
+
+function validateRpcUrl(rawUrl: string | undefined): ConfigIssue[] {
+ const field = 'NEXT_PUBLIC_RPC_URL';
+
+ if (!rawUrl) {
+ return [{ field, level: 'error', message: 'RPC URL is not set.' }];
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(rawUrl);
+ } catch {
+ return [{ field, level: 'error', message: 'RPC URL is not a valid URL.' }];
+ }
+
+ const isLocalHost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
+ if (parsed.protocol !== 'https:' && !isLocalHost) {
+ return [
+ {
+ field,
+ level: 'warning',
+ message: 'RPC URL does not use HTTPS. Non-local RPC endpoints should always be HTTPS.',
+ },
+ ];
+ }
+
+ return [];
+}
+
+function validateNetworkPassphrase(passphrase: string | undefined): ConfigIssue[] {
+ const field = 'NEXT_PUBLIC_NETWORK_PASSPHRASE';
+
+ if (!passphrase) {
+ return [{ field, level: 'error', message: 'Network passphrase is not set.' }];
+ }
+
+ if (!KNOWN_PASSPHRASES.has(passphrase)) {
+ return [
+ {
+ field,
+ level: 'warning',
+ message:
+ 'Passphrase is not one of the known Stellar PUBLIC/TESTNET passphrases. ' +
+ 'Confirm this is intentional (e.g. a custom standalone network) before deploying.',
+ },
+ ];
+ }
+
+ return [];
+}
+
+function validateContractId(contractId: string | undefined): ConfigIssue[] {
+ const field = 'NEXT_PUBLIC_AEGIS_CONTRACT_ID';
+
+ if (!contractId) {
+ return [{ field, level: 'error', message: 'Contract ID is not set.' }];
+ }
+
+ if (!CONTRACT_ID_PATTERN.test(contractId)) {
+ return [
+ {
+ field,
+ level: 'error',
+ message: 'Contract ID is not a valid Soroban contract ID (expected 56 characters, starting with "C").',
+ },
+ ];
+ }
+
+ return [];
+}
+
+/**
+ * Validate the dashboard's own configuration env vars.
+ *
+ * When mock mode is active, RPC URL / contract ID / passphrase are allowed to
+ * be missing or placeholder values since no real network calls are made, so
+ * validation is skipped entirely and this always reports valid.
+ */
+export function validateDashboardConfig(): ConfigValidationResult {
+ if (isMockModeEnabled()) {
+ return { valid: true, issues: [] };
+ }
+
+ const issues: ConfigIssue[] = [
+ ...validateRpcUrl(process.env.NEXT_PUBLIC_RPC_URL),
+ ...validateNetworkPassphrase(process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE),
+ ...validateContractId(process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID),
+ ];
+
+ const valid = !issues.some((issue) => issue.level === 'error');
+
+ return { valid, issues };
+}
\ No newline at end of file
diff --git a/src/features/diagnostics/components/DiagnosticsPanel.tsx b/src/features/diagnostics/components/DiagnosticsPanel.tsx
index 14c62ec..9a5e9c9 100644
--- a/src/features/diagnostics/components/DiagnosticsPanel.tsx
+++ b/src/features/diagnostics/components/DiagnosticsPanel.tsx
@@ -4,6 +4,7 @@ import { redactUrl, redactContractId } from '@/lib/diagnostics/redact';
import { useWallet } from '@/hooks/useWallet';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { isProviderMocked } from '@/lib/sdk';
+import { validateDashboardConfig } from '@/config/validate';
export default function DiagnosticsPanel() {
const { address, network } = useWallet();
@@ -18,6 +19,29 @@ export default function DiagnosticsPanel() {
const mockActive = isProviderMocked();
+ // Config validation (Issue #8). Only the issue *count* and *field names* are
+ // surfaced here — never raw env values — so this stays safe to include in a
+ // diagnostics report someone might paste into a support channel.
+ const configResult = validateDashboardConfig();
+ const configErrorCount = configResult.issues.filter((i) => i.level === 'error').length;
+ const configWarningCount = configResult.issues.filter((i) => i.level === 'warning').length;
+
+ let configStatusValue: string;
+ let configStatus: 'ok' | 'warning' | 'error' | 'unknown';
+ if (mockActive) {
+ configStatusValue = '[MOCK] Skipped — mock provider active';
+ configStatus = 'warning';
+ } else if (configErrorCount > 0) {
+ configStatusValue = `${configErrorCount} error(s), ${configWarningCount} warning(s)`;
+ configStatus = 'error';
+ } else if (configWarningCount > 0) {
+ configStatusValue = `Valid — ${configWarningCount} warning(s)`;
+ configStatus = 'warning';
+ } else {
+ configStatusValue = 'Valid';
+ configStatus = 'ok';
+ }
+
const reportData = {
timestamp: new Date().toISOString(),
sdkVersion: mockActive ? '[MOCK] v0.0.0-local' : 'Mocked v0.0.0',
@@ -25,6 +49,12 @@ export default function DiagnosticsPanel() {
contract: mockActive ? '[MOCK] Not deployed — mock provider active' : redactedContract,
wallet: address ? redactContractId(address) : 'Not connected',
network: mockActive ? 'LOCAL_MOCK' : network || 'Not connected',
+ configValidation: {
+ valid: mockActive ? true : configResult.valid,
+ errorCount: mockActive ? 0 : configErrorCount,
+ warningCount: mockActive ? 0 : configWarningCount,
+ fields: mockActive ? [] : configResult.issues.map((i) => `${i.field} (${i.level})`),
+ },
flags: flags,
provider: mockActive ? 'MockAegisProvider' : 'LiveAegisProvider',
};
@@ -67,6 +97,11 @@ export default function DiagnosticsPanel() {
value={reportData.provider}
status={mockActive ? 'warning' : 'ok'}
/>
+
);
-}
+}
\ No newline at end of file
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index b2a8971..f3d4c05 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -3,9 +3,12 @@ import { useEffect, useState, type ReactNode } from 'react';
import type { AppProps } from 'next/app';
import Navbar from '@/components/layout/Navbar';
import MockModeBanner from '@/components/MockModeBanner';
+import EnvironmentBanner from '@/components/EnvironmentBanner';
import EnvironmentMismatchScreen from '@/components/EnvironmentMismatchScreen';
+import ConfigErrorScreen from '@/components/ConfigErrorScreen';
import { useWallet } from '@/hooks/useWallet';
import { isMockModeEnabled } from '@/config/mockMode';
+import { validateDashboardConfig } from '@/config/validate';
import { evaluateEnvironmentMismatch, type EnvironmentMismatchResult } from '@/lib/environment';
function WalletAutoReconnect() {
@@ -20,6 +23,21 @@ function WalletAutoReconnect() {
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
+ * checking a wallet's network against a target network we can't even parse.
+ */
+function ConfigGuard({ children }: { children: ReactNode }) {
+ const result = validateDashboardConfig();
+
+ if (!result.valid) {
+ return ;
+ }
+
+ return <>{children}>;
+}
+
function EnvironmentGuard({ children }: { children: ReactNode }) {
const [blocked, setBlocked] = useState(false);
const [result, setResult] = useState(null);
@@ -52,13 +70,16 @@ export default function App({ Component, pageProps }: AppProps) {
return (
+
-
-
-
+
+
+
+
+
);
-}
+}
\ No newline at end of file