From 51170343f701e4e7b9f26be7cb006d755bc44afa Mon Sep 17 00:00:00 2001 From: Khadijah Abdulwasii Date: Fri, 25 Sep 2026 16:38:16 +0100 Subject: [PATCH 1/3] feat: Implement allowance approval, emergency contract pause, and holding cap warnings (#953, #955, #961) - #955: Build allowance approval flow for delegating key transfers to staking contract - Add useAllowanceStore with session-level caching of approved addresses - Add AllowanceApprovalStep UI with clear explanation of permissions - Integrate into TradeDialog for staking operations before stake submission - #953: Show contract paused banner and disable all trade actions when emergency pause is active - Add useContractPausedStore with 60s background polling and admin verification - Add top-level ContractPausedBanner with animated shimmer - Add AdminPauseControl for authorized admin wallets - Disable trade actions (buy, sell, stake, transfer) with explanatory tooltips when paused - #961: Add maximum holding cap warning on buy panel when approaching wallet limit - Add useHoldingCapStore with cap tracking and capacity calculations - Add HoldingCapWarning with accessible progress bar (role=progressbar) - Display approaching-cap warning at >=80% capacity and auto-clamp quantity to remaining limit - Display cap reached state and disable buying when at 100% capacity - Update holding cap store and UI state upon successful purchase - Tests: Add 55 comprehensive unit tests across stores and components --- src/App.tsx | 23 ++ src/components/common/AdminPauseControl.tsx | 118 ++++++ .../common/AllowanceApprovalStep.tsx | 133 +++++++ .../common/ContractPausedBanner.tsx | 84 ++++ src/components/common/CreatorCard.tsx | 21 +- src/components/common/HoldingCapWarning.tsx | 135 +++++++ src/components/common/TradeDialog.tsx | 363 ++++++++++++++++-- .../__tests__/AdminPauseControl.test.tsx | 60 +++ .../__tests__/AllowanceApprovalStep.test.tsx | 77 ++++ .../__tests__/ContractPausedBanner.test.tsx | 57 +++ .../__tests__/HoldingCapWarning.test.tsx | 69 ++++ .../__tests__/TradeDialog.features.test.tsx | 263 +++++++++++++ src/hooks/__tests__/useAllowanceStore.test.ts | 97 +++++ .../__tests__/useContractPausedStore.test.ts | 94 +++++ .../__tests__/useHoldingCapStore.test.ts | 123 ++++++ src/hooks/useAllowanceStore.ts | 136 +++++++ src/hooks/useContractPausedStore.ts | 147 +++++++ src/hooks/useHoldingCapStore.ts | 159 ++++++++ src/pages/LandingPage.tsx | 85 +++- src/test/setup.ts | 12 + vitest.config.ts | 3 +- 21 files changed, 2212 insertions(+), 47 deletions(-) create mode 100644 src/components/common/AdminPauseControl.tsx create mode 100644 src/components/common/AllowanceApprovalStep.tsx create mode 100644 src/components/common/ContractPausedBanner.tsx create mode 100644 src/components/common/HoldingCapWarning.tsx create mode 100644 src/components/common/__tests__/AdminPauseControl.test.tsx create mode 100644 src/components/common/__tests__/AllowanceApprovalStep.test.tsx create mode 100644 src/components/common/__tests__/ContractPausedBanner.test.tsx create mode 100644 src/components/common/__tests__/HoldingCapWarning.test.tsx create mode 100644 src/components/common/__tests__/TradeDialog.features.test.tsx create mode 100644 src/hooks/__tests__/useAllowanceStore.test.ts create mode 100644 src/hooks/__tests__/useContractPausedStore.test.ts create mode 100644 src/hooks/__tests__/useHoldingCapStore.test.ts create mode 100644 src/hooks/useAllowanceStore.ts create mode 100644 src/hooks/useContractPausedStore.ts create mode 100644 src/hooks/useHoldingCapStore.ts diff --git a/src/App.tsx b/src/App.tsx index 6fe4f09c..571f0b4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,25 @@ +import { useEffect } from 'react'; import { Toaster } from 'react-hot-toast'; import { createBrowserRouter, RouterProvider } from 'react-router'; import MarketingPage from './pages/MarketingPage'; +import LandingPage from './pages/LandingPage'; import NotFoundPage from './pages/NotFoundPage'; +import ContractPausedBanner from './components/common/ContractPausedBanner'; +import { useContractPausedStore } from './hooks/useContractPausedStore'; const router = createBrowserRouter([ { path: '/', element: , }, + { + path: '/app', + element: , + }, + { + path: '/marketplace', + element: , + }, { path: '*', element: , @@ -15,8 +27,19 @@ const router = createBrowserRouter([ ]); function App() { + // Start polling contract pause state on app load (#953) + useEffect(() => { + const store = useContractPausedStore.getState(); + store.startPolling(); + return () => { + store.stopPolling(); + }; + }, []); + return ( <> + {/* Full-width banner when contract emergency pause is active (#953) */} + = ({ + 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 ( +
+
+
+ {isPaused ? ( +
+
+

+ Admin Controls +

+

+ Contract status:{' '} + + {isPaused ? 'Paused (Trading Suspended)' : 'Active (Trading Open)'} + +

+
+
+ + +
+ ); +}; + +export default AdminPauseControl; diff --git a/src/components/common/AllowanceApprovalStep.tsx b/src/components/common/AllowanceApprovalStep.tsx new file mode 100644 index 00000000..aa67f989 --- /dev/null +++ b/src/components/common/AllowanceApprovalStep.tsx @@ -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 = ({ + isApproving, + errorMessage, + onApprove, + className, +}) => { + return ( +
+ {/* Header */} +
+
+
+
+

+ Approval required +

+

+ 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. +

+
+
+ + {/* What the approval grants */} +
+

+

+
    +
  • + + The staking contract may transfer your approved creator keys. +
  • +
  • + + Your wallet retains full custody until a stake action is + confirmed. +
  • +
  • + + You can revoke this approval at any time from your wallet + settings. +
  • +
+
+ + {/* Error state */} + {errorMessage && ( +
+
+ )} + + {/* Approve button */} + +
+ ); +}; + +export default AllowanceApprovalStep; diff --git a/src/components/common/ContractPausedBanner.tsx b/src/components/common/ContractPausedBanner.tsx new file mode 100644 index 00000000..8cf91021 --- /dev/null +++ b/src/components/common/ContractPausedBanner.tsx @@ -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 = ({ + className, +}) => { + const isPaused = useContractPausedStore(selectIsPaused); + + return ( + + {isPaused && ( + + {/* Subtle animated shimmer to draw attention */} +