From e6bce0699d10f05a42111804094bbc0975715166 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 7 Jul 2026 13:52:08 +0100 Subject: [PATCH 1/9] feat(explorer): Add confirmation-modal to warn about irreversible actions. --- .../components/ConfirmationModal.stories.tsx | 61 ++++++++++++++++ .../src/components/ConfirmationModal.tsx | 69 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 apps/explorer/src/components/ConfirmationModal.stories.tsx create mode 100644 apps/explorer/src/components/ConfirmationModal.tsx diff --git a/apps/explorer/src/components/ConfirmationModal.stories.tsx b/apps/explorer/src/components/ConfirmationModal.stories.tsx new file mode 100644 index 00000000..81454f3d --- /dev/null +++ b/apps/explorer/src/components/ConfirmationModal.stories.tsx @@ -0,0 +1,61 @@ +import { Button, Group, Stack, Text } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { action } from "storybook/actions"; +import { ConfirmationModal } from "./ConfirmationModal"; + +const meta = { + title: "Components/General/ConfirmationModal", + component: ConfirmationModal, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +type Props = Parameters[0]; + +const Wrapper = (props: Props) => { + const [opened, handlers] = useDisclosure(false); + const [resultTxt, setResultTxt] = useState(null); + + return ( + + { + setResultTxt("Not confirmed"); + handlers.close(); + }} + onConfirm={() => { + setResultTxt("Confirmed"); + handlers.close(); + }} + /> + + + Result: {resultTxt ?? "No interaction yet"} + + + ); +}; + +/** + * The standard story for the ConfirmationModal component. + */ +export const Default: Story = { + args: { + isOpened: false, + title: "Confirmation before proceeding", + text: "Are you sure you want to proceed?", + onClose: action("Modal Closed"), + onConfirm: action("Confirmed"), + }, + render: Wrapper, +}; diff --git a/apps/explorer/src/components/ConfirmationModal.tsx b/apps/explorer/src/components/ConfirmationModal.tsx new file mode 100644 index 00000000..c2be252c --- /dev/null +++ b/apps/explorer/src/components/ConfirmationModal.tsx @@ -0,0 +1,69 @@ +import { Button, Group, Modal, Notification, Stack, Text } from "@mantine/core"; +import { isValidElement, type FC, type ReactNode } from "react"; +import { content } from "../content"; + +export interface ConfirmationModalProps { + isOpened: boolean; + title?: ReactNode; + text: ReactNode; + onClose: () => void; + onConfirm: () => void; +} + +/** + * A modal component that displays a confirmation dialog with customizable text and actions. + * It can be used to confirm irreversible actions or any other user decisions. + * @param props + * @returns + */ +export const ConfirmationModal: FC = (props) => { + const { isOpened, text, onClose, onConfirm, title } = props; + const isElement = isValidElement(text); + + return ( + { + evt.stopPropagation(); + }} + closeOnClickOutside + title={title ?? Before you proceed...} + centered + > + + {isElement ? ( + text + ) : ( + + {text} + + )} + + + + + + + + ); +}; From f9f39c1f763d72b8d706c91bf35ed66f06f01435 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 7 Jul 2026 14:44:28 +0100 Subject: [PATCH 2/9] feat(explorer): Add content/copy objects to be reused in the application. --- .../explorer/src/content/forecloseMessages.ts | 23 +++++++++++++++++ apps/explorer/src/content/globalMessages.ts | 25 +++++++++++++++++++ apps/explorer/src/content/index.ts | 7 ++++++ 3 files changed, 55 insertions(+) create mode 100644 apps/explorer/src/content/forecloseMessages.ts create mode 100644 apps/explorer/src/content/globalMessages.ts create mode 100644 apps/explorer/src/content/index.ts diff --git a/apps/explorer/src/content/forecloseMessages.ts b/apps/explorer/src/content/forecloseMessages.ts new file mode 100644 index 00000000..917febe3 --- /dev/null +++ b/apps/explorer/src/content/forecloseMessages.ts @@ -0,0 +1,23 @@ +/** + * Content/copy for foreclose related messages. + */ +export const forecloseMessages = { + confirmation: { + title: "Are you sure you want to foreclose?", + description: + "It is an irreversible action that prevents any further deposits or inputs to the application.", + }, + sendTooltip: + "The application is foreclosed. You can no longer send transactions.", + + foreclosingWarning: "Foreclosing an application is a permanent action.", + generic: { + txSuccess: "Application foreclosed successfully!", + }, + feedback: { + applicationForeclosed: + "The application is already foreclosed. You cannot foreclose it again.", + }, + forecloseTxt: "Foreclose", + foreclosedTxt: "Foreclosed", +} as const; diff --git a/apps/explorer/src/content/globalMessages.ts b/apps/explorer/src/content/globalMessages.ts new file mode 100644 index 00000000..31beef91 --- /dev/null +++ b/apps/explorer/src/content/globalMessages.ts @@ -0,0 +1,25 @@ +/** + * Generic messages that can be used across the application. + */ +export const globalMessages = { + alert: { + error: "Error!", + success: "Success!", + warning: "Warning!", + info: "Info!", + reminder: "Remember!", + }, + error: { + generic: "Something went wrong.", + }, + btn: { + send: "Send", + cancel: "Cancel", + confirm: "Confirm", + close: "Close", + connectWallet: "Connect Wallet", + }, + application: { + address: "Application Address", + }, +} as const; diff --git a/apps/explorer/src/content/index.ts b/apps/explorer/src/content/index.ts new file mode 100644 index 00000000..d2c88517 --- /dev/null +++ b/apps/explorer/src/content/index.ts @@ -0,0 +1,7 @@ +import { forecloseMessages } from "./forecloseMessages"; +import { globalMessages } from "./globalMessages"; + +export const content = { + foreclose: forecloseMessages, + global: globalMessages, +} as const; From 0f7372004507706416306718557371475ddeec0d Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 7 Jul 2026 14:52:49 +0100 Subject: [PATCH 3/9] feat(explorer): Add Foreclose to the transactions feature --- .../transactions/DepositFormTypes.ts | 4 +- .../transactions/ForecloseForm/index.tsx | 169 ++++++++++++ .../transactions/TransactionDetails.tsx | 12 +- .../transactions/TransactionProgress.tsx | 29 ++- .../transactions/ForecloseForm/index.test.tsx | 246 ++++++++++++++++++ apps/explorer/test/test-utils.tsx | 28 +- 6 files changed, 471 insertions(+), 17 deletions(-) create mode 100644 apps/explorer/src/components/transactions/ForecloseForm/index.tsx create mode 100644 apps/explorer/test/components/transactions/ForecloseForm/index.test.tsx diff --git a/apps/explorer/src/components/transactions/DepositFormTypes.ts b/apps/explorer/src/components/transactions/DepositFormTypes.ts index ece8d581..aa7da120 100644 --- a/apps/explorer/src/components/transactions/DepositFormTypes.ts +++ b/apps/explorer/src/components/transactions/DepositFormTypes.ts @@ -6,9 +6,11 @@ export type TRANSACTION_TYPE = | "ERC-721" | "ETHER" | "RAW" - | "ADDRESS-RELAY"; + | "ADDRESS-RELAY" + | "FORECLOSE"; export type TransactionFormSuccessData = { receipt: UseWaitForTransactionReceiptReturnType["data"]; type: TRANSACTION_TYPE; + message?: string; }; diff --git a/apps/explorer/src/components/transactions/ForecloseForm/index.tsx b/apps/explorer/src/components/transactions/ForecloseForm/index.tsx new file mode 100644 index 00000000..b72170f2 --- /dev/null +++ b/apps/explorer/src/components/transactions/ForecloseForm/index.tsx @@ -0,0 +1,169 @@ +import type { Application } from "@cartesi/viem"; +import { + useReadApplicationIsForeclosed, + useSimulateApplicationForeclose, + useWriteApplicationForeclose, +} from "@cartesi/wagmi"; +import { + Button, + Collapse, + Group, + Notification, + Stack, + Text, +} from "@mantine/core"; +import { isNil, isNotNil } from "ramda"; +import { useEffect, useMemo, type FC } from "react"; +import { TbLockFilled } from "react-icons/tb"; +import { useAccount, useWaitForTransactionReceipt } from "wagmi"; +import { content } from "../../../content"; +import type { TransactionFormSuccessData } from "../DepositFormTypes"; +import TransactionDetails from "../TransactionDetails"; +import { TransactionProgress } from "../TransactionProgress"; + +interface ForecloseFormProps { + application: Application; + onSuccess: (receipt: TransactionFormSuccessData) => void; +} + +export const ForecloseForm: FC = (props) => { + const { application, onSuccess } = props; + + // connected account + const { address } = useAccount(); + + const { + data: isApplicationForeclosed, + isLoading: isCheckingIfForeclosed, + refetch: recheckIfForeclosed, + } = useReadApplicationIsForeclosed({ + address: application.applicationAddress, + query: { + enabled: isNotNil(application.applicationAddress), + }, + }); + + const prepare = useSimulateApplicationForeclose({ + address: application.applicationAddress, + query: { + enabled: + isNotNil(application.applicationAddress) && + isNotNil(address) && + isNotNil(isApplicationForeclosed) && + isApplicationForeclosed === false, + }, + }); + + const execute = useWriteApplicationForeclose(); + + const wait = useWaitForTransactionReceipt({ + hash: execute.data, + }); + + const canSubmit = + !isCheckingIfForeclosed && + isApplicationForeclosed === false && + isNotNil(address) && + !prepare.isLoading && + isNotNil(prepare.data?.request) && + isNil(prepare.error); + const loading = execute.isPending || wait.isLoading; + + const details = useMemo( + () => [ + { + legend: content.global.application.address, + text: application.applicationAddress, + }, + ], + [application.applicationAddress], + ); + + const executeReset = execute.reset; + + useEffect(() => { + if (wait.isSuccess) { + onSuccess({ + receipt: wait.data, + type: "FORECLOSE", + message: content.foreclose.generic.txSuccess, + }); + recheckIfForeclosed(); + executeReset(); + } + }, [ + wait.isSuccess, + wait.data, + executeReset, + onSuccess, + recheckIfForeclosed, + ]); + + return ( +
{ + e.preventDefault(); + if (canSubmit) { + execute.writeContract(prepare.data!.request); + } + }} + data-testid="foreclose-form" + > + + + + + + {content.foreclose.foreclosingWarning} + + + + + + + + + + + +
+ ); +}; diff --git a/apps/explorer/src/components/transactions/TransactionDetails.tsx b/apps/explorer/src/components/transactions/TransactionDetails.tsx index 796cb173..2332ea40 100644 --- a/apps/explorer/src/components/transactions/TransactionDetails.tsx +++ b/apps/explorer/src/components/transactions/TransactionDetails.tsx @@ -15,10 +15,18 @@ type Detail = { text: ReactNode; legend: string }; type TransactionDetailsProps = { details: Detail[]; + /** + * Initial value for the details visibility. This is not a controlled prop. + * Follow mantine's convention for default values, using `default` prefix. + */ + defaultOpened?: boolean; }; -const TransactionDetails: FC = ({ details }) => { - const [showDetails, handlers] = useDisclosure(false); +const TransactionDetails: FC = ({ + details, + defaultOpened, +}) => { + const [showDetails, handlers] = useDisclosure(defaultOpened ?? false); return ( <> = ({ {shortErrorMessage || defaultErrorMessage} - {errorMessage && - (showError ? ( - - ) : ( - - ))} + + + + {showError ? ( + + ) : ( + + )} + + )} diff --git a/apps/explorer/test/components/transactions/ForecloseForm/index.test.tsx b/apps/explorer/test/components/transactions/ForecloseForm/index.test.tsx new file mode 100644 index 00000000..d5757fbf --- /dev/null +++ b/apps/explorer/test/components/transactions/ForecloseForm/index.test.tsx @@ -0,0 +1,246 @@ +import { + useReadApplicationIsForeclosed, + useSimulateApplicationForeclose, + useWriteApplicationForeclose, +} from "@cartesi/wagmi"; +import type { WriteContractErrorType } from "viem/actions"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAccount, useWaitForTransactionReceipt } from "wagmi"; +import type { SimulateContractErrorType } from "wagmi/actions"; +import { ForecloseForm } from "../../../../src/components/transactions/ForecloseForm"; +import { content } from "../../../../src/content"; +import { + createApplication, + fireEvent, + render, + screen, + waitFor, +} from "../../../test-utils"; + +const mocks = vi.hoisted(() => ({ + useReadApplicationIsForeclosed: vi.fn(), + useSimulateApplicationForeclose: vi.fn(), + useWriteApplicationForeclose: vi.fn(), + useAccount: vi.fn(), + useWaitForTransactionReceipt: vi.fn(), +})); + +vi.mock("@cartesi/wagmi", () => ({ + useReadApplicationIsForeclosed: mocks.useReadApplicationIsForeclosed, + useSimulateApplicationForeclose: mocks.useSimulateApplicationForeclose, + useWriteApplicationForeclose: mocks.useWriteApplicationForeclose, +})); + +vi.mock("wagmi", () => ({ + useAccount: mocks.useAccount, + useWaitForTransactionReceipt: mocks.useWaitForTransactionReceipt, +})); + +const mockUseReadIsForeclosed = vi.mocked(useReadApplicationIsForeclosed, { + partial: true, +}); +const mockUseSimulateForeclose = vi.mocked(useSimulateApplicationForeclose, { + partial: true, +}); +const mockUseWriteForeclose = vi.mocked(useWriteApplicationForeclose, { + partial: true, +}); +const mockUseAccount = vi.mocked(useAccount, { partial: true }); +const mockUseWaitForTx = vi.mocked(useWaitForTransactionReceipt, { + partial: true, +}); + +const mockApplication = createApplication(); + +const setupCommonMocks = () => { + const refetch = vi.fn(); + const writeContract = vi.fn(); + const reset = vi.fn(); + + mockUseAccount.mockReturnValue({ address: "0xaddress" }); + + mockUseReadIsForeclosed.mockReturnValue({ + data: false, + isLoading: false, + refetch, + }); + + mockUseSimulateForeclose.mockReturnValue({ + data: { request: {} }, + isLoading: false, + error: null, + }); + + mockUseWriteForeclose.mockReturnValue({ + writeContract, + data: undefined, + isPending: false, + isSuccess: false, + isError: false, + reset, + }); + + mockUseWaitForTx.mockReturnValue({ + isLoading: false, + isSuccess: false, + }); + + return { refetch, writeContract, reset }; +}; + +describe("ForecloseForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should disable the Foreclose button when the application is already foreclosed and change the button text to 'Foreclosed'", () => { + setupCommonMocks(); + mockUseReadIsForeclosed.mockReturnValue({ + data: true, + isLoading: false, + refetch: vi.fn(), + }); + + render( + , + ); + + expect( + screen.getByRole("button", { + name: content.foreclose.foreclosedTxt, + }), + ).toBeDisabled(); + }); + + it("should disable the Foreclose button when the simulate call fails", async () => { + setupCommonMocks(); + mockUseSimulateForeclose.mockReturnValue({ + data: null, + isLoading: false, + isError: true, + error: new Error( + "Simulate error while foreclosing", + ) as SimulateContractErrorType, + }); + + render( + , + ); + + await waitFor(() => + expect(screen.getByTestId("foreclose-progress")).toBeVisible(), + ); + + expect(screen.getByText("Transaction failed")).toBeVisible(); + + fireEvent.click( + screen.getByTestId("transaction-progress-error-toggle"), + ); + + await waitFor(() => + expect( + screen.getByText("Simulate error while foreclosing"), + ).toBeVisible(), + ); + + expect( + screen.getByRole("button", { + name: content.foreclose.forecloseTxt, + }), + ).toBeDisabled(); + }); + + it("should display the error message when the execute call fails", () => { + const { writeContract } = setupCommonMocks(); + mockUseWriteForeclose.mockReturnValue({ + writeContract, + data: undefined, + isPending: false, + isSuccess: false, + isError: true, + error: { + message: "Execution reverted with message xpto", + } as WriteContractErrorType, + reset: vi.fn(), + }); + + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { + name: content.foreclose.forecloseTxt, + }), + ); + + expect(writeContract).toHaveBeenCalled(); + // TransactionProgress renders the error message in multiple places + expect( + screen.getAllByText("Execution reverted with message xpto")[0], + ).toBeInTheDocument(); + }); + + it("should call onSuccess with the correct data when the foreclose transaction succeeds", () => { + const { refetch, reset } = setupCommonMocks(); + const receipt = { status: "success" }; + + mockUseWriteForeclose.mockReturnValue({ + writeContract: vi.fn(), + data: "0x12345678901234567890123456789012345678901234567890123456789012345", + isPending: false, + isSuccess: true, + isError: false, + reset, + }); + + mockUseWaitForTx.mockReturnValue({ + data: receipt, + isLoading: false, + isSuccess: true, + }); + + const onSuccess = vi.fn(); + + render( + , + ); + + expect(onSuccess).toHaveBeenCalledWith({ + receipt, + type: "FORECLOSE", + message: content.foreclose.generic.txSuccess, + }); + expect(refetch).toHaveBeenCalled(); + expect(reset).toHaveBeenCalled(); + }); + + it("should display the application address upon opening the form", () => { + setupCommonMocks(); + + render( + , + ); + + expect(screen.getByText("Application Address")).toBeVisible(); + expect( + screen.getByText(mockApplication.applicationAddress), + ).toBeVisible(); + }); + + it("should display the foreclosure info warning message", () => { + setupCommonMocks(); + + render( + , + ); + + expect(screen.getByText(content.global.alert.reminder)).toBeVisible(); + expect( + screen.getByText(content.foreclose.foreclosingWarning), + ).toBeVisible(); + }); +}); diff --git a/apps/explorer/test/test-utils.tsx b/apps/explorer/test/test-utils.tsx index d3dada76..22da1715 100644 --- a/apps/explorer/test/test-utils.tsx +++ b/apps/explorer/test/test-utils.tsx @@ -1,10 +1,16 @@ /* eslint-disable react-refresh/only-export-components */ +import type { Application } from "@cartesi/viem"; import { MantineProvider } from "@mantine/core"; import { render, type RenderOptions } from "@testing-library/react"; +import { zeroHash } from "viem"; import theme from "../src/providers/theme"; const WithProviders = ({ children }: { children: React.ReactNode }) => { - return {children}; + return ( + + {children} + + ); }; const customRender = ( @@ -14,3 +20,23 @@ const customRender = ( export * from "@testing-library/react"; export { customRender as render }; + +type ApplicationOverrides = Partial< + Pick< + Application, + | "applicationAddress" + | "forecloseBlock" + | "forecloseTransaction" + | "name" + | "withdrawalConfig" + > +>; + +export const createApplication = (overrides?: ApplicationOverrides) => + ({ + name: "My App", + applicationAddress: "0x1234567890abcdef1234567890abcdef12345678", + forecloseBlock: 0n, + forecloseTransaction: zeroHash, + ...overrides, + }) as Application; From 52df0defc8a4357a0b155d9c5c5b9d541203362b Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 7 Jul 2026 14:58:25 +0100 Subject: [PATCH 4/9] feat(explorer): Add Foreclose as an option to the send-menu component. --- .../src/components/application/utils.ts | 23 +- .../src/components/send/SendContexts.tsx | 9 +- .../explorer/src/components/send/SendMenu.tsx | 100 +++++- .../src/components/send/SendModal.tsx | 24 +- .../src/components/send/SendProvider.tsx | 7 + apps/explorer/src/components/send/hooks.tsx | 5 + .../test/components/send/SendMenu.test.tsx | 286 +++++++++++++----- 7 files changed, 353 insertions(+), 101 deletions(-) diff --git a/apps/explorer/src/components/application/utils.ts b/apps/explorer/src/components/application/utils.ts index 0208f886..13926010 100644 --- a/apps/explorer/src/components/application/utils.ts +++ b/apps/explorer/src/components/application/utils.ts @@ -1,6 +1,6 @@ import type { Application } from "@cartesi/viem"; import { isNil } from "ramda"; -import { zeroHash, type Hash } from "viem"; +import { isAddressEqual, zeroHash, type Address, type Hash } from "viem"; /** * @@ -23,3 +23,24 @@ const hasForecloseTransaction = (hash: Hash | null | undefined) => { } return true; }; + +/** + * Check if the given address is the guardian configured in the withdrawal setup of the application. + * @param application The application object containing the withdrawal configuration. + * @param address The address to check against the guardian address in the application's withdrawal configuration. + * @returns {boolean} - Returns true if the address is the guardian, otherwise false. + */ +export const isGuardian = (application: Application, address?: Address) => { + if (!address || !application) return false; + + if ( + isNil(application.withdrawalConfig) || + isNil(application.withdrawalConfig.guardian) + ) { + return false; + } + + const guardian = application.withdrawalConfig.guardian; + + return isAddressEqual(address, guardian); +}; diff --git a/apps/explorer/src/components/send/SendContexts.tsx b/apps/explorer/src/components/send/SendContexts.tsx index 4589820b..bb9ed0d3 100644 --- a/apps/explorer/src/components/send/SendContexts.tsx +++ b/apps/explorer/src/components/send/SendContexts.tsx @@ -3,8 +3,7 @@ import type { Application } from "@cartesi/viem"; import { createContext, type ActionDispatch } from "react"; import type { DbSpecification } from "../specification/types"; -// Actions -type CloseModal = { type: "close_modal" }; +export type ForecloseType = "foreclose"; export type InputType = "generic_input"; export type DepositType = | "deposit_eth" @@ -13,13 +12,15 @@ export type DepositType = | "deposit_erc1155Single" | "deposit_erc1155Batch"; -export type TransactionType = DepositType | InputType; +export type TransactionType = DepositType | InputType | ForecloseType; +type CloseModal = { type: "close_modal" }; type Deposit = { type: DepositType; payload: { application: Application } }; type GenericInput = { type: InputType; payload: { application: Application; specifications: DbSpecification[] }; }; +type Foreclose = { type: ForecloseType; payload: { application: Application } }; type SendState = { application: Application; @@ -28,7 +29,7 @@ type SendState = { timestamp: number; } | null; -export type SendAction = CloseModal | Deposit | GenericInput; +export type SendAction = CloseModal | Deposit | GenericInput | Foreclose; export type SendReducer = (state: SendState, action: SendAction) => SendState; export const SendStateContext = createContext(null); diff --git a/apps/explorer/src/components/send/SendMenu.tsx b/apps/explorer/src/components/send/SendMenu.tsx index 74c7cbe1..fc36f0c7 100644 --- a/apps/explorer/src/components/send/SendMenu.tsx +++ b/apps/explorer/src/components/send/SendMenu.tsx @@ -1,13 +1,28 @@ "use client"; import type { Application } from "@cartesi/viem"; -import { Button, Group, Menu, Text, Tooltip } from "@mantine/core"; +import { + Button, + Group, + Menu, + Text, + Tooltip, + useMantineTheme, +} from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { useChainModal, useConnectModal } from "@rainbow-me/rainbowkit"; -import { cond, filter, isNil } from "ramda"; -import type { FC } from "react"; -import { TbCoins, TbCurrencyEthereum, TbInbox, TbSend } from "react-icons/tb"; +import { cond, filter, isNil, isNotNil } from "ramda"; +import { useState, type FC, type ReactNode } from "react"; +import { + TbCoins, + TbCurrencyEthereum, + TbInbox, + TbLockFilled, + TbSend, +} from "react-icons/tb"; import { useAccount } from "wagmi"; -import { isForeclosed } from "../application/utils"; +import { content } from "../../content"; +import { isForeclosed, isGuardian } from "../application/utils"; +import { ConfirmationModal } from "../ConfirmationModal"; import { useSelectedNodeConnection } from "../connection/hooks"; import { useSpecification } from "../specification/hooks/useSpecification"; import type { DbSpecification } from "../specification/types"; @@ -21,8 +36,7 @@ const getMenuState = cond([ [ isForeclosed, () => ({ - tooltip: - "The application is foreclosed. You can no longer send transactions.", + tooltip: content.foreclose.sendTooltip, disabled: true, }), ], @@ -33,7 +47,13 @@ const SendMenu: FC = ({ application }) => { const selectedConnection = useSelectedNodeConnection(); const { listSpecifications } = useSpecification(); const [opened, handlers] = useDisclosure(false); + const theme = useMantineTheme(); const [openedTooltip, tooltipHandlers] = useDisclosure(false); + const [confirmationState, setConfirmationState] = useState void; + title?: string; + text: ReactNode; + }>(null); const actions = useSendAction(); const account = useAccount(); const connectModal = useConnectModal(); @@ -41,6 +61,7 @@ const SendMenu: FC = ({ application }) => { const needSwitchNetwork = account.isConnected && isNil(account.chain); const canSend = !needSwitchNetwork && account.isConnected; const menuState = getMenuState(application); + const isAppGuardian = isGuardian(application, account.address); if (selectedConnection?.type === "system_mock") return null; @@ -51,6 +72,19 @@ const SendMenu: FC = ({ application }) => { onClose={handlers.close} onDismiss={handlers.close} > + { + setConfirmationState(null); + }} + onConfirm={() => { + confirmationState?.action(); + setConfirmationState(null); + }} + /> + = ({ application }) => { } + leftSection={} onClick={(evt) => { evt.stopPropagation(); const specifications = filter( @@ -103,12 +137,14 @@ const SendMenu: FC = ({ application }) => { - + Deposits } + leftSection={ + + } onClick={(evt) => { evt.stopPropagation(); actions.depositEth(application); @@ -118,7 +154,9 @@ const SendMenu: FC = ({ application }) => { Ether } + leftSection={ + + } onClick={(evt) => { evt.stopPropagation(); actions.depositErc20(application); @@ -128,7 +166,9 @@ const SendMenu: FC = ({ application }) => { ERC-20 } + leftSection={ + + } onClick={(evt) => { evt.stopPropagation(); actions.depositErc721(application); @@ -138,7 +178,9 @@ const SendMenu: FC = ({ application }) => { ERC-721 } + leftSection={ + + } onClick={(evt) => { evt.stopPropagation(); actions.depositErc1155Single(application); @@ -148,7 +190,9 @@ const SendMenu: FC = ({ application }) => { ERC-1155 (Single) } + leftSection={ + + } onClick={(evt) => { evt.stopPropagation(); actions.depositErc1155Batch(application); @@ -157,6 +201,34 @@ const SendMenu: FC = ({ application }) => { > ERC-1155 (Batch) + + {isAppGuardian ? ( + <> + + + } + color="red" + onClick={(evt) => { + evt.stopPropagation(); + setConfirmationState({ + title: content.foreclose.confirmation.title, + text: content.foreclose.confirmation + .description, + action: () => { + actions.foreclose(application); + }, + }); + handlers.close(); + }} + > + + {content.foreclose.forecloseTxt} + + + + ) : null} ); diff --git a/apps/explorer/src/components/send/SendModal.tsx b/apps/explorer/src/components/send/SendModal.tsx index 489040c8..c6f6477e 100644 --- a/apps/explorer/src/components/send/SendModal.tsx +++ b/apps/explorer/src/components/send/SendModal.tsx @@ -11,6 +11,7 @@ import { ERC1155DepositForm } from "../transactions/ERC1155DepositForm"; import { ERC20DepositForm } from "../transactions/ERC20DepositForm"; import { ERC721DepositForm } from "../transactions/ERC721DepositForm"; import { EtherDepositForm } from "../transactions/EtherDepositForm"; +import { ForecloseForm } from "../transactions/ForecloseForm"; import { GenericInputForm, type GenericInputFormSpecification, @@ -25,6 +26,7 @@ const wordingPrefix: Record = { deposit_erc1155Single: "Send ERC-1155 to", deposit_erc20: "Send ERC-20 to", deposit_erc721: "Send ERC-721 to", + foreclose: "Foreclose application", }; type SendModalTitleProps = { @@ -57,7 +59,11 @@ const SendModal: FC = () => { const config = useConfig(); const onSuccess = useCallback( - ({ receipt, type }: TransactionFormSuccessData) => { + ({ + receipt, + type, + message: successMessage, + }: TransactionFormSuccessData) => { const message = receipt?.transactionHash ? { message: ( @@ -67,9 +73,12 @@ const SendModal: FC = () => { chain={config.chains[0]} /> ), - title: `${type} transaction completed`, + title: successMessage ?? `${type} transaction completed`, } - : { message: `${type} transaction completed.` }; + : { + message: + successMessage ?? `${type} transaction completed.`, + }; notifications.show({ withCloseButton: true, @@ -129,6 +138,15 @@ const SendModal: FC = () => { } onSuccess={onSuccess} /> + ) : state.transactionType === "foreclose" ? ( + { + onSuccess(data); + // Close the modal after foreclose is successful + actions.closeModal(); + }} + /> ) : null} ); diff --git a/apps/explorer/src/components/send/SendProvider.tsx b/apps/explorer/src/components/send/SendProvider.tsx index f52f9437..1b76ec43 100644 --- a/apps/explorer/src/components/send/SendProvider.tsx +++ b/apps/explorer/src/components/send/SendProvider.tsx @@ -26,6 +26,13 @@ const sendReducer: SendReducer = (state, action) => { specifications: action.payload.specifications, timestamp: Date.now(), }; + case "foreclose": + return { + transactionType: action.type, + application: action.payload.application, + specifications: [], + timestamp: Date.now(), + }; case "close_modal": return null; default: diff --git a/apps/explorer/src/components/send/hooks.tsx b/apps/explorer/src/components/send/hooks.tsx index 1e4a81f5..270040d2 100644 --- a/apps/explorer/src/components/send/hooks.tsx +++ b/apps/explorer/src/components/send/hooks.tsx @@ -28,6 +28,11 @@ export const useSendAction = () => { type: "deposit_erc1155Batch", payload: { application }, }), + foreclose: (application: Application) => + dispatch({ + type: "foreclose", + payload: { application }, + }), sendGenericInput: ( application: Application, specifications: DbSpecification[], diff --git a/apps/explorer/test/components/send/SendMenu.test.tsx b/apps/explorer/test/components/send/SendMenu.test.tsx index 29db9933..d4523da5 100644 --- a/apps/explorer/test/components/send/SendMenu.test.tsx +++ b/apps/explorer/test/components/send/SendMenu.test.tsx @@ -1,20 +1,24 @@ -import type { Application } from "@cartesi/viem"; -import { useDisclosure } from "@mantine/hooks"; import { useChainModal, useConnectModal, type Chain, } from "@rainbow-me/rainbowkit"; -import { zeroHash } from "viem"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Address } from "abitype"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { useAccount } from "wagmi"; import { mainnet } from "wagmi/chains"; import type { DbNodeConnectionConfig } from "../../../src/components/connection/types"; import SendMenu from "../../../src/components/send/SendMenu"; -import { fireEvent, render, screen } from "../../test-utils"; +import { content } from "../../../src/content"; +import { + createApplication, + fireEvent, + render, + screen, + waitFor, +} from "../../test-utils"; const mocks = vi.hoisted(() => ({ - useDisclosure: vi.fn(), useAccount: vi.fn(), useConnectModal: vi.fn(), useChainModal: vi.fn(), @@ -23,10 +27,6 @@ const mocks = vi.hoisted(() => ({ useSendAction: vi.fn(), })); -vi.mock("@mantine/hooks", () => ({ - useDisclosure: mocks.useDisclosure, -})); - vi.mock("wagmi", () => ({ useAccount: mocks.useAccount, })); @@ -48,67 +48,23 @@ vi.mock("../../../src/components/send/hooks", () => ({ useSendAction: mocks.useSendAction, })); -const mockUseDisclosure = vi.mocked(useDisclosure, { partial: true }); const mockUseAccount = vi.mocked(useAccount, { partial: true }); const mockUseConnectModal = vi.mocked(useConnectModal, { partial: true }); const mockUseChainModal = vi.mocked(useChainModal, { partial: true }); -type ApplicationOverrides = Partial< - Pick< - Application, - | "applicationAddress" - | "forecloseBlock" - | "forecloseTransaction" - | "name" - > ->; - -const createApplication = (overrides?: ApplicationOverrides) => - ({ - name: "My App", - applicationAddress: "0x1234567890abcdef1234567890abcdef12345678", - forecloseBlock: 0n, - forecloseTransaction: zeroHash, - ...overrides, - }) as Application; - -const setupDisclosures = ({ - menuOpened = false, - tooltipOpened = false, -}: { - menuOpened?: boolean; - tooltipOpened?: boolean; -}) => { - const menuHandlers = { - open: vi.fn(), - close: vi.fn(), - toggle: vi.fn(), - }; - const tooltipHandlers = { - open: vi.fn(), - close: vi.fn(), - toggle: vi.fn(), - }; - - mockUseDisclosure.mockReset(); - mockUseDisclosure - .mockImplementationOnce(() => [menuOpened, menuHandlers] as const) - .mockImplementationOnce( - () => [tooltipOpened, tooltipHandlers] as const, - ); - - return { menuHandlers, tooltipHandlers }; -}; +const guardianAddress = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as const; const setupCommonMocks = ({ selectedConnection, isConnected, chain, + address, listSpecifications = () => [], }: { selectedConnection: { type: DbNodeConnectionConfig["type"] } | null; isConnected: boolean; chain?: Chain; + address?: Address; listSpecifications?: () => Array<{ id: string; mode: string }>; }) => { const connectModal = { @@ -124,11 +80,13 @@ const setupCommonMocks = ({ depositErc721: vi.fn(), depositErc1155Single: vi.fn(), depositErc1155Batch: vi.fn(), + foreclose: vi.fn(), }; mockUseAccount.mockReturnValue({ isConnected, chain, + address, }); mockUseConnectModal.mockReturnValue(connectModal); @@ -150,27 +108,23 @@ describe("SendMenu", () => { vi.clearAllMocks(); }); - test("should render nothing when the selected node is a system mock", () => { + it("should render nothing when the selected node is a system mock", () => { setupCommonMocks({ selectedConnection: { type: "system_mock" }, isConnected: false, }); - setupDisclosures({}); - render(); expect(screen.queryByText("Send")).not.toBeInTheDocument(); }); - test("should open the connect modal when the user is disconnected", () => { + it("should open the connect modal when the user is disconnected", () => { const { connectModal } = setupCommonMocks({ selectedConnection: { type: "system" }, isConnected: false, }); - setupDisclosures({}); - render(); fireEvent.click(screen.getByText("Send")); @@ -178,15 +132,13 @@ describe("SendMenu", () => { expect(connectModal.openConnectModal).toHaveBeenCalledTimes(1); }); - test("should open the chain modal when the user needs to switch networks", () => { + it("should open the chain modal when the user needs to switch networks", () => { const { chainModal } = setupCommonMocks({ selectedConnection: { type: "system" }, isConnected: true, chain: undefined, }); - setupDisclosures({}); - render(); fireEvent.click(screen.getByText("Send")); @@ -194,9 +146,7 @@ describe("SendMenu", () => { expect(chainModal.openChainModal).toHaveBeenCalledTimes(1); }); - test("Should toggle the tooltip for foreclosed applications", () => { - const { tooltipHandlers } = setupDisclosures({}); - + it("should show the foreclosure tooltip when clicking Send on a foreclosed application", () => { setupCommonMocks({ selectedConnection: { type: "system" }, isConnected: true, @@ -215,11 +165,10 @@ describe("SendMenu", () => { fireEvent.click(screen.getByText("Send")); - expect(tooltipHandlers.toggle).toHaveBeenCalledTimes(1); + expect(screen.getByText(content.foreclose.sendTooltip)).toBeVisible(); }); - test("should toggle the menu when the user can send", () => { - const { menuHandlers } = setupDisclosures({}); + it("should open the menu when the user can send", async () => { setupCommonMocks({ selectedConnection: { type: "system" }, isConnected: true, @@ -230,11 +179,17 @@ describe("SendMenu", () => { fireEvent.click(screen.getByText("Send")); - expect(menuHandlers.toggle).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByRole("menu")).toBeVisible()); + + expect(screen.getByText("Ether")).toBeVisible(); + expect(screen.getByText("ERC-20")).toBeVisible(); + expect(screen.getByText("ERC-721")).toBeVisible(); + expect(screen.getByText("ERC-1155 (Single)")).toBeVisible(); + expect(screen.getByText("ERC-1155 (Batch)")).toBeVisible(); + expect(screen.getByText("Input")).toBeVisible(); }); - test("should dispatch the input action with json ABI specifications", () => { - const { menuHandlers } = setupDisclosures({ menuOpened: true }); + it("should dispatch the input action with json ABI specifications", async () => { const listSpecifications = vi.fn(() => [ { id: "1", mode: "json_abi" }, { id: "2", mode: "abi_params" }, @@ -248,6 +203,13 @@ describe("SendMenu", () => { }); render(); + + fireEvent.click(screen.getByText("Send")); + + await waitFor(() => { + expect(screen.getByText("Input")).toBeVisible(); + }); + fireEvent.click(screen.getByText("Input")); expect(actions.sendGenericInput).toHaveBeenCalledWith( @@ -256,11 +218,10 @@ describe("SendMenu", () => { }), [{ id: "1", mode: "json_abi" }], ); - expect(menuHandlers.close).toHaveBeenCalled(); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); - test("should dispatch the deposit actions from the menu", () => { - const { menuHandlers } = setupDisclosures({ menuOpened: true }); + it("should dispatch the deposit actions from the menu", async () => { const { actions } = setupCommonMocks({ selectedConnection: { type: "system" }, isConnected: true, @@ -269,10 +230,28 @@ describe("SendMenu", () => { render(); + fireEvent.click(screen.getByText("Send")); + await waitFor(() => expect(screen.getByText("Ether")).toBeVisible()); fireEvent.click(screen.getByText("Ether")); + + fireEvent.click(screen.getByText("Send")); + await waitFor(() => expect(screen.getByText("ERC-20")).toBeVisible()); fireEvent.click(screen.getByText("ERC-20")); + + fireEvent.click(screen.getByText("Send")); + await waitFor(() => expect(screen.getByText("ERC-721")).toBeVisible()); fireEvent.click(screen.getByText("ERC-721")); + + fireEvent.click(screen.getByText("Send")); + await waitFor(() => + expect(screen.getByText("ERC-1155 (Single)")).toBeVisible(), + ); fireEvent.click(screen.getByText("ERC-1155 (Single)")); + + fireEvent.click(screen.getByText("Send")); + await waitFor(() => + expect(screen.getByText("ERC-1155 (Batch)")).toBeVisible(), + ); fireEvent.click(screen.getByText("ERC-1155 (Batch)")); expect(actions.depositEth).toHaveBeenCalledTimes(1); @@ -280,6 +259,155 @@ describe("SendMenu", () => { expect(actions.depositErc721).toHaveBeenCalledTimes(1); expect(actions.depositErc1155Single).toHaveBeenCalledTimes(1); expect(actions.depositErc1155Batch).toHaveBeenCalledTimes(1); - expect(menuHandlers.close).toHaveBeenCalled(); + }); + + describe("Foreclose workflow", () => { + const guardianApplication = createApplication({ + withdrawalConfig: { + guardian: guardianAddress, + log2LeavesPerAccount: 0n, + log2MaxNumOfAccounts: 0n, + accountsDriveStartIndex: 0n, + withdrawalOutputBuilder: + "0x0000000000000000000000000000000000000000", + }, + }); + + it("should not show the Foreclose menu item for non-guardian users", async () => { + setupCommonMocks({ + selectedConnection: { type: "system" }, + isConnected: true, + chain: mainnet, + address: "0x1111111111111111111111111111111111111111", + }); + + render(); + + fireEvent.click(screen.getByText("Send")); + + await waitFor(() => + expect(screen.getByText("Ether")).toBeVisible(), + ); + expect(screen.queryByText("Foreclose")).not.toBeInTheDocument(); + }); + + it("should show the Foreclose menu item for connected guardian account", async () => { + setupCommonMocks({ + selectedConnection: { type: "system" }, + isConnected: true, + chain: mainnet, + address: guardianAddress, + }); + + render(); + + fireEvent.click(screen.getByText("Send")); + + await waitFor(() => + expect(screen.getByText("Foreclose")).toBeVisible(), + ); + }); + + it("should open the confirmation modal when Foreclose is clicked", async () => { + setupCommonMocks({ + selectedConnection: { type: "system" }, + isConnected: true, + chain: mainnet, + address: guardianAddress, + }); + + render(); + + fireEvent.click(screen.getByText("Send")); + + await waitFor(() => + expect(screen.getByText("Foreclose")).toBeVisible(), + ); + + fireEvent.click(screen.getByText("Foreclose")); + + await waitFor(() => + expect( + screen.getByText("Are you sure you want to foreclose?"), + ).toBeVisible(), + ); + + expect( + screen.getByText( + "It is an irreversible action that prevents any further deposits or inputs to the application.", + ), + ).toBeVisible(); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("should call foreclose action when the user confirms to proceed", async () => { + const { actions } = setupCommonMocks({ + selectedConnection: { type: "system" }, + isConnected: true, + chain: mainnet, + address: guardianAddress, + }); + + render(); + + fireEvent.click(screen.getByText("Send")); + await waitFor(() => + expect(screen.getByText("Foreclose")).toBeVisible(), + ); + + fireEvent.click(screen.getByText("Foreclose")); + + await waitFor(() => + expect( + screen.getByText("Are you sure you want to foreclose?"), + ).toBeVisible(), + ); + + fireEvent.click(screen.getByText("Confirm")); + + await waitFor(() => + expect( + screen.queryByText("Are you sure you want to foreclose?"), + ).not.toBeInTheDocument(), + ); + + expect(actions.foreclose).toHaveBeenCalledWith(guardianApplication); + }); + + it("should not call foreclose action when the confirmation modal is cancelled", async () => { + const { actions } = setupCommonMocks({ + selectedConnection: { type: "system" }, + isConnected: true, + chain: mainnet, + address: guardianAddress, + }); + + render(); + + fireEvent.click(screen.getByText("Send")); + + await waitFor(() => + expect(screen.getByText("Foreclose")).toBeVisible(), + ); + + fireEvent.click(screen.getByText("Foreclose")); + + await waitFor(() => + expect( + screen.getByText("Are you sure you want to foreclose?"), + ).toBeVisible(), + ); + + fireEvent.click(screen.getByText("Cancel")); + + await waitFor(() => + expect( + screen.queryByText("Are you sure you want to foreclose?"), + ).not.toBeInTheDocument(), + ); + + expect(actions.foreclose).not.toHaveBeenCalled(); + }); }); }); From b13dfa9bef75957f4c5b11facf3ef27b55c50339 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 8 Jul 2026 11:39:29 +0100 Subject: [PATCH 5/9] refactor(explorer): Remove epochs polling for output-execution. * Avoids thundering-herd effect. --- .../src/components/output/OutputExecution.tsx | 44 +------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/apps/explorer/src/components/output/OutputExecution.tsx b/apps/explorer/src/components/output/OutputExecution.tsx index b79e4ff7..756637c2 100644 --- a/apps/explorer/src/components/output/OutputExecution.tsx +++ b/apps/explorer/src/components/output/OutputExecution.tsx @@ -17,23 +17,19 @@ import { } from "@mantine/core"; import { useChainModal, useConnectModal } from "@rainbow-me/rainbowkit"; import { useQueryClient } from "@tanstack/react-query"; -import { isNil, isNotEmpty, isNotNil, pathOr } from "ramda"; -import { isFunction, isNotNilOrEmpty, isObj, isString } from "ramda-adjunct"; +import { isNil, isNotEmpty, isNotNil } from "ramda"; +import { isFunction, isNotNilOrEmpty } from "ramda-adjunct"; import { Activity, useEffect, useMemo, type FC } from "react"; import { TbExclamationCircle, TbInfoCircle, TbReceipt } from "react-icons/tb"; import type { TransactionReceipt } from "viem"; import { type Hex } from "viem"; import { useAccount, useWaitForTransactionReceipt } from "wagmi"; -import { useSelectedNodeConnection } from "../connection/hooks"; import TransactionHash from "../TransactionHash"; import OutputExecutionError, { type WagmiActionError, } from "./errors/OutputExecutionError"; import type { VoucherOutput } from "./types"; -// xxx: Maybe should become an environment var. -const POLLING_INTERVAL_MS = 8000 as const; - type OutputExecutionProps = { output: VoucherOutput; application: Hex; @@ -57,7 +53,6 @@ const OutputExecution: FC = ({ const theme = useMantineTheme(); const userAccount = useAccount(); const connectModal = useConnectModal(); - const selectedNode = useSelectedNodeConnection(); const chainModal = useChainModal(); const epochQuery = useEpoch({ epochIndex: output.epochIndex, application }); const queryClient = useQueryClient(); @@ -155,41 +150,6 @@ const OutputExecution: FC = ({ } }, [errors, onError]); - useEffect(() => { - let key: unknown; - // skip invalidation if there is no node-connected or the connected node is a mock. - if ( - isNotNil(selectedNode) && - selectedNode.type !== "system_mock" && - !isClaimAccepted - ) { - key = setInterval(() => { - // Invalidation makes the targeted queries stale. - // It creates a Polling effect. - queryClient.invalidateQueries({ - predicate: (query) => { - const [baseKey, paramsKey] = query.queryKey; - const isEpochQuery = - isString(baseKey) && baseKey.includes("epoch"); - const isMatchingEpochIndex = - isObj(paramsKey) && - pathOr("", ["epochIndex"], paramsKey) === - output.epochIndex.toString(); - - return isEpochQuery && isMatchingEpochIndex; - }, - }); - }, POLLING_INTERVAL_MS); - } - - return () => { - if (isNotNil(key)) { - console.info(`Clearing the polling.`); - clearInterval(key as number); - } - }; - }, [queryClient, isClaimAccepted, selectedNode, output.epochIndex]); - return ( <> From 7e2b35f5c6451fe8453b8e68c27c8bc3a460cb64 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 8 Jul 2026 11:49:38 +0100 Subject: [PATCH 6/9] feat(explorer): Add content/copy for output context. --- apps/explorer/src/content/outputMessages.ts | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 apps/explorer/src/content/outputMessages.ts diff --git a/apps/explorer/src/content/outputMessages.ts b/apps/explorer/src/content/outputMessages.ts new file mode 100644 index 00000000..e580af6d --- /dev/null +++ b/apps/explorer/src/content/outputMessages.ts @@ -0,0 +1,27 @@ +/** + * This file contains the output messages used in the application. + */ +export const outputMessages = { + txhash: "Execution transaction hash", + delegateCallVoucherTxt: "Delegated Call Voucher", + voucherTxt: "Voucher", + noticeTxt: "Notice", + voucher: { + checking: "Checking voucher...", + preparing: "Preparing voucher...", + executed: "Executed", + execute: "Execute", + executionSuccess: "Voucher executed successfully!", + feedback: { + executionStatusTxt: "Voucher execution status", + executionStatusSuccess: "Executed successfully", + executionStatusError: "Execution failed", + }, + }, + epoch: { + nonAcceptedClaim: + "Once the epoch status become CLAIM_ACCEPTED the output will become executable.", + claimForeclosed: + "The epoch claim is foreclosed. The output cannot be executed.", + }, +} as const; From 64d80b5f190568527acb7951bde06e8148999916 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 8 Jul 2026 11:50:48 +0100 Subject: [PATCH 7/9] refactor(explorer): replace hardcoded string to use output-message content. --- .../src/components/output/OutputExecution.tsx | 13 +++++++------ apps/explorer/src/components/output/OutputView.tsx | 14 +++++++++----- apps/explorer/src/content/index.ts | 2 ++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/explorer/src/components/output/OutputExecution.tsx b/apps/explorer/src/components/output/OutputExecution.tsx index 756637c2..0027b8e4 100644 --- a/apps/explorer/src/components/output/OutputExecution.tsx +++ b/apps/explorer/src/components/output/OutputExecution.tsx @@ -24,6 +24,7 @@ import { TbExclamationCircle, TbInfoCircle, TbReceipt } from "react-icons/tb"; import type { TransactionReceipt } from "viem"; import { type Hex } from "viem"; import { useAccount, useWaitForTransactionReceipt } from "wagmi"; +import { content } from "../../content"; import TransactionHash from "../TransactionHash"; import OutputExecutionError, { type WagmiActionError, @@ -163,7 +164,7 @@ const OutputExecution: FC = ({ leftSection={ = ({ > {isNotNil(output.executionTransactionHash) && ( - + = ({ }} > {isExecuted - ? "Executed" + ? content.output.voucher.executed : checkingOutputExecuted - ? "Checking voucher..." + ? content.output.voucher.checking : prepare.isFetching - ? "Preparing voucher..." - : "Execute"} + ? content.output.voucher.preparing + : content.output.voucher.execute} diff --git a/apps/explorer/src/components/output/OutputView.tsx b/apps/explorer/src/components/output/OutputView.tsx index 79c4120e..b6b2754f 100644 --- a/apps/explorer/src/components/output/OutputView.tsx +++ b/apps/explorer/src/components/output/OutputView.tsx @@ -4,6 +4,7 @@ import { notifications } from "@mantine/notifications"; import { isNotNil } from "ramda"; import { type FC } from "react"; import { formatUnits, isHex, type Hex } from "viem"; +import { content } from "../../content"; import { useIsSmallDevice } from "../../hooks/useIsSmallDevice"; import { getDecoder } from "../../lib/decoders"; import Address from "../Address"; @@ -24,7 +25,7 @@ const NoticeContent: FC = ({ const decoderFn = getDecoder(decoderType); return ( -
+
{decoderFn(decodedData.payload)} @@ -38,7 +39,7 @@ const VoucherContent: FC = ({ decoderType, output, application, - title = "Voucher", + title = content.output.voucherTxt, }) => { const decoderFn = getDecoder(decoderType); const { isSmallDevice } = useIsSmallDevice(); @@ -95,8 +96,11 @@ const VoucherContent: FC = ({ output={output} onSuccess={() => { notifications.show({ - title: "Voucher execution status", - message: "Executed successfully", + title: content.output.voucher.feedback + .executionStatusTxt, + message: + content.output.voucher.feedback + .executionStatusSuccess, color: "green", withBorder: true, }); @@ -128,7 +132,7 @@ export const OutputView: FC = ({ ) : outputType === "DelegateCallVoucher" ? ( diff --git a/apps/explorer/src/content/index.ts b/apps/explorer/src/content/index.ts index d2c88517..57f5a6c4 100644 --- a/apps/explorer/src/content/index.ts +++ b/apps/explorer/src/content/index.ts @@ -1,7 +1,9 @@ import { forecloseMessages } from "./forecloseMessages"; import { globalMessages } from "./globalMessages"; +import { outputMessages } from "./outputMessages"; export const content = { foreclose: forecloseMessages, global: globalMessages, + output: outputMessages, } as const; From 5b1dfa9c8621a1b2fc260d0d65332b2674dfb6db Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 8 Jul 2026 18:50:51 +0100 Subject: [PATCH 8/9] refactor(explorer): Improve output execution feedback and add tests. * Improve feedback depending on output epoch status. * For epochs with status claim-foreclosed the output UI also marks it as execution foreclosed text including informational tooltip. --- .../src/components/output/OutputExecution.tsx | 47 +- apps/explorer/src/content/outputMessages.ts | 2 + .../output/OutputExecution.test.tsx | 618 ++++++++++++++++++ 3 files changed, 656 insertions(+), 11 deletions(-) create mode 100644 apps/explorer/test/components/output/OutputExecution.test.tsx diff --git a/apps/explorer/src/components/output/OutputExecution.tsx b/apps/explorer/src/components/output/OutputExecution.tsx index 0027b8e4..ecf4fc8e 100644 --- a/apps/explorer/src/components/output/OutputExecution.tsx +++ b/apps/explorer/src/components/output/OutputExecution.tsx @@ -1,4 +1,5 @@ "use client"; +import type { EpochStatus } from "@cartesi/viem"; import { useEpoch, useReadApplicationWasOutputExecuted, @@ -21,8 +22,7 @@ import { isNil, isNotEmpty, isNotNil } from "ramda"; import { isFunction, isNotNilOrEmpty } from "ramda-adjunct"; import { Activity, useEffect, useMemo, type FC } from "react"; import { TbExclamationCircle, TbInfoCircle, TbReceipt } from "react-icons/tb"; -import type { TransactionReceipt } from "viem"; -import { type Hex } from "viem"; +import { type Hex, type TransactionReceipt } from "viem"; import { useAccount, useWaitForTransactionReceipt } from "wagmi"; import { content } from "../../content"; import TransactionHash from "../TransactionHash"; @@ -45,6 +45,27 @@ const buildProof = (output: VoucherOutput): Proof => ({ outputHashesSiblings: output.outputHashesSiblings ?? [], }); +const buildFeedback = (epochStatus?: EpochStatus) => { + if (isNil(epochStatus)) return null; + + switch (epochStatus) { + case "CLAIM_FORECLOSED": + return { + status: epochStatus, + tooltip: content.output.epoch.claimForeclosed, + body: content.output.epoch.executionForeclosed, + }; + case "CLAIM_ACCEPTED": + return null; + default: + return { + status: epochStatus, + tooltip: content.output.epoch.nonAcceptedClaim, + body: content.output.epoch.waitingClaim, + }; + } +}; + const OutputExecution: FC = ({ application, output, @@ -57,8 +78,10 @@ const OutputExecution: FC = ({ const chainModal = useChainModal(); const epochQuery = useEpoch({ epochIndex: output.epochIndex, application }); const queryClient = useQueryClient(); - const isClaimAccepted = epochQuery.data?.status === "CLAIM_ACCEPTED"; + const epochStatus = epochQuery.data?.status; + const isClaimAccepted = epochStatus === "CLAIM_ACCEPTED"; const hasExecutionTransaction = isNotNil(output.executionTransactionHash); + const feedback = buildFeedback(epochStatus); const wasOutputExecutedQuery = useReadApplicationWasOutputExecuted({ address: application, @@ -156,27 +179,29 @@ const OutputExecution: FC = ({ - + {isNotNil(feedback) ? ( + } > - Waiting Claim + {feedback.body} - + ) : null} ({ + useEpoch: vi.fn(), + useReadApplicationWasOutputExecuted: vi.fn(), + useSimulateApplicationExecuteOutput: vi.fn(), + useWriteApplicationExecuteOutput: vi.fn(), + useConfig: vi.fn(), + useAccount: vi.fn(), + useWaitForTransactionReceipt: vi.fn(), + useChainModal: vi.fn(), + useConnectModal: vi.fn(), + useQueryClient: vi.fn(), +})); + +vi.mock("@cartesi/wagmi", () => ({ + useEpoch: mocks.useEpoch, + useReadApplicationWasOutputExecuted: + mocks.useReadApplicationWasOutputExecuted, + useSimulateApplicationExecuteOutput: + mocks.useSimulateApplicationExecuteOutput, + useWriteApplicationExecuteOutput: mocks.useWriteApplicationExecuteOutput, + useConfig: mocks.useConfig, +})); + +vi.mock("wagmi", () => ({ + useAccount: mocks.useAccount, + useConfig: mocks.useConfig, + useWaitForTransactionReceipt: mocks.useWaitForTransactionReceipt, +})); + +vi.mock("@rainbow-me/rainbowkit", () => ({ + useConnectModal: mocks.useConnectModal, + useChainModal: mocks.useChainModal, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: mocks.useQueryClient, +})); + +const APPLICATION = "0x1234567890abcdef1234567890abcdef12345678" as const; +const TX_HASH = + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as const; + +const buildOutput = (overrides: Partial = {}): VoucherOutput => + ({ + epochIndex: 2n, + inputIndex: 7n, + index: 14n, + rawData: "0x", + decodedData: { + type: "Voucher", + payload: "0x", + destination: "0x4A679253410272dd5232B3Ff7cF5dbB88f295319", + value: 0n, + }, + hash: null, + outputHashesSiblings: [ + "0x5a26e5a8c5b393796bb8ea278408a7278afee9a52a16a55f65839fe7a4689551", + ], + executionTransactionHash: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) as VoucherOutput; + +type SetupOptions = { + epochStatus?: EpochStatus; + wasOutputExecuted?: boolean | undefined; + checkingOutputExecuted?: boolean; + checkingOutputExecutedError?: Error | null; + simulateIsFetching?: boolean; + simulateError?: Error | null; + simulateData?: { request: unknown } | undefined; + isConnected?: boolean; + chain?: { id: number } | null; + waitIsSuccess?: boolean; + waitData?: unknown; + waitIsFetching?: boolean; + executeTxHash?: Hex | undefined; + executeIsPending?: boolean; +}; + +const setupMocks = ({ + epochStatus, + wasOutputExecuted = false, + checkingOutputExecuted = false, + checkingOutputExecutedError = null, + simulateIsFetching = false, + simulateError = null, + simulateData = undefined, + isConnected = true, + chain = mainnet, + waitIsSuccess = false, + waitData = undefined, + waitIsFetching = false, + executeTxHash = undefined, + executeIsPending = false, +}: SetupOptions = {}) => { + const refetch = vi.fn(); + const reset = vi.fn(); + const writeContract = vi.fn(); + const openConnectModal = vi.fn(); + const openChainModal = vi.fn(); + const invalidateQueries = vi.fn(); + + mocks.useEpoch.mockReturnValue({ + data: epochStatus !== undefined ? { status: epochStatus } : undefined, + }); + mocks.useReadApplicationWasOutputExecuted.mockReturnValue({ + data: wasOutputExecuted, + isFetching: checkingOutputExecuted, + error: checkingOutputExecutedError, + refetch, + }); + mocks.useSimulateApplicationExecuteOutput.mockReturnValue({ + data: simulateData, + error: simulateError, + isFetching: simulateIsFetching, + }); + mocks.useWriteApplicationExecuteOutput.mockReturnValue({ + data: executeTxHash, + isPending: executeIsPending, + reset, + writeContract, + }); + mocks.useAccount.mockReturnValue({ isConnected, chain }); + mocks.useConfig.mockReturnValue({ chains: chain ? [chain] : [] }); + mocks.useWaitForTransactionReceipt.mockReturnValue({ + data: waitData, + isFetching: waitIsFetching, + isSuccess: waitIsSuccess, + }); + mocks.useConnectModal.mockReturnValue({ openConnectModal }); + mocks.useChainModal.mockReturnValue({ openChainModal }); + mocks.useQueryClient.mockReturnValue({ invalidateQueries }); + + return { + refetch, + reset, + writeContract, + openConnectModal, + openChainModal, + invalidateQueries, + }; +}; + +describe("OutputExecution", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("feedback badge", () => { + it("shows no badge when epoch status is undefined", () => { + setupMocks({ epochStatus: undefined }); + render( + , + ); + + expect( + screen.queryByText(content.output.epoch.waitingClaim), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(content.output.epoch.executionForeclosed), + ).not.toBeInTheDocument(); + }); + + it("shows no badge when epoch status is CLAIM_ACCEPTED", () => { + setupMocks({ epochStatus: "CLAIM_ACCEPTED" }); + render( + , + ); + + expect( + screen.queryByText(content.output.epoch.waitingClaim), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(content.output.epoch.executionForeclosed), + ).not.toBeInTheDocument(); + }); + + it("shows waiting claim badge for non-accepted epoch statuses", () => { + setupMocks({ epochStatus: "OPEN" }); + render( + , + ); + + expect( + screen.getByText(content.output.epoch.waitingClaim), + ).toBeVisible(); + }); + + it("shows foreclosed badge when epoch status is CLAIM_FORECLOSED", () => { + setupMocks({ epochStatus: "CLAIM_FORECLOSED" }); + render( + , + ); + + expect( + screen.getByText(content.output.epoch.executionForeclosed), + ).toBeVisible(); + }); + }); + + describe("Execute button labels", () => { + it('displays "Execute" when claim is accepted and output is pending execution', () => { + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + }); + render( + , + ); + + expect( + screen.getByText(content.output.voucher.execute), + ).toBeVisible(); + }); + + it('displays "Checking voucher..." while verifying on-chain execution status', () => { + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + checkingOutputExecuted: true, + }); + + render( + , + ); + + expect( + screen.getByText(content.output.voucher.checking), + ).toBeVisible(); + }); + + it('displays "Preparing voucher..." while simulation is in progress', () => { + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + simulateIsFetching: true, + }); + render( + , + ); + + expect( + screen.getByText(content.output.voucher.preparing), + ).toBeVisible(); + }); + + it('displays "Executed" when the output was already executed on-chain', () => { + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: true, + }); + render( + , + ); + + expect( + screen.getByText(content.output.voucher.executed), + ).toBeVisible(); + }); + + it('displays "Executed" when the output has an execution transaction hash', () => { + setupMocks({ epochStatus: "CLAIM_ACCEPTED" }); + render( + , + ); + + expect( + screen.getByText(content.output.voucher.executed), + ).toBeVisible(); + }); + }); + + describe("execute button disabled state", () => { + it("is disabled when the output is already executed on-chain", () => { + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: true, + }); + render( + , + ); + + expect( + screen.getByRole("button", { + name: content.output.voucher.executed, + }), + ).toBeDisabled(); + }); + + it("is disabled when there are wagmi errors", () => { + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + simulateError: Object.assign(new Error("Simulate failed"), { + shortMessage: "Simulate failed", + }), + }); + render( + , + ); + + expect( + screen.getByRole("button", { + name: content.output.voucher.execute, + }), + ).toBeDisabled(); + }); + + it("is disabled while simulation is fetching", () => { + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + simulateIsFetching: true, + }); + render( + , + ); + + expect( + screen.getByRole("button", { + name: content.output.voucher.preparing, + }), + ).toBeDisabled(); + }); + }); + + describe("execute button click", () => { + it("opens the connect modal when clicked by a disconnected user", () => { + const { openConnectModal } = setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + isConnected: false, + wasOutputExecuted: false, + }); + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { + name: content.output.voucher.execute, + }), + ); + + expect(openConnectModal).toHaveBeenCalledTimes(1); + }); + + it("opens the chain modal when the connected user needs to switch networks", () => { + const { openChainModal } = setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + isConnected: true, + chain: null, + wasOutputExecuted: false, + }); + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { + name: content.output.voucher.execute, + }), + ); + + expect(openChainModal).toHaveBeenCalledTimes(1); + }); + + it("calls writeContract when user is connected and simulation data is available", () => { + const request = { address: APPLICATION, data: "0x" as Hex }; + const { writeContract } = setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + isConnected: true, + chain: mainnet, + wasOutputExecuted: false, + simulateData: { request }, + }); + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { + name: content.output.voucher.execute, + }), + ); + + expect(writeContract).toHaveBeenCalledWith(request); + }); + }); + + describe("execution transaction hash", () => { + it("displays the transaction hash when the output was executed via L2 (as text for devnet)", () => { + setupMocks({ epochStatus: "CLAIM_ACCEPTED", chain: foundry }); + render( + , + ); + + const txHashElement = screen.getByText(shortenHash(TX_HASH)); + expect(txHashElement).toBeVisible(); + expect(txHashElement.closest("a")).toBeNull(); + }); + + it("displays the transaction hash when the output was executed via L2 (as anchor for mainnet/testnet)", () => { + setupMocks({ epochStatus: "CLAIM_ACCEPTED", chain: mainnet }); + render( + , + ); + + const txHashElement = screen.getByText(shortenHash(TX_HASH)); + expect(txHashElement).toBeVisible(); + expect(txHashElement.closest("a")).toHaveAttribute( + "href", + `https://etherscan.io/tx/${TX_HASH}`, + ); + }); + + it("does not display a transaction hash when executionTransactionHash is null", () => { + setupMocks({ epochStatus: "CLAIM_ACCEPTED" }); + render( + , + ); + + expect( + screen.queryByText(shortenHash(TX_HASH)), + ).not.toBeInTheDocument(); + }); + }); + + describe("error display", () => { + it("shows an alert when the simulation step fails", () => { + const errorMessage = "User rejected the request."; + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + simulateError: Object.assign(new Error(errorMessage), { + shortMessage: errorMessage, + }), + }); + render( + , + ); + + expect(screen.getByText(errorMessage)).toBeVisible(); + }); + + it("shows an alert when the on-chain execution check fails", () => { + const errorMessage = "Failed to read contract."; + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + checkingOutputExecutedError: Object.assign( + new Error(errorMessage), + { shortMessage: errorMessage }, + ), + }); + render( + , + ); + + expect(screen.getByText(errorMessage)).toBeVisible(); + }); + }); + + describe("onError callback", () => { + it("is invoked with the list of errors when errors are present", () => { + const onError = vi.fn(); + const errorMessage = "Contract simulation failed."; + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + simulateError: Object.assign(new Error(errorMessage), { + shortMessage: errorMessage, + }), + }); + render( + , + ); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toHaveLength(1); + }); + + it("is not invoked when there are no errors", () => { + const onError = vi.fn(); + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + }); + render( + , + ); + + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe("onSuccess callback", () => { + it("is invoked with the transaction receipt after successful execution", async () => { + const onSuccess = vi.fn(); + const txReceipt = { transactionHash: TX_HASH }; + setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + waitIsSuccess: true, + waitData: txReceipt, + }); + render( + , + ); + + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith(txReceipt), + ); + }); + + it("invalidates the outputs query after successful execution", async () => { + const { invalidateQueries } = setupMocks({ + epochStatus: "CLAIM_ACCEPTED", + wasOutputExecuted: false, + waitIsSuccess: true, + waitData: { transactionHash: TX_HASH }, + }); + render( + , + ); + + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ["outputs"] }), + ), + ); + }); + }); +}); From 397944b13935db76e34ba5f3f008e97012936146 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 8 Jul 2026 19:13:23 +0100 Subject: [PATCH 9/9] fix(explorer): Allow HTTP loopback connections from HTTPS deployments. * Remove upgrade-insecure-requests and explicit list loopback origins from CSP. --- apps/explorer/next.config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/explorer/next.config.ts b/apps/explorer/next.config.ts index 7df52e60..901155a1 100644 --- a/apps/explorer/next.config.ts +++ b/apps/explorer/next.config.ts @@ -6,12 +6,11 @@ const ContentSecurityPolicy = ` font-src 'self' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; - connect-src 'self' https: wss: http:; + connect-src 'self' https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; frame-ancestors 'self' https://app.safe.global https://verify.walletconnect.org; object-src 'none'; base-uri 'self'; - form-action 'self'; - upgrade-insecure-requests; + form-action 'self'; `; const nextConfig: NextConfig = {