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
5 changes: 5 additions & 0 deletions contracts/token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions react/src/components/PausedBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<bcForgeClient, "isPaused">;
/** Background refresh interval in milliseconds. @default 30000 */
pollIntervalMs?: number;
}

/** Persistent contract maintenance warning, mounted for all provider screens. */
export const PausedBanner: React.FC<PausedBannerProps> = ({
client,
pollIntervalMs = 30_000,
}) => {
const [paused, setPaused] = useState<boolean | null>(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 (
<div data-testid="paused-banner" style={{ padding: "0 16px" }}>
<Alert variant="danger" title="Contract paused">
Contract is currently paused for maintenance; writes are disabled.
</Alert>
</div>
);
};
37 changes: 34 additions & 3 deletions react/src/components/ProductComponents.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +16,7 @@
import { TokenCard } from "./TokenCard";
import { TransactionToast } from "./TransactionToast";
import { VaultDepositWidget } from "./VaultDepositWidget";
import { PausedBanner } from "./PausedBanner";

const mockCalculateApy = jest.fn();
const mockBcForgeClient = jest.fn();
Expand Down Expand Up @@ -371,20 +378,27 @@

it("routes execution through onExecute when quorum is reached", async () => {
const onExecute = jest.fn(async () => {});
const onExecuted = jest.fn(async () => {});
render(
<ProposalVotingPanel
proposals={[{ id: 7, approvals: 3, quorum: 3 }]}
onVote={jest.fn()}
onExecute={onExecute}
onExecuted={onExecuted}
/>,
);

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(<ProposalVotingPanel proposals={proposals} />);
render(
<ProposalVotingPanel
proposals={[...proposals, { id: 3, approvals: 3, quorum: 3 }]}
/>,
);

expect(screen.getByRole("alert")).toHaveTextContent("SDK unavailable");
screen
Expand All @@ -395,6 +409,23 @@
.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(<PausedBanner client={client as never} pollIntervalMs={0} />);

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(<ProposalVotingPanel proposals={[]} emptyMessage="All clear" />);
expect(screen.getByTestId("proposal-voting-empty")).toHaveTextContent(
Expand Down
5 changes: 5 additions & 0 deletions react/src/components/ProposalVotingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface ProposalVotingPanelProps {
onVote?: (proposalId: bigint) => void | Promise<void>;
/** Routes the execution yourself instead of calling `executeProposal`. */
onExecute?: (proposalId: bigint) => void | Promise<void>;
/** Refreshes parent proposal/RBAC data after a successful execution. */
onExecuted?: (proposalId: bigint) => void | Promise<void>;
/** Message shown when `proposals` is empty. @default 'No pending proposals.' */
emptyMessage?: string;
/** Optional container style. */
Expand Down Expand Up @@ -151,6 +153,7 @@ export const ProposalVotingPanel: React.FC<ProposalVotingPanelProps> = ({
showExecute = true,
onVote,
onExecute,
onExecuted,
emptyMessage = "No pending proposals.",
style,
className,
Expand Down Expand Up @@ -246,6 +249,7 @@ export const ProposalVotingPanel: React.FC<ProposalVotingPanelProps> = ({
try {
await onExecute(bigintId);
setToast({ status: "success" });
await onExecuted?.(bigintId);
} catch (err) {
setToast({
status: "error",
Expand All @@ -266,6 +270,7 @@ export const ProposalVotingPanel: React.FC<ProposalVotingPanelProps> = ({
(hash) => `Execution failed with transaction hash: ${hash}`,
),
);
if (result.success) await onExecuted?.(bigintId);
} catch (err) {
setFailure({
id: String(proposal.id),
Expand Down
3 changes: 3 additions & 0 deletions react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,6 @@ export type {
ConnectionStatusBarProps,
LatencyTier,
} from "./ConnectionStatusBar";

export { PausedBanner } from "./PausedBanner";
export type { PausedBannerProps } from "./PausedBanner";
2 changes: 2 additions & 0 deletions react/src/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -400,6 +401,7 @@ export const BcForgeProvider: React.FC<BcForgeProviderProps> = ({
return (
<bcForgeContext.Provider value={value}>
<ProviderErrorBoundary fallback={errorFallback} onError={onError}>
<PausedBanner client={client} />
{children}
</ProviderErrorBoundary>
</bcForgeContext.Provider>
Expand Down
38 changes: 36 additions & 2 deletions react/src/screens/RolesScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 ?? "",
};
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading