From d65f7ff887b996a4991c7922de7db17740ef92df Mon Sep 17 00:00:00 2001
From: tekprecious
Date: Sun, 27 Sep 2026 23:31:03 +0100
Subject: [PATCH 1/2] feat: implement platform-wide emergency pause UI (#1026)
---
src/App.tsx | 2 +
src/components/common/BuySellKeyFlow.tsx | 49 +++++++------
src/components/common/GlobalPauseBanner.tsx | 13 ++--
src/components/common/PortfolioHoldingRow.tsx | 45 +++++++-----
.../common/StakingRewardsSection.tsx | 12 +++-
src/components/common/TradeDialog.tsx | 56 ++++++++-------
src/hooks/useGlobalPause.ts | 72 ++++++++++++-------
src/pages/AtomicSwapCreatePage.tsx | 27 ++++---
8 files changed, 165 insertions(+), 111 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index ba8a9bca..4ca505bc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,6 +4,7 @@ import { Toaster } from 'react-hot-toast';
import { createBrowserRouter, RouterProvider } from 'react-router';
import AppErrorBoundary from './components/common/AppErrorBoundary';
import OfflineBanner from './components/common/OfflineBanner';
+import GlobalPauseBanner from './components/common/GlobalPauseBanner';
import SessionExpiryWatcher from './components/common/SessionExpiryWatcher';
import { routes } from './routes';
import { useRouteChangeLogging } from './hooks/useRouteChangeLogging';
@@ -29,6 +30,7 @@ function App() {
return (
+
= ({
>(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [internalSubmitting, setInternalSubmitting] = useState(false);
+ const { paused } = useGlobalPause();
const isSubmitting = externalIsSubmitting || internalSubmitting;
@@ -489,27 +491,30 @@ export const BuySellKeyFlow: React.FC = ({
)}
{/* Review / Proceed to Confirmation Button */}
-
+
+
+
{/* Confirmation Modal */}
{
+ const { paused, pauseActivatedAt } = useGlobalPause();
+
+ if (!paused) return null;
-const GlobalPauseBanner: React.FC = ({
- pauseActivatedAt,
-}) => {
const formatted = pauseActivatedAt
? new Date(pauseActivatedAt).toLocaleString()
: null;
@@ -20,7 +19,7 @@ const GlobalPauseBanner: React.FC = ({
Trading is temporarily suspended across all keys. We are working to
- resolve this.
+ resolve this. Check our status page for updates.
{formatted && (
Paused since {formatted}
diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx
index 8511febf..7fcc9da0 100644
--- a/src/components/common/PortfolioHoldingRow.tsx
+++ b/src/components/common/PortfolioHoldingRow.tsx
@@ -20,6 +20,8 @@ import { isKeyDeprecated } from '@/utils/keyDeprecation.utils';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';
+import { useGlobalPause } from '@/hooks/useGlobalPause';
+import { UnavailableAction } from '@/components/ui/unavailable-action';
export interface PortfolioHoldingRowProps {
position: HeldKeyPosition;
@@ -65,6 +67,7 @@ export const PortfolioHoldingRow: React.FC = ({
const hasDividends = hasUnclaimedDividend(position.unclaimedDividend);
const keyPriceStroops = resolveCreatorKeyPriceStroops(position);
const deprecated = isKeyDeprecated(creator);
+ const { paused } = useGlobalPause();
// #935 — unrealised P&L for this position: what the keys would fetch if sold
// at the current bonding-curve sell price, less what was paid for them.
@@ -209,27 +212,31 @@ export const PortfolioHoldingRow: React.FC = ({
)}
{onBuy && (
-
+
+
+
)}
{onSell && (
-
+
+
+
)}
>
)}
diff --git a/src/components/common/StakingRewardsSection.tsx b/src/components/common/StakingRewardsSection.tsx
index 4c17cd27..4b542a0d 100644
--- a/src/components/common/StakingRewardsSection.tsx
+++ b/src/components/common/StakingRewardsSection.tsx
@@ -8,6 +8,8 @@ import {
hasStakingActivity,
type StakingPoolStats,
} from '@/utils/stakingRewards.utils';
+import { useGlobalPause } from '@/hooks/useGlobalPause';
+import { UnavailableAction } from '@/components/ui/unavailable-action';
export interface StakingRewardsSectionProps extends StakingPoolStats {
/** Whether the key detail data is still loading. */
@@ -35,6 +37,8 @@ const StakingRewardsSection: React.FC = ({
isLoading = false,
stakeHref = '/profile?tab=staking',
}) => {
+ const { paused } = useGlobalPause();
+
if (isLoading) {
return (
@@ -110,9 +114,11 @@ const StakingRewardsSection: React.FC = ({
current pool balance. Actual rewards vary with trading volume.
-
+
+
+
);
};
diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx
index 36ba3b1a..a3837aba 100644
--- a/src/components/common/TradeDialog.tsx
+++ b/src/components/common/TradeDialog.tsx
@@ -55,6 +55,8 @@ import {
import type { KeyConfig } from '@/services/course.service';
import SpreadIndicator from '@/components/common/SpreadIndicator';
import { useSlippageTolerancePreference } from '@/hooks/useSlippageTolerancePreference';
+import { useGlobalPause } from '@/hooks/useGlobalPause';
+import { UnavailableAction } from '@/components/ui/unavailable-action';
export type TradeSide = 'buy' | 'sell';
@@ -139,6 +141,7 @@ const TradeDialog: React.FC = ({
// capture whatever had focus right before the dialog opened ourselves
// and restore it in onCloseAutoFocus instead.
const triggerElementRef = useRef(null);
+ const { paused } = useGlobalPause();
useEffect(() => {
if (open) {
@@ -655,33 +658,36 @@ const TradeDialog: React.FC = ({
>
Cancel
-
+
>
);
diff --git a/src/hooks/useGlobalPause.ts b/src/hooks/useGlobalPause.ts
index ed38d7ef..e06930db 100644
--- a/src/hooks/useGlobalPause.ts
+++ b/src/hooks/useGlobalPause.ts
@@ -5,37 +5,61 @@ export interface ProtocolStatus {
pauseActivatedAt?: string;
}
-const POLL_INTERVAL_MS = 60_000;
+const POLL_INTERVAL_MS = 30_000;
+
+let globalPaused = false;
+let globalPauseActivatedAt: string | undefined;
+let listeners: Array<() => void> = [];
+
+const notify = () => listeners.forEach(l => l());
+
+const check = async () => {
+ try {
+ const res = await fetch('/protocol/status');
+ if (!res.ok) return;
+ const data: ProtocolStatus = await res.json();
+ if (
+ globalPaused !== data.globalTradingPaused ||
+ globalPauseActivatedAt !== data.pauseActivatedAt
+ ) {
+ globalPaused = data.globalTradingPaused;
+ globalPauseActivatedAt = data.pauseActivatedAt;
+ notify();
+ }
+ } catch {
+ // silently ignore — will retry next poll
+ }
+};
+
+let pollTimer: number | undefined;
export function useGlobalPause() {
- const [paused, setPaused] = useState(false);
- const [pauseActivatedAt, setPauseActivatedAt] = useState<
- string | undefined
- >();
+ const [state, setState] = useState({
+ paused: globalPaused,
+ pauseActivatedAt: globalPauseActivatedAt,
+ });
useEffect(() => {
- let active = true;
-
- const check = async () => {
- try {
- const res = await fetch('/protocol/status');
- if (!res.ok) return;
- const data: ProtocolStatus = await res.json();
- if (!active) return;
- setPaused(data.globalTradingPaused);
- setPauseActivatedAt(data.pauseActivatedAt);
- } catch {
- // silently ignore — will retry next poll
- }
- };
+ const listener = () =>
+ setState({
+ paused: globalPaused,
+ pauseActivatedAt: globalPauseActivatedAt,
+ });
+ listeners.push(listener);
+
+ if (listeners.length === 1) {
+ check();
+ pollTimer = window.setInterval(check, POLL_INTERVAL_MS);
+ }
- check();
- const id = window.setInterval(check, POLL_INTERVAL_MS);
return () => {
- active = false;
- window.clearInterval(id);
+ listeners = listeners.filter(l => l !== listener);
+ if (listeners.length === 0 && pollTimer !== undefined) {
+ window.clearInterval(pollTimer);
+ pollTimer = undefined;
+ }
};
}, []);
- return { paused, pauseActivatedAt };
+ return state;
}
diff --git a/src/pages/AtomicSwapCreatePage.tsx b/src/pages/AtomicSwapCreatePage.tsx
index cc80aa0a..e23b9495 100644
--- a/src/pages/AtomicSwapCreatePage.tsx
+++ b/src/pages/AtomicSwapCreatePage.tsx
@@ -15,6 +15,8 @@ import PortfolioHoldingRow from '@/components/common/PortfolioHoldingRow';
import HoldingsEmptyState from '@/components/common/HoldingsEmptyState';
import CreatorSkeleton from '@/components/common/CreatorSkeleton';
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
+import { useGlobalPause } from '@/hooks/useGlobalPause';
+import { UnavailableAction } from '@/components/ui/unavailable-action';
const DEMO_WALLET_ADDRESS = 'demo-wallet-address';
@@ -26,6 +28,7 @@ export default function AtomicSwapCreatePage() {
const [creators, setCreators] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
+ const { paused } = useGlobalPause();
const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress);
@@ -166,17 +169,19 @@ export default function AtomicSwapCreatePage() {
walletAddress={activeWalletAddress}
isSubmitting={isNetworkMismatch}
/>
-
-
- Create Swap Proposal
-
+
+
+
+ Create Swap Proposal
+
+
The counterparty will need to connect their wallet and accept the proposal to execute the atomic swap.
From 069d1cd24661053f08aa3f0d71b96924af2593e0 Mon Sep 17 00:00:00 2001
From: tekprecious
Date: Thu, 1 Oct 2026 23:03:44 +0100
Subject: [PATCH 2/2] chore: fix unused variables and missing imports
---
src/components/common/BuySellKeyFlow.tsx | 4 +++-
src/components/common/PortfolioHoldingRow.tsx | 14 ++------------
src/components/common/TradeDialog.tsx | 19 +++++++------------
3 files changed, 12 insertions(+), 25 deletions(-)
diff --git a/src/components/common/BuySellKeyFlow.tsx b/src/components/common/BuySellKeyFlow.tsx
index 28a1a2ab..8fa404fa 100644
--- a/src/components/common/BuySellKeyFlow.tsx
+++ b/src/components/common/BuySellKeyFlow.tsx
@@ -18,6 +18,8 @@ import {
calculateTradePriceImpact,
isHighPriceImpact,
} from '@/utils/priceImpact.utils';
+import CircuitBreakerStatusIndicator from '@/components/common/CircuitBreakerStatusIndicator';
+import { evaluateCircuitBreakerStatus } from '@/utils/circuitBreaker.utils';
import { calculateLaunchPenalty } from '@/utils/launchPenalty.utils';
import {
calculateFeeBreakdown,
@@ -661,7 +663,7 @@ export const BuySellKeyFlow: React.FC = ({
)}
>
- {side === 'buy' ? 'Review Buy Order' : 'Review Sell Order'}
+ {reviewButtonLabel}
diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx
index 2fe974de..56453de2 100644
--- a/src/components/common/PortfolioHoldingRow.tsx
+++ b/src/components/common/PortfolioHoldingRow.tsx
@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import LockupCountdown from '@/components/common/LockupCountdown';
-import TradeCooldownButton from '@/components/common/TradeCooldownButton';
import ReinvestDividendDialog from '@/components/common/ReinvestDividendDialog';
import DeprecationNotice from '@/components/common/DeprecationNotice';
import RedeemKeyDialog from '@/components/common/RedeemKeyDialog';
@@ -18,8 +17,7 @@ import {
} from '@/utils/portfolioValue.utils';
import { hasUnclaimedDividend, xlmToStroops } from '@/utils/reinvestDividend.utils';
import { isKeyDeprecated } from '@/utils/keyDeprecation.utils';
-import { isActiveCooldown } from '@/utils/tradeCooldown.utils';
-import type { ActiveTradeCooldown } from '@/utils/tradeCooldown.utils';
+
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';
@@ -41,11 +39,7 @@ export interface PortfolioHoldingRowProps {
isReinvesting?: boolean;
isRedeeming?: boolean;
isNetworkMismatch?: boolean;
- /**
- * Active trade cooldown for this key (#998). When present, the Buy and
- * Sell buttons are replaced by a disabled countdown until it expires.
- */
- tradeCooldown?: ActiveTradeCooldown | null;
+
}
export const PortfolioHoldingRow: React.FC = ({
@@ -63,7 +57,6 @@ export const PortfolioHoldingRow: React.FC = ({
isReinvesting = false,
isRedeeming = false,
isNetworkMismatch = false,
- tradeCooldown = null,
}) => {
const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp);
const [isLocked, setIsLocked] = useState(initialRemaining > 0);
@@ -97,9 +90,6 @@ export const PortfolioHoldingRow: React.FC = ({
// #998 — the per-key trade cooldown disables both buy and sell for the
// window configured by the creator; the countdown drives the labels.
- const tradeCooldownActive = isActiveCooldown(tradeCooldown)
- ? (tradeCooldown as ActiveTradeCooldown)
- : null;
const handleConfirmReinvest = async () => {
if (!onReinvest) return;
diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx
index 1a176e2a..7fa3fc1c 100644
--- a/src/components/common/TradeDialog.tsx
+++ b/src/components/common/TradeDialog.tsx
@@ -70,11 +70,14 @@ import {
computeSlippageBounds,
type SlippageBounds,
} from '@/utils/slippageTolerance.utils';
-import type { KeyConfig } from '@/services/course.service';
-import SpreadIndicator from '@/components/common/SpreadIndicator';
-import { useSlippageTolerancePreference } from '@/hooks/useSlippageTolerancePreference';
import { useGlobalPause } from '@/hooks/useGlobalPause';
import { UnavailableAction } from '@/components/ui/unavailable-action';
+import { useContractPausedStore, selectIsPaused } from '@/hooks/useContractPausedStore';
+import { useAllowanceStore, selectNeedsApproval } from '@/hooks/useAllowanceStore';
+import { useShallow } from 'zustand/react/shallow';
+import { useHoldingCapStore, isAtHoldingCap, remainingCapacity } from '@/hooks/useHoldingCapStore';
+
+export type TradeSide = 'buy' | 'sell' | 'stake' | 'transfer';
/**
* Merges contract-returned dynamic fee rates over the dialog's configured
@@ -1076,18 +1079,10 @@ const TradeDialog: React.FC = ({
{
- if (impactWarningActive && !impactAcknowledged) return;
- if (requireConfirmation) {
- setConfirmationOpen(true);
- } else {
- onConfirm(parsedAmount, pricePreview, slippageBounds);
- }
- }}
+ onClick={handleConfirm}
disabled={
!amountValid ||
isSubmitting ||
- (impactWarningActive && !impactAcknowledged) ||
(side === 'buy' && (previewLoading || previewError != null)) ||
paused
}