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
16 changes: 15 additions & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,6 @@ body {
.micro-interaction-input {
transition: border-color 180ms ease, box-shadow 180ms ease;
}

.micro-interaction-input:focus {
border-color: rgb(96 165 250 / 0.9);
box-shadow: 0 0 0 1px rgb(96 165 250 / 0.55), 0 0 22px rgb(59 130 246 / 0.18);
Expand Down Expand Up @@ -308,10 +307,25 @@ body {
@media (prefers-reduced-motion: reduce) {
.status-ping,
.status-pulse,
.circuit-breaker-flash,
.stellar-confetti { animation: none; }
.micro-interaction-input { transition: none; }
}

/* ─── Circuit Breaker Emergency Banner ────────────────────────── */

/* Slow, low-contrast flash: the banner must read as urgent without strobing.
Suppressed entirely under prefers-reduced-motion (see block above). */
.circuit-breaker-flash {
animation: circuit-breaker-flash 1.8s ease-in-out infinite;
will-change: background-color;
}

@keyframes circuit-breaker-flash {
0%, 100% { background-color: rgb(159 18 57 / 0.97); }
50% { background-color: rgb(225 29 72 / 0.97); }
}

/* ─── Admin Tab Bar ──────────────────────────────────────── */

.admin-tabbar-wrapper {
Expand Down
11 changes: 11 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import { RpcFailoverMonitor } from "./components/providers/RpcFailoverMonitor";
import { CommandPalette } from "@/components/command-palette";
import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary";
import { CircuitBreakerProvider, CircuitBreakerBanner } from "@/components/circuit-breaker";
import { MobileBottomNav } from "@/components/navigation";

export const metadata: Metadata = {
Expand Down Expand Up @@ -151,6 +152,16 @@
>
<GlobalErrorBoundary>
<OfflineBanner />
{/*
Circuit breaker provider wraps the whole tree so an on-chain
`CircuitBreakerTriggered` event can lock the action CTAs of any
affected module view, not just the page that happens to be open.
The banner itself sits above it in document order so it is the first
thing a user meets when a pause lands.
*/}
<CircuitBreakerProvider>
<CircuitBreakerBanner />
</CircuitBreakerProvider>
<CspReporterInit />
<StorageSanitizer />
<SvgSprite />
Expand Down Expand Up @@ -195,7 +206,7 @@
</PushNotificationRoot>
</ToastProvider>
<SwUpdateBanner />
<InstallBanner />

Check failure on line 209 in src/app/layout.tsx

View workflow job for this annotation

GitHub Actions / Quality checks

'InstallBanner' is not defined
<OfflineDraftSyncRoot />
<PWAInstallGuideModal />
<CommandPalette />
Expand Down
37 changes: 29 additions & 8 deletions src/app/swap/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useSlippageTolerance } from "@/app/hooks/useSlippageTolerance";
import WalletConnectButton from "@/app/components/WalletConnectButton";
import OptimizedDialog from "@/app/components/OptimizedDialog";
import { MotionButton, MotionCard, SuccessConfetti } from "@/components/ui/MotionPrimitives";
import { PausedActionButton, useModulePause } from "@/components/circuit-breaker";
import CandlestickChart from "@/components/trading/CandlestickChart";
import { ASSET_SYMBOLS } from "@/config/assetSymbols";

Expand Down Expand Up @@ -45,6 +46,11 @@ function SwapContent() {
const [transactionPrepared, setTransactionPrepared] = useState(false);
const [slippageDialogOpen, setSlippageDialogOpen] = useState<boolean>(false);

// Emergency pause guard — an on-chain circuit breaker can halt the swaps
// module at any moment, in which case the CTA below is locked down.
const swapsPause = useModulePause("swaps");
const isSwapPaused = swapsPause !== undefined;

// Common tokens list (could be fetched from an API or config)
const commonTokens = useMemo(() => [
{ code: "USDC", issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", name: "USD Coin" },
Expand Down Expand Up @@ -142,6 +148,13 @@ function SwapContent() {
}, [selectedPath, sourceAsset, destAsset]);

const handleSwap = async () => {
if (isSwapPaused) {
setError(
"Swaps are temporarily paused by an automatic circuit breaker. Please retry once recovery completes."
);
return;
}

if (!wallet?.publicKey || !selectedPath || !destAsset || !destinationAddress) {
setError("Please connect wallet, select assets, and enter a destination address");
return;
Expand Down Expand Up @@ -302,14 +315,22 @@ function SwapContent() {
</div>
)}

{/* Swap Button */}
<MotionButton
onClick={handleSwap}
disabled={isLoading || !wallet?.connected || !selectedPath}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 py-4 rounded-xl font-semibold text-lg transition-colors"
>
{isLoading ? "Loading..." : !wallet?.connected ? "Connect Wallet to Swap" : "Swap & Send"}
</MotionButton>
{/* Swap Button — locked down while the swaps circuit breaker is in force */}
<PausedActionButton module="swaps">
<MotionButton
onClick={handleSwap}
disabled={isSwapPaused || isLoading || !wallet?.connected || !selectedPath}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 py-4 rounded-xl font-semibold text-lg transition-colors"
>
{isSwapPaused
? "Swaps Paused"
: isLoading
? "Loading..."
: !wallet?.connected
? "Connect Wallet to Swap"
: "Swap & Send"}
</MotionButton>
</PausedActionButton>
</MotionCard>

{/* Path Breakdown - only show if we have a selected path */}
Expand Down
171 changes: 171 additions & 0 deletions src/components/circuit-breaker/CircuitBreakerBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
'use client';

/**
* CircuitBreakerBanner
*
* High-visibility emergency alert pinned to the top of the root layout.
*
* Subscribes to the shared {@link CircuitBreakerProvider} and renders the
* instant the indexer mirrors an on-chain `CircuitBreakerTriggered` event.
* The banner names every halted protocol module and states the two numbers a
* user actually needs during an incident — how long the risk review is
* expected to take and when the module is estimated to come back.
*
* Accessibility notes:
* - `role="alert"` + `aria-live="assertive"` so the pause is announced the
* moment it lands, not on the next navigation.
* - The flash is a plain CSS animation registered in `globals.css` and is
* switched off under `prefers-reduced-motion: reduce`.
* - Countdown text is only rendered after mount so the server and client
* markup agree on the first paint.
*/

import React, { useEffect, useState } from 'react';
import { Clock, RotateCcw, ShieldAlert, TriangleAlert } from 'lucide-react';
import { useMounted } from '@/app/hooks/useMounted';
import { useCircuitBreakerState } from './CircuitBreakerContext';
import {
formatDurationMinutes,
formatRecoveryClock,
formatRecoveryCountdown,
} from '@/lib/circuitBreakerEvents';
import type { ActiveModulePause } from '@/types/circuitBreaker';

/** How often the relative countdown re-renders while a pause is active. */
const COUNTDOWN_INTERVAL_MS = 1_000;

export interface CircuitBreakerBannerProps {
/** Override the headline. Defaults to a count-aware emergency headline. */
title?: string;
}

export function CircuitBreakerBanner({ title }: CircuitBreakerBannerProps) {
const { activePauses, isMonitoring } = useCircuitBreakerState();
const mounted = useMounted();
const [now, setNow] = useState(() => Date.now());

const isActive = activePauses.length > 0;

// Tick only while there is something counting down. The interval is aligned
// to the wall clock so the displayed minute does not drift by up to a second
// per tick.
useEffect(() => {
if (!isActive) return;

setNow(Date.now());
const interval = setInterval(() => {
setNow(Date.now());
}, COUNTDOWN_INTERVAL_MS);

return () => clearInterval(interval);
}, [isActive]);

if (!isActive) return null;

const headline = title ?? buildHeadline(activePauses);

return (
<aside
role="alert"
aria-live="assertive"
aria-atomic="true"
aria-label="Emergency circuit breaker notice"
data-testid="circuit-breaker-banner"
data-circuit-breaker-modules={activePauses.map((pause) => pause.module).join(',')}
className="circuit-breaker-flash relative z-[70] w-full border-b border-rose-300/50 bg-rose-700 px-4 py-3 text-white shadow-lg"
>
<div className="mx-auto flex max-w-7xl flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between lg:gap-6">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 shrink-0" aria-hidden="true">
<TriangleAlert className="h-6 w-6 motion-safe:animate-pulse" />
</span>

<div className="min-w-0 text-sm">
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-bold uppercase tracking-wide sm:text-base">
<span>{headline}</span>
{!isMonitoring && (
<span className="rounded bg-black/25 px-1.5 py-0.5 text-[10px] font-semibold normal-case tracking-normal">
Reconnecting to indexer
</span>
)}
</p>

<ul className="mt-1.5 space-y-1">
{activePauses.map((pause) => (
<li
key={pause.id}
className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-rose-50 sm:text-sm"
>
<span className="inline-flex items-center gap-1 font-semibold">
<ShieldAlert className="h-3.5 w-3.5" aria-hidden="true" />
{pause.moduleLabel} paused
</span>
<span className="text-rose-100/90">{pause.reason}</span>
<RecoveryEstimate pause={pause} now={mounted ? now : null} />
</li>
))}
</ul>
</div>
</div>

<dl className="flex shrink-0 flex-wrap gap-x-5 gap-y-2 text-xs lg:justify-end">
{activePauses.map((pause) => (
<div key={pause.id} className="flex items-center gap-1.5">
<dt className="inline-flex items-center gap-1 text-rose-100/80">
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
{pause.moduleLabel} review
</dt>
<dd className="font-mono font-semibold">
~{formatDurationMinutes(pause.reviewMinutes)}
</dd>
</div>
))}
<div className="flex items-center gap-1.5">
<dt className="inline-flex items-center gap-1 text-rose-100/80">
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
Recovery
</dt>
<dd className="font-mono font-semibold">
{mounted ? `${formatRecoveryClock(earliestRecoveryAt(activePauses))} UTC` : '—'}
</dd>
</div>
</dl>
</div>
</aside>
);
}

export default CircuitBreakerBanner;

/** Earliest estimated recovery across all active pauses. */
function earliestRecoveryAt(pauses: readonly ActiveModulePause[]): number {
return pauses.reduce(
(earliest, pause) => Math.min(earliest, pause.estimatedRecoveryAt),
Number.POSITIVE_INFINITY,
);
}

function buildHeadline(pauses: readonly ActiveModulePause[]): string {
const isCritical = pauses.some((pause) => pause.severity === 'critical');
const scope = pauses.length === 1 ? `${pauses[0].moduleLabel} module` : 'Multiple modules';
return isCritical
? `Circuit breaker triggered — ${scope} halted`
: `Circuit breaker warning — ${scope} restricted`;
}

/**
* Relative recovery countdown for one pause.
*
* `now` is `null` before mount, which suppresses the countdown instead of
* rendering a value the server could not have produced.
*/
function RecoveryEstimate({ pause, now }: { pause: ActiveModulePause; now: number | null }) {
if (now === null) return null;

const countdown = formatRecoveryCountdown(pause.estimatedRecoveryAt, now);
return (
<span className="font-mono text-[11px] text-rose-100/90">
· recovery {countdown} ({formatRecoveryClock(pause.estimatedRecoveryAt)} UTC)
</span>
);
}
Loading
Loading