Skip to content
Open
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: 2 additions & 3 deletions apps/explorer/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
61 changes: 61 additions & 0 deletions apps/explorer/src/components/ConfirmationModal.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ConfirmationModal>;

export default meta;
type Story = StoryObj<typeof meta>;

type Props = Parameters<typeof ConfirmationModal>[0];

const Wrapper = (props: Props) => {
const [opened, handlers] = useDisclosure(false);
const [resultTxt, setResultTxt] = useState<string | null>(null);

return (
<Stack
align="center"
justify="center"
gap="md"
style={{ height: "100vh", width: "100vw" }}
>
<ConfirmationModal
{...props}
isOpened={opened}
onClose={() => {
setResultTxt("Not confirmed");
handlers.close();
}}
onConfirm={() => {
setResultTxt("Confirmed");
handlers.close();
}}
/>
<Button onClick={handlers.open}>Open Confirmation Modal</Button>
<Group>
<Text>Result: {resultTxt ?? "No interaction yet"}</Text>
</Group>
</Stack>
);
};

/**
* 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,
};
69 changes: 69 additions & 0 deletions apps/explorer/src/components/ConfirmationModal.tsx
Original file line number Diff line number Diff line change
@@ -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<ConfirmationModalProps> = (props) => {
const { isOpened, text, onClose, onConfirm, title } = props;
const isElement = isValidElement(text);

return (
<Modal
opened={isOpened}
onClose={onClose}
onClick={(evt) => {
evt.stopPropagation();
}}
closeOnClickOutside
title={title ?? <Text fw="bold">Before you proceed...</Text>}
centered
>
<Stack gap="md">
{isElement ? (
text
) : (
<Notification
withCloseButton={false}
color="red"
style={{ boxShadow: "none" }}
>
{text}
</Notification>
)}

<Group justify="flex-end">
<Button
variant="default"
onClick={(evt) => {
evt.stopPropagation();
onClose();
}}
>
{content.global.btn.cancel}
</Button>
<Button
onClick={(evt) => {
evt.stopPropagation();
onConfirm();
}}
>
{content.global.btn.confirm}
</Button>
</Group>
</Stack>
</Modal>
);
};
23 changes: 22 additions & 1 deletion apps/explorer/src/components/application/utils.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
*
Expand All @@ -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);
};
102 changes: 44 additions & 58 deletions apps/explorer/src/components/output/OutputExecution.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"use client";
import type { EpochStatus } from "@cartesi/viem";
import {
useEpoch,
useReadApplicationWasOutputExecuted,
Expand All @@ -17,23 +18,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 { type Hex, type TransactionReceipt } from "viem";
import { useAccount, useWaitForTransactionReceipt } from "wagmi";
import { useSelectedNodeConnection } from "../connection/hooks";
import { content } from "../../content";
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;
Expand All @@ -48,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<OutputExecutionProps> = ({
application,
output,
Expand All @@ -57,12 +75,13 @@ const OutputExecution: FC<OutputExecutionProps> = ({
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();
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,
Expand Down Expand Up @@ -155,67 +174,34 @@ const OutputExecution: FC<OutputExecutionProps> = ({
}
}, [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 (
<>
<Stack>
<Divider mt="sm" />

<Activity mode={!isClaimAccepted ? "visible" : "hidden"}>
{isNotNil(feedback) ? (
<Group justify="flex-end">
<Badge
color={
feedback.status === "CLAIM_FORECLOSED"
? "foreclosed"
: undefined
}
variant="filled"
radius="xs"
size="lg"
leftSection={
<Tooltip
label={
"Once the epoch status become CLAIM_ACCEPTED the voucher will become executable."
}
>
<Tooltip label={feedback.tooltip}>
<TbInfoCircle
size={theme.other.smIconSize}
/>
</Tooltip>
}
>
Waiting Claim
{feedback.body}
</Badge>
</Group>
</Activity>
) : null}

<Activity mode={isClaimAccepted ? "visible" : "hidden"}>
<Group
Expand All @@ -227,7 +213,7 @@ const OutputExecution: FC<OutputExecutionProps> = ({
>
{isNotNil(output.executionTransactionHash) && (
<Group gap={3}>
<Tooltip label="Execution transaction hash">
<Tooltip label={content.output.txhash}>
<TbReceipt size={theme.other.mdIconSize} />
</Tooltip>
<TransactionHash
Expand Down Expand Up @@ -261,12 +247,12 @@ const OutputExecution: FC<OutputExecutionProps> = ({
}}
>
{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}
</Button>
</Group>
</Activity>
Expand Down
Loading
Loading