From fb586b2bbe6a7751828fd1b725acdbad3f91891f Mon Sep 17 00:00:00 2001 From: Samuel Ojetunde Date: Mon, 19 Oct 2026 18:19:02 +0000 Subject: [PATCH] feat(react): implement assigned phase 3 dashboard issues --- contracts/token/src/lib.rs | 5 + react/src/components/PausedBanner.tsx | 52 ++++++++ .../src/components/ProductComponents.test.tsx | 37 +++++- react/src/components/ProposalVotingPanel.tsx | 5 + react/src/components/index.ts | 3 + react/src/context.tsx | 2 + react/src/screens/RolesScreen.test.tsx | 38 +++++- react/src/screens/RolesScreen.tsx | 118 +++++++++++++++--- react/src/screens/VaultsScreen.test.tsx | 45 +++++++ react/src/screens/VaultsScreen.tsx | 41 ++++-- sdk/src/client.ts | 6 + sdk/src/vaultClient.ts | 15 ++- 12 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 react/src/components/PausedBanner.tsx diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 0d7844aa..6de3175e 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -912,6 +912,11 @@ impl BcForgeToken { Ok(()) } + /// Returns whether token operations are currently paused. + pub fn is_paused(env: Env) -> bool { + bc_forge_lifecycle::is_paused(&env) + } + /// Pauses the contract. /// /// @notice Pauses all token operations. Only the admin (or SuperAdmin/Pauser role holder) can call this function. diff --git a/react/src/components/PausedBanner.tsx b/react/src/components/PausedBanner.tsx new file mode 100644 index 00000000..5f2afdd4 --- /dev/null +++ b/react/src/components/PausedBanner.tsx @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +import React, { useCallback, useEffect, useState } from "react"; +import type { bcForgeClient } from "@bc-forge/sdk"; +import { Alert } from "./Alert"; + +export interface PausedBannerProps { + /** Token client used to read the on-chain pause state. */ + client: Pick; + /** Background refresh interval in milliseconds. @default 30000 */ + pollIntervalMs?: number; +} + +/** Persistent contract maintenance warning, mounted for all provider screens. */ +export const PausedBanner: React.FC = ({ + client, + pollIntervalMs = 30_000, +}) => { + const [paused, setPaused] = useState(null); + + const refresh = useCallback(async () => { + try { + setPaused(await client.isPaused()); + } catch { + return; + } + }, [client]); + + useEffect(() => { + void refresh(); + const interval = + pollIntervalMs > 0 + ? window.setInterval(() => void refresh(), pollIntervalMs) + : null; + window.addEventListener("focus", refresh); + window.addEventListener("bcforge-paused-state-change", refresh); + return () => { + if (interval !== null) window.clearInterval(interval); + window.removeEventListener("focus", refresh); + window.removeEventListener("bcforge-paused-state-change", refresh); + }; + }, [pollIntervalMs, refresh]); + + if (!paused) return null; + + return ( +
+ + Contract is currently paused for maintenance; writes are disabled. + +
+ ); +}; diff --git a/react/src/components/ProductComponents.test.tsx b/react/src/components/ProductComponents.test.tsx index e8e26913..b7dc2c56 100644 --- a/react/src/components/ProductComponents.test.tsx +++ b/react/src/components/ProductComponents.test.tsx @@ -1,7 +1,13 @@ -// SPDX-License-Identifier: MIT import "@testing-library/jest-dom"; import React from "react"; -import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { + render, + screen, + fireEvent, + waitFor, + within, + act, +} from "@testing-library/react"; import type { VaultClient } from "@bc-forge/sdk"; import { BcForgeProvider } from "../context"; @@ -10,6 +16,7 @@ import { ProposalVotingPanel } from "./ProposalVotingPanel"; import { TokenCard } from "./TokenCard"; import { TransactionToast } from "./TransactionToast"; import { VaultDepositWidget } from "./VaultDepositWidget"; +import { PausedBanner } from "./PausedBanner"; const mockCalculateApy = jest.fn(); const mockBcForgeClient = jest.fn(); @@ -371,20 +378,27 @@ describe("Product components (#950)", () => { it("routes execution through onExecute when quorum is reached", async () => { const onExecute = jest.fn(async () => {}); + const onExecuted = jest.fn(async () => {}); render( , ); fireEvent.click(screen.getByRole("button", { name: "Execute" })); await waitFor(() => expect(onExecute).toHaveBeenCalledWith(7n)); + await waitFor(() => expect(onExecuted).toHaveBeenCalledWith(7n)); }); it("keeps every control disabled with no provider and no handlers", () => { - render(); + render( + , + ); expect(screen.getByRole("alert")).toHaveTextContent("SDK unavailable"); screen @@ -395,6 +409,23 @@ describe("Product components (#950)", () => { .forEach((b: HTMLElement) => expect(b).toBeDisabled()); }); + it("shows the paused warning and refreshes when state changes", async () => { + let paused = true; + const client = { isPaused: jest.fn(async () => paused) }; + render(); + + expect(await screen.findByTestId("paused-banner")).toHaveTextContent( + "Contract is currently paused for maintenance; writes are disabled.", + ); + paused = false; + act(() => { + window.dispatchEvent(new Event("bcforge-paused-state-change")); + }); + await waitFor(() => + expect(screen.queryByTestId("paused-banner")).not.toBeInTheDocument(), + ); + }); + it("shows the empty message with no proposals", () => { render(); expect(screen.getByTestId("proposal-voting-empty")).toHaveTextContent( diff --git a/react/src/components/ProposalVotingPanel.tsx b/react/src/components/ProposalVotingPanel.tsx index 9cfe2797..9c562dce 100644 --- a/react/src/components/ProposalVotingPanel.tsx +++ b/react/src/components/ProposalVotingPanel.tsx @@ -46,6 +46,8 @@ export interface ProposalVotingPanelProps { onVote?: (proposalId: bigint) => void | Promise; /** Routes the execution yourself instead of calling `executeProposal`. */ onExecute?: (proposalId: bigint) => void | Promise; + /** Refreshes parent proposal/RBAC data after a successful execution. */ + onExecuted?: (proposalId: bigint) => void | Promise; /** Message shown when `proposals` is empty. @default 'No pending proposals.' */ emptyMessage?: string; /** Optional container style. */ @@ -151,6 +153,7 @@ export const ProposalVotingPanel: React.FC = ({ showExecute = true, onVote, onExecute, + onExecuted, emptyMessage = "No pending proposals.", style, className, @@ -246,6 +249,7 @@ export const ProposalVotingPanel: React.FC = ({ try { await onExecute(bigintId); setToast({ status: "success" }); + await onExecuted?.(bigintId); } catch (err) { setToast({ status: "error", @@ -266,6 +270,7 @@ export const ProposalVotingPanel: React.FC = ({ (hash) => `Execution failed with transaction hash: ${hash}`, ), ); + if (result.success) await onExecuted?.(bigintId); } catch (err) { setFailure({ id: String(proposal.id), diff --git a/react/src/components/index.ts b/react/src/components/index.ts index 2f2b519e..d7050193 100644 --- a/react/src/components/index.ts +++ b/react/src/components/index.ts @@ -159,3 +159,6 @@ export type { ConnectionStatusBarProps, LatencyTier, } from "./ConnectionStatusBar"; + +export { PausedBanner } from "./PausedBanner"; +export type { PausedBannerProps } from "./PausedBanner"; diff --git a/react/src/context.tsx b/react/src/context.tsx index 18e7bbed..cbb9c7e2 100644 --- a/react/src/context.tsx +++ b/react/src/context.tsx @@ -22,6 +22,7 @@ import { } from "@bc-forge/sdk"; import type { WalletAdapter } from "@bc-forge/sdk"; import { truncateMiddle } from "./utils"; +import { PausedBanner } from "./components/PausedBanner"; import { WALLET_LABELS, WALLET_NAMES, @@ -400,6 +401,7 @@ export const BcForgeProvider: React.FC = ({ return ( + {children} diff --git a/react/src/screens/RolesScreen.test.tsx b/react/src/screens/RolesScreen.test.tsx index 542502d1..fd9d3f4a 100644 --- a/react/src/screens/RolesScreen.test.tsx +++ b/react/src/screens/RolesScreen.test.tsx @@ -32,6 +32,9 @@ const mockGetRoleProposal = jest.fn(); const mockCreateRoleProposal = jest.fn(); const mockExecuteRoleProposal = jest.fn(); const mockApproveProposal = jest.fn(); +const mockIsPaused = jest.fn(); +const mockPause = jest.fn(); +const mockUnpause = jest.fn(); jest.mock("@bc-forge/sdk", () => ({ Role: { @@ -65,6 +68,9 @@ jest.mock("@bc-forge/sdk", () => ({ executeRoleProposal: (...args: unknown[]) => mockExecuteRoleProposal(...args), approveProposal: (...args: unknown[]) => mockApproveProposal(...args), + isPaused: (...args: unknown[]) => mockIsPaused(...args), + pause: (...args: unknown[]) => mockPause(...args), + unpause: (...args: unknown[]) => mockUnpause(...args), setWalletAdapter: jest.fn(), getContractId: () => config?.contractId ?? "", }; @@ -184,6 +190,9 @@ describe("RolesScreen (#906, #1224, #1225, #1227, #1228, #1231, #1232, #1233)", success: true, hash: "APPROVEHASH1", }); + mockIsPaused.mockResolvedValue(false); + mockPause.mockResolvedValue({ success: true, hash: "PAUSEHASH1" }); + mockUnpause.mockResolvedValue({ success: true, hash: "UNPAUSEHASH1" }); }); const typeTarget = (value: string) => @@ -266,7 +275,11 @@ describe("RolesScreen (#906, #1224, #1225, #1227, #1228, #1231, #1232, #1233)", mockHasRole.mockResolvedValue(false); renderWithWallet(true); typeTarget(TARGET); - expect(await screen.findByTestId("not-admin")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( + "GBRP…YOXT", + ); + }); expect(screen.getByTestId("open-grant")).toBeDisabled(); }); @@ -332,7 +345,6 @@ describe("RolesScreen (#906, #1224, #1225, #1227, #1228, #1231, #1232, #1233)", it("explains each role permission in a badge tooltip and distinguishes granted from not granted", async () => { renderWithWallet(false); - typeTarget(TARGET); await waitFor(() => { @@ -557,6 +569,28 @@ describe("RolesScreen (#906, #1224, #1225, #1227, #1228, #1231, #1232, #1233)", expect(mockListLegacyProposals).toHaveBeenCalledTimes(2); expect(mockListLegacyProposals).toHaveBeenLastCalledWith(50n); }); + + it("requires confirmation before a Pauser can pause the contract", async () => { + const walletAddress = + "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT"; + mockHasRole.mockImplementation( + async (role: Role, address: string) => + role === Role.Pauser && address === walletAddress, + ); + mockIsPaused.mockResolvedValue(false); + renderWithWallet(true); + + const pauseButton = await screen.findByTestId("toggle-contract-pause"); + expect(screen.getByTestId("contract-pause-status")).toHaveTextContent( + "active", + ); + fireEvent.click(pauseButton); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: /^Pause$/ })); + + await waitFor(() => expect(mockPause).toHaveBeenCalledTimes(1)); + expect(await screen.findByRole("status")).toHaveTextContent("PAUSEHASH1"); + }); }); describe("exportRbacReport", () => { diff --git a/react/src/screens/RolesScreen.tsx b/react/src/screens/RolesScreen.tsx index f29c3d3a..6e468383 100644 --- a/react/src/screens/RolesScreen.tsx +++ b/react/src/screens/RolesScreen.tsx @@ -6,6 +6,7 @@ import { useBcForgeClient, truncatePublicKey } from "../context"; import { useProposalVote, useWallet } from "../hooks"; import { Alert } from "../components/Alert"; import { Badge } from "../components/Badge"; +import { ConfirmationDialog } from "../components/ConfirmationDialog"; import { theme } from "../theme"; import { GrantRoleModal } from "./GrantRoleModal"; import type { GrantableRole } from "./GrantRoleModal"; @@ -136,6 +137,10 @@ export const RolesScreen: React.FC = ({ const [proposals, setProposals] = useState([]); const [proposalRole, setProposalRole] = useState(Role.Minter); + const [canManagePause, setCanManagePause] = useState(false); + const [paused, setPaused] = useState(false); + const [confirmPause, setConfirmPause] = useState(false); + const trimmedTarget = target.trim(); const targetValid = isValidStellarAddress(trimmedTarget); const targetInvalid = trimmedTarget.length > 0 && !targetValid; @@ -221,6 +226,35 @@ export const RolesScreen: React.FC = ({ }; }, [client, connected, publicKey, reloadKey]); + useEffect(() => { + if (!connected || !publicKey) { + setCanManagePause(false); + return; + } + const walletAddress = publicKey; + let cancelled = false; + async function loadPauseAccess() { + try { + const [currentState, isPauser, isAdmin] = await Promise.all([ + client.isPaused(), + client.hasRole(Role.Pauser, walletAddress), + client.hasRole(Role.Admin, walletAddress), + ]); + if (!cancelled) { + setPaused(currentState); + setCanManagePause(isPauser || isAdmin); + } + } catch (err) { + if (!cancelled) + setError(err instanceof Error ? err.message : String(err)); + } + } + void loadPauseAccess(); + return () => { + cancelled = true; + }; + }, [client, connected, publicKey, reloadKey]); + useEffect(() => { let cancelled = false; async function loadProposals() { @@ -375,6 +409,32 @@ export const RolesScreen: React.FC = ({ } }; + const togglePause = async () => { + setError(null); + setSuccess(null); + setBusy(true); + try { + const result = paused ? await client.unpause() : await client.pause(); + if (result.success) { + setPaused(!paused); + setSuccess( + `${paused ? "Unpause" : "Pause"} succeeded. Tx: ${result.hash}`, + ); + setConfirmPause(false); + setReloadKey((key) => key + 1); + window.dispatchEvent(new Event("bcforge-paused-state-change")); + } else { + setError( + `${paused ? "Unpause" : "Pause"} failed with transaction hash: ${result.hash}`, + ); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + const callerIsAdmin = connected && !!publicKey && adminAccount === publicKey; const canAct = connected && callerIsAdmin && targetValid; const heldCount = @@ -439,22 +499,22 @@ export const RolesScreen: React.FC = ({ htmlFor="export-format-select" style={{ fontSize: "13px", color: theme.neutralText }} > - Export format + Format + + ) : null} + + setConfirmPause(false)} + onConfirm={() => void togglePause()} + title={paused ? "Unpause contract?" : "Pause contract?"} + description={ + paused + ? "Resuming the contract will enable token writes again." + : "Pausing the contract will disable token writes for users." + } + confirmLabel={paused ? "Unpause" : "Pause"} + busy={busy} + busyLabel={paused ? "Unpausing…" : "Pausing…"} + variant="danger" + /> + {!connected && ( Connect a wallet to grant or revoke roles. diff --git a/react/src/screens/VaultsScreen.test.tsx b/react/src/screens/VaultsScreen.test.tsx index ce0b9ebb..67867e8f 100644 --- a/react/src/screens/VaultsScreen.test.tsx +++ b/react/src/screens/VaultsScreen.test.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT import "@testing-library/jest-dom"; +import React from "react"; import { act, render, @@ -28,6 +29,7 @@ describe("VaultsScreen", () => { getTotalSupply: jest.fn().mockResolvedValue(500000n), calculateSharePrice: jest.fn().mockResolvedValue(2n), getShareBalance: jest.fn().mockResolvedValue(100n), + getUnderlyingBalance: jest.fn().mockResolvedValue(1000n), deposit: jest.fn().mockResolvedValue({ success: true, hash: "0xabc123deposit", @@ -120,6 +122,10 @@ describe("VaultsScreen", () => { expect(mockClient.getShareBalance).toHaveBeenCalledWith( mockAdapter.publicKey, ); + expect(mockClient.getUnderlyingBalance).toHaveBeenCalledWith( + mockAdapter.publicKey, + ); + expect(screen.getByTestId("token-balance")).toHaveTextContent("1000"); }); it("refreshes vault metrics every 30 seconds", async () => { @@ -136,6 +142,37 @@ describe("VaultsScreen", () => { jest.useRealTimers(); }); + it("disables deposits above the connected wallet token balance", async () => { + (calculateApy as jest.Mock).mockResolvedValue(null); + render(); + + const input = screen.getByPlaceholderText(/enter positive integer amount/i); + const submit = screen.getByRole("button", { name: /^deposit$/i }); + await waitFor(() => + expect(screen.getByTestId("token-balance")).toHaveTextContent("1000"), + ); + + fireEvent.change(input, { target: { value: "1001" } }); + expect(submit).toBeDisabled(); + fireEvent.change(input, { target: { value: "1000" } }); + expect(submit).toBeEnabled(); + }); + + it("reports token balance lookup failures and keeps deposits disabled", async () => { + mockClient.getUnderlyingBalance.mockRejectedValueOnce( + new Error("RPC unavailable"), + ); + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Unable to load token balance: RPC unavailable", + ); + expect(screen.getByTestId("token-balance")).toHaveTextContent( + "Unavailable", + ); + expect(screen.getByRole("button", { name: /^deposit$/i })).toBeDisabled(); + }); + it("disables deposit and withdraw forms when no wallet is connected", async () => { (calculateApy as jest.Mock).mockResolvedValue(null); @@ -276,6 +313,10 @@ describe("VaultsScreen", () => { it("shows an error Alert when trying to deposit non-positive or invalid amounts", async () => { render(); + await waitFor(() => + expect(screen.getByTestId("token-balance")).toHaveTextContent("1000"), + ); + const depositInput = screen.getByPlaceholderText( /enter positive integer amount/i, ); @@ -311,6 +352,10 @@ describe("VaultsScreen", () => { render(); + await waitFor(() => + expect(screen.getByTestId("token-balance")).toHaveTextContent("1000"), + ); + const depositInput = screen.getByPlaceholderText( /enter positive integer amount/i, ); diff --git a/react/src/screens/VaultsScreen.tsx b/react/src/screens/VaultsScreen.tsx index 2e67f212..977b7640 100644 --- a/react/src/screens/VaultsScreen.tsx +++ b/react/src/screens/VaultsScreen.tsx @@ -71,6 +71,7 @@ export const VaultsScreen: React.FC = ({ const [totalShares, setTotalShares] = useState(null); const [sharePrice, setSharePrice] = useState(null); const [shareBalance, setShareBalance] = useState(null); + const [tokenBalance, setTokenBalance] = useState(null); const [apyResult, setApyResult] = useState(null); const [depositAmount, setDepositAmount] = useState(""); @@ -105,6 +106,11 @@ export const VaultsScreen: React.FC = ({ const parsedDepositAmount = isPositiveInteger(depositAmount) ? BigInt(depositAmount.trim()) : null; + const depositDisabled = + !isConnected || + isSubmitting || + tokenBalance === null || + (parsedDepositAmount !== null && parsedDepositAmount > tokenBalance); const estimatedDepositShares = parsedDepositAmount === null ? null @@ -162,12 +168,19 @@ export const VaultsScreen: React.FC = ({ setSharePrice(price); if (connectedAddress) { - const balance = await activeClient - .getShareBalance(connectedAddress) - .catch(() => 0n); - setShareBalance(balance); + const [shares, tokens] = await Promise.all([ + activeClient.getShareBalance(connectedAddress).catch(() => 0n), + activeClient.getUnderlyingBalance(connectedAddress).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + setError(`Unable to load token balance: ${message}`); + return null; + }), + ]); + setShareBalance(shares); + setTokenBalance(tokens); } else { setShareBalance(null); + setTokenBalance(null); } if (activeRpcUrl && activePassphrase && activeContractId) { @@ -675,18 +688,30 @@ export const VaultsScreen: React.FC = ({ ? "Enter an amount to preview shares." : `Estimated shares: ${estimatedDepositShares.toString()}`} +
+ Token balance:{" "} + {tokenBalance === null + ? isConnected + ? isLoadingMetrics + ? "Loading..." + : "Unavailable" + : "Disconnected" + : tokenBalance.toString()} +