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 ? ( +
  • + ); +} + +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 ( +
    +
    +
    +
    + + {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