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
13 changes: 13 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { Toaster } from 'react-hot-toast';
import { createBrowserRouter, RouterProvider } from 'react-router';

import AppErrorBoundary from './components/common/AppErrorBoundary';
import ContractPausedBanner from './components/common/ContractPausedBanner';
import OfflineBanner from './components/common/OfflineBanner';
import SessionExpiryWatcher from './components/common/SessionExpiryWatcher';
import { useContractPausedStore } from './hooks/useContractPausedStore';
import { routes } from './routes';
import { useRouteChangeLogging } from './hooks/useRouteChangeLogging';

Expand All @@ -14,6 +16,15 @@ const router = createBrowserRouter(routes);
function App() {
useRouteChangeLogging();

// Start polling contract pause state on app load (#953)
useEffect(() => {
const store = useContractPausedStore.getState();
store.startPolling();
return () => {
store.stopPolling();
};
}, []);

useEffect(() => {
const lenis = new Lenis({
duration: 1.2,
Expand All @@ -29,6 +40,8 @@ function App() {

return (
<AppErrorBoundary>
{/* Full-width banner when contract emergency pause is active (#953) */}
<ContractPausedBanner />
<OfflineBanner />
<Toaster
toastOptions={{
Expand Down
118 changes: 118 additions & 0 deletions src/components/common/AdminPauseControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* AdminPauseControl (#953)
*
* Emergency pause / unpause toggle for the Access Layer contract.
* Per the spec:
* - Only rendered/visible to authorised admin wallets.
* - Allows toggling the contract pause state.
*/

import React from 'react';
import { useAccount } from 'wagmi';
import { Shield, ShieldAlert, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
useContractPausedStore,
selectIsPaused,
isAuthorizedAdminWallet,
} from '@/hooks/useContractPausedStore';
import toast from 'react-hot-toast';

export interface AdminPauseControlProps {
/** Optional wallet address override (otherwise uses wagmi's useAccount). */
walletAddress?: string;
className?: string;
}

const AdminPauseControl: React.FC<AdminPauseControlProps> = ({
walletAddress,
className,
}) => {
const { address } = useAccount();
const activeAddress = walletAddress ?? address;

const isPaused = useContractPausedStore(selectIsPaused);
const togglePause = useContractPausedStore(state => state.togglePause);

// Only visible to authorised admin wallets
if (!isAuthorizedAdminWallet(activeAddress)) {
return null;
}

const handleToggle = () => {
const nextState = !isPaused;
togglePause();
if (nextState) {
toast.error('Emergency pause activated. All trading disabled.');
} else {
toast.success('Contract unpaused. Trading resumed.');
}
};

return (
<div
data-testid="admin-pause-panel"
className={cn(
'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3',
'rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 sm:px-4',
className
)}
>
<div className="flex items-center gap-2.5">
<div className="flex size-7 items-center justify-center rounded-lg bg-amber-500/20 text-amber-300">
{isPaused ? (
<ShieldAlert className="size-4 text-red-400" aria-hidden="true" />
) : (
<Shield className="size-4 text-amber-400" aria-hidden="true" />
)}
</div>
<div>
<p className="text-xs font-bold tracking-wide uppercase text-amber-300">
Admin Controls
</p>
<p className="text-xs text-white/70">
Contract status:{' '}
<span
className={cn(
'font-semibold',
isPaused ? 'text-red-400' : 'text-emerald-400'
)}
>
{isPaused ? 'Paused (Trading Suspended)' : 'Active (Trading Open)'}
</span>
</p>
</div>
</div>

<Button
type="button"
size="sm"
variant={isPaused ? 'default' : 'destructive'}
onClick={handleToggle}
data-testid="admin-pause-toggle"
aria-label={isPaused ? 'Unpause contract' : 'Emergency pause contract'}
className={cn(
'rounded-lg font-bold text-xs',
isPaused
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
: 'bg-red-600 hover:bg-red-500 text-white'
)}
>
{isPaused ? (
<>
<ShieldCheck className="mr-1.5 size-3.5" aria-hidden="true" />
Resume Contract
</>
) : (
<>
<ShieldAlert className="mr-1.5 size-3.5" aria-hidden="true" />
Emergency Pause
</>
)}
</Button>
</div>
);
};

export default AdminPauseControl;
133 changes: 133 additions & 0 deletions src/components/common/AllowanceApprovalStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* AllowanceApprovalStep (#955)
*
* Inline approval step rendered inside the buy/staking flow when the
* staking contract does not yet have sufficient allowance to transfer
* the user's keys.
*
* Flow:
* 1. Parent checks `needsApproval` (from useAllowanceStore).
* 2. If true, renders this component instead of the normal confirm button.
* 3. User reads the explanation and clicks "Approve transfer".
* 4. On success, `onApproved` is called so the parent can proceed.
* 5. On error, an inline error message is shown with a retry option.
*
* The component is purely presentational with respect to layout — it takes
* no wallet addresses; the parent passes the pre-checked status and the
* submit handler.
*/

import { ShieldCheck, AlertCircle, Loader2, Info } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { StableButtonContent } from '@/components/ui/stable-button-content';
import { cn } from '@/lib/utils';

export interface AllowanceApprovalStepProps {
/** Whether the approval transaction is currently in-flight. */
isApproving: boolean;
/** Non-null when the approval transaction failed. */
errorMessage: string | null;
/** Called when the user clicks "Approve transfer". */
onApprove: () => void;
className?: string;
}

const AllowanceApprovalStep: React.FC<AllowanceApprovalStepProps> = ({
isApproving,
errorMessage,
onApprove,
className,
}) => {
return (
<div
className={cn(
'rounded-xl border border-amber-500/25 bg-amber-500/8 px-4 py-4 space-y-3',
className
)}
>
{/* Header */}
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-amber-500/15">
<ShieldCheck
className="size-3.5 text-amber-400"
aria-hidden="true"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-amber-100">
Approval required
</p>
<p className="mt-1 text-xs leading-relaxed text-amber-200/70">
Before staking your keys, you must grant the staking contract
permission to transfer them on your behalf. This is a one-time
on-chain approval per wallet.
</p>
</div>
</div>

{/* What the approval grants */}
<div className="rounded-lg border border-white/8 bg-white/[0.03] px-3 py-2.5 space-y-1.5">
<p className="flex items-center gap-1.5 text-[0.7rem] font-semibold uppercase tracking-wider text-white/40">
<Info className="size-3" aria-hidden="true" />
What this approval grants
</p>
<ul className="space-y-1 text-xs text-white/60">
<li className="flex items-start gap-1.5">
<span className="mt-1 size-1 shrink-0 rounded-full bg-amber-400/60" />
The staking contract may transfer your approved creator keys.
</li>
<li className="flex items-start gap-1.5">
<span className="mt-1 size-1 shrink-0 rounded-full bg-amber-400/60" />
Your wallet retains full custody until a stake action is
confirmed.
</li>
<li className="flex items-start gap-1.5">
<span className="mt-1 size-1 shrink-0 rounded-full bg-amber-400/60" />
You can revoke this approval at any time from your wallet
settings.
</li>
</ul>
</div>

{/* Error state */}
{errorMessage && (
<div
role="alert"
className="flex items-start gap-2 rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2.5"
>
<AlertCircle
className="mt-0.5 size-3.5 shrink-0 text-red-400"
aria-hidden="true"
/>
<p className="text-xs text-red-300">{errorMessage}</p>
</div>
)}

{/* Approve button */}
<Button
type="button"
onClick={onApprove}
disabled={isApproving}
aria-busy={isApproving || undefined}
className="w-full rounded-xl font-bold"
data-testid="allowance-approve-btn"
>
<StableButtonContent
isLoading={isApproving}
loadingLabel="Approving…"
spinner={
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
}
>
<ShieldCheck className="size-4" aria-hidden="true" />
Approve transfer
</StableButtonContent>
</Button>
</div>
);
};

export default AllowanceApprovalStep;
84 changes: 84 additions & 0 deletions src/components/common/ContractPausedBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* ContractPausedBanner (#953)
*
* Full-width banner shown at the top of the app when the contract emergency
* pause is active. Dismissed automatically when the contract is unpaused
* (the store polling handles this).
*
* When paused:
* - Renders a prominent red/amber banner with an explanation.
* - All trade action buttons elsewhere are disabled (enforced by callers
* reading `selectIsPaused` from the store).
*
* When not paused (or still checking): renders nothing.
*/

import { AnimatePresence, motion } from 'framer-motion';
import { ShieldAlert } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useContractPausedStore,
selectIsPaused,
} from '@/hooks/useContractPausedStore';

interface ContractPausedBannerProps {
className?: string;
}

const ContractPausedBanner: React.FC<ContractPausedBannerProps> = ({
className,
}) => {
const isPaused = useContractPausedStore(selectIsPaused);

return (
<AnimatePresence>
{isPaused && (
<motion.div
key="contract-paused-banner"
role="alert"
aria-live="assertive"
aria-atomic="true"
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.25 }}
className={cn(
'relative z-50 w-full overflow-hidden',
'border-b border-red-500/40 bg-gradient-to-r from-red-900/80 via-red-800/70 to-orange-900/60',
'backdrop-blur-md',
className
)}
>
{/* Subtle animated shimmer to draw attention */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,rgba(255,255,255,0.04)_50%,transparent_100%)] animate-[shimmer_2s_linear_infinite]"
/>

<div className="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:px-6">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-red-500/20">
<ShieldAlert
className="size-4 text-red-300"
aria-hidden="true"
/>
</div>

<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-red-100">
Trading suspended — contract paused
</p>
<p className="mt-0.5 text-xs leading-relaxed text-red-200/75">
The Access Layer contract has been temporarily paused by
the admin. All buy, sell, stake, and transfer actions are
disabled until it is unpaused. Read-only browsing
continues normally.
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
};

export default ContractPausedBanner;
Loading
Loading