From ca2e9504995676a8803ea6586089aa270c1754b4 Mon Sep 17 00:00:00 2001 From: Matthew Laux Date: Mon, 18 Nov 2024 15:08:35 -0600 Subject: [PATCH 1/4] Add TX monitoring for signed TXs --- .../buttons/putVotesOnChainButton.tsx | 57 +++++++++++++------ src/lib/awaitTxConfirm.ts | 48 ++++++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 src/lib/awaitTxConfirm.ts diff --git a/src/components/buttons/putVotesOnChainButton.tsx b/src/components/buttons/putVotesOnChainButton.tsx index c1cd6a0..e8e0b61 100644 --- a/src/components/buttons/putVotesOnChainButton.tsx +++ b/src/components/buttons/putVotesOnChainButton.tsx @@ -1,7 +1,9 @@ +import { useState } from 'react'; import Button from '@mui/material/Button'; import { useSession } from 'next-auth/react'; import toast from 'react-hot-toast'; +import { awaitTxConfirm } from '@/lib/awaitTxConfirm'; import { addTxToPoll } from '@/lib/helpers/addTxToPoll'; import { addTxToPollTransactions } from '@/lib/helpers/addTxToPollTransactions'; import { addTxToPollVotes } from '@/lib/helpers/addTxToPollVotes'; @@ -24,6 +26,7 @@ interface Props { */ export function PutVotesOnChainButton(props: Props): JSX.Element { const { pollId, isSubmitting, setIsSubmitting } = props; + const [txSubmitting, setTxSubmitting] = useState(false); const session = useSession(); @@ -42,18 +45,19 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { // Keep trying until the transaction is successful while (!success) { // Post votes on chain - const txHash = await postVotesOnChain(metadata.metadata); - if (txHash) { + const txResult = await postVotesOnChain(metadata.metadata); + setTxSubmitting(true); + if (txResult) { // TODO: Figure out how to handle errors in addTxToPollTransactions and addTxToPollVotes since the data is already on-chain success = true; // Add txHash to poll const addTxResponse = await addTxToPollTransactions( pollId, - txHash.submitTxId, + txResult.submitTxId, ); if (addTxResponse.pollTransactionId === '-1') { toast.error(addTxResponse.message); - break; + break; // TODO: Do we really want to break here? } // Add txHash to poll votes const addTxToVotesResponse = await addTxToPollVotes( @@ -63,8 +67,16 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { ); if (!addTxToVotesResponse.succeeded) { toast.error(addTxToVotesResponse.message); - break; + break; // TODO: Do we really want to break here? } + await awaitTxConfirm(txResult.submitTxId).then(() => + setTxSubmitting(false), + ); + } else { + toast.error( + 'Error posting this TX on-chain. Please try signing this TX again. If the issue persists, refresh the app and re-click the upload votes on-chain button.', + ); + break; } } } @@ -78,18 +90,22 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { while (!summaryTxSuccess) { const response = await getSummaryTxMetadata(pollId); if (response.metadata) { - const txHash = await postVotesOnChain(response.metadata); - if (txHash) { + const txResult = await postVotesOnChain(response.metadata); + setTxSubmitting(true); + if (txResult) { // TODO: Figure out how to handle errors in addTxToPoll since the data is already on-chain summaryTxSuccess = true; // Add txHash to poll - const addTxResponse = await addTxToPoll(pollId, txHash.submitTxId); + const addTxResponse = await addTxToPoll(pollId, txResult.submitTxId); if (!addTxResponse.success) { toast.error(addTxResponse.message); break; } else { toast.success('Votes successfully uploaded on-chain'); } + await awaitTxConfirm(txResult.submitTxId).then(() => + setTxSubmitting(false), + ); } } else { toast.error(response.message); @@ -99,16 +115,25 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { setIsSubmitting(false); } + let buttonText = 'Upload votes on-chain'; + if (txSubmitting) { + buttonText = 'Submitting transaction...'; + } else if (isSubmitting) { + buttonText = 'Preparing next transaction...'; + } + if (session.data?.user.isCoordinator) { return ( - + <> + + ); } else { return <>; diff --git a/src/lib/awaitTxConfirm.ts b/src/lib/awaitTxConfirm.ts new file mode 100644 index 0000000..767e0a6 --- /dev/null +++ b/src/lib/awaitTxConfirm.ts @@ -0,0 +1,48 @@ +import { isTransactionConfirmed } from '@claritydao/clarity-backend'; +import * as Sentry from '@sentry/nextjs'; +import toast from 'react-hot-toast'; + +import { buildClarityBackendReq } from '@/lib/buildClarityBackendReq'; + +/** + * Monitors a transaction until it is confirmed + * @param txId - The transaction ID to monitor + * @returns Boolean - True if the transaction is confirmed, false otherwise + */ +export async function awaitTxConfirm(txId: string): Promise { + try { + const clarityBackendReq = await buildClarityBackendReq(); + if (!clarityBackendReq) { + toast.error('Error monitoring transaction.'); + return false; + } + return new Promise((resolve) => { + // timeout is just hardcoded to 300 seconds (5 minutes) + let elapsedTime = 0; + const intervalId = setInterval(async () => { + elapsedTime += 5000; // Increment elapsed time by 5 seconds (5000 ms) + // If the elapsed time exceeds or equals the max time, clear the interval and exit + if (elapsedTime >= 300000) { + // Clear the interval once the transaction is confirmed + clearInterval(intervalId); + resolve(false); + } + + const isConfirmed = await isTransactionConfirmed( + clarityBackendReq.url, + txId, + ); + + if (isConfirmed) { + // Clear the interval once the transaction is confirmed + clearInterval(intervalId); + resolve(true); + } + }, 5000); // Run every 5 seconds + }); + } catch (error) { + Sentry.captureException(error); + toast.error('Error monitoring transaction.'); + return false; + } +} From 34d2267eca8835a995067587594e3f2d2a5d27de Mon Sep 17 00:00:00 2001 From: Matthew Laux Date: Mon, 18 Nov 2024 15:08:45 -0600 Subject: [PATCH 2/4] Log txHash for future record --- src/lib/postVotesOnChain.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/postVotesOnChain.ts b/src/lib/postVotesOnChain.ts index a64cbd2..341c4fa 100644 --- a/src/lib/postVotesOnChain.ts +++ b/src/lib/postVotesOnChain.ts @@ -36,6 +36,9 @@ export async function postVotesOnChain( }, }, ); + // Logging the TX hash just in case there is an issue saving the TX hash to the DB. + // We can at least look it up in the logs and then manually add it. + console.log('txHash', txHash); return txHash; } catch (error) { Sentry.captureException(error); From 2055fad8b79a35b659d37c7d57a6639a100d6814 Mon Sep 17 00:00:00 2001 From: Matthew Laux Date: Mon, 18 Nov 2024 15:46:48 -0600 Subject: [PATCH 3/4] Add popup for tx uploading --- .../buttons/putVotesOnChainButton.tsx | 25 +++------ src/components/txPopups/txPopup.tsx | 54 +++++++++++++++++++ src/pages/polls/[pollId]/index.tsx | 25 ++++++++- 3 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 src/components/txPopups/txPopup.tsx diff --git a/src/components/buttons/putVotesOnChainButton.tsx b/src/components/buttons/putVotesOnChainButton.tsx index e8e0b61..d22dbb6 100644 --- a/src/components/buttons/putVotesOnChainButton.tsx +++ b/src/components/buttons/putVotesOnChainButton.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import Button from '@mui/material/Button'; import { useSession } from 'next-auth/react'; import toast from 'react-hot-toast'; @@ -15,6 +14,7 @@ interface Props { pollId: string | string[] | undefined; isSubmitting: boolean; setIsSubmitting: (value: boolean) => void; + setIsTxUploading: (value: boolean) => void; } /** @@ -22,11 +22,11 @@ interface Props { * @param pollId - The pollId of the poll to end voting for * @param isSubmitting - Whether the button is in a submitting state * @param setIsSubmitting - Function to set the submitting state + * @param setIsTxUploading - Function to set the transaction submitting state * @returns Put votes on-chain Button */ export function PutVotesOnChainButton(props: Props): JSX.Element { - const { pollId, isSubmitting, setIsSubmitting } = props; - const [txSubmitting, setTxSubmitting] = useState(false); + const { pollId, isSubmitting, setIsSubmitting, setIsTxUploading } = props; const session = useSession(); @@ -46,7 +46,7 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { while (!success) { // Post votes on chain const txResult = await postVotesOnChain(metadata.metadata); - setTxSubmitting(true); + setIsTxUploading(true); if (txResult) { // TODO: Figure out how to handle errors in addTxToPollTransactions and addTxToPollVotes since the data is already on-chain success = true; @@ -70,7 +70,7 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { break; // TODO: Do we really want to break here? } await awaitTxConfirm(txResult.submitTxId).then(() => - setTxSubmitting(false), + setIsTxUploading(false), ); } else { toast.error( @@ -91,7 +91,7 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { const response = await getSummaryTxMetadata(pollId); if (response.metadata) { const txResult = await postVotesOnChain(response.metadata); - setTxSubmitting(true); + setIsTxUploading(true); if (txResult) { // TODO: Figure out how to handle errors in addTxToPoll since the data is already on-chain summaryTxSuccess = true; @@ -100,11 +100,9 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { if (!addTxResponse.success) { toast.error(addTxResponse.message); break; - } else { - toast.success('Votes successfully uploaded on-chain'); } await awaitTxConfirm(txResult.submitTxId).then(() => - setTxSubmitting(false), + setIsTxUploading(false), ); } } else { @@ -115,13 +113,6 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { setIsSubmitting(false); } - let buttonText = 'Upload votes on-chain'; - if (txSubmitting) { - buttonText = 'Submitting transaction...'; - } else if (isSubmitting) { - buttonText = 'Preparing next transaction...'; - } - if (session.data?.user.isCoordinator) { return ( <> @@ -131,7 +122,7 @@ export function PutVotesOnChainButton(props: Props): JSX.Element { disabled={isSubmitting} data-testid="put-votes-onchain-button" > - {buttonText} + Upload votes on-chain ); diff --git a/src/components/txPopups/txPopup.tsx b/src/components/txPopups/txPopup.tsx new file mode 100644 index 0000000..168077c --- /dev/null +++ b/src/components/txPopups/txPopup.tsx @@ -0,0 +1,54 @@ +import { useTheme } from '@mui/material'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; + +interface Props { + title: string; + message: string; +} + +/** + * Transaction in progress popup + * @param title - Title of the popup + * @param message - Message of the popup + * @returns Popup for when a transaction is in progress + */ +export function TxPopup(props: Props): JSX.Element { + const { title, message } = props; + const theme = useTheme(); + + return ( + + + + + ), + }} + > + + {title} + + + {message} + + + + ); +} diff --git a/src/pages/polls/[pollId]/index.tsx b/src/pages/polls/[pollId]/index.tsx index bab2472..3300374 100644 --- a/src/pages/polls/[pollId]/index.tsx +++ b/src/pages/polls/[pollId]/index.tsx @@ -35,6 +35,7 @@ import { PollResults } from '@/components/polls/pollResults'; import { PollStatusChip } from '@/components/polls/pollStatusChip'; import { PollVoteCount } from '@/components/polls/pollVoteCount'; import { RepresentativesTable } from '@/components/representatives/representativesTable'; +import { TxPopup } from '@/components/txPopups/txPopup'; interface Props { poll: Poll | null; @@ -69,6 +70,7 @@ export default function ViewPoll(props: Props): JSX.Element { let { poll } = props; const [isSubmitting, setIsSubmitting] = useState(false); const [pollResults, setPollResults] = useState(pollResultsSSR); + const [isTxUploading, setIsTxUploading] = useState(false); const session = useSession(); const router = useRouter(); @@ -95,6 +97,10 @@ export default function ViewPoll(props: Props): JSX.Element { setIsSubmitting(value); }, []); + const updateIsTxUploading = useCallback((value: boolean) => { + setIsTxUploading(value); + }, []); + const updatePollResults = useCallback( (newPollResults: { yes: { @@ -169,11 +175,27 @@ export default function ViewPoll(props: Props): JSX.Element { {poll.description} - {poll.summary_tx_id && ( + {poll.summary_tx_id && !isTxUploading && ( )} + {isSubmitting && !isTxUploading && ( + + + + )} + {isTxUploading && ( + + + + )} ) : ( !isPending && Poll not found @@ -223,6 +245,7 @@ export default function ViewPoll(props: Props): JSX.Element { pollId={pollId} isSubmitting={isSubmitting} setIsSubmitting={updateIsSubmitting} + setIsTxUploading={updateIsTxUploading} /> )} Date: Mon, 18 Nov 2024 15:54:11 -0600 Subject: [PATCH 4/4] Fix button disabled states --- src/components/buttons/beginVoteButton.tsx | 6 +++--- src/components/buttons/deletePollButton.tsx | 11 ++++++----- src/components/buttons/endVoteButton.tsx | 8 +++----- src/components/buttons/voteOnPollButtons.tsx | 7 ++----- src/pages/polls/[pollId]/index.tsx | 16 ++-------------- 5 files changed, 16 insertions(+), 32 deletions(-) diff --git a/src/components/buttons/beginVoteButton.tsx b/src/components/buttons/beginVoteButton.tsx index 672a718..b0b281d 100644 --- a/src/components/buttons/beginVoteButton.tsx +++ b/src/components/buttons/beginVoteButton.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import Button from '@mui/material/Button'; import toast from 'react-hot-toast'; @@ -5,8 +6,6 @@ import { startVoting } from '@/lib/helpers/startVoting'; interface Props { pollId: string | string[] | undefined; - isSubmitting: boolean; - setIsSubmitting: (value: boolean) => void; } /** @@ -14,7 +13,8 @@ interface Props { * @returns Begin Voting Button */ export function BeginVoteButton(props: Props): JSX.Element { - const { pollId, isSubmitting, setIsSubmitting } = props; + const { pollId } = props; + const [isSubmitting, setIsSubmitting] = useState(false); async function handleBeginVote(): Promise { if (typeof pollId !== 'string') { diff --git a/src/components/buttons/deletePollButton.tsx b/src/components/buttons/deletePollButton.tsx index 3fc9c8d..3b9f6fc 100644 --- a/src/components/buttons/deletePollButton.tsx +++ b/src/components/buttons/deletePollButton.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useRouter } from 'next/router'; import { DeleteRounded } from '@mui/icons-material'; import Button from '@mui/material/Button'; @@ -9,16 +10,16 @@ import { archivePoll } from '@/lib/helpers/archivePoll'; interface Props { pollId: string | string[] | undefined; - isSubmitting: boolean; - setIsSubmitting: (value: boolean) => void; } /** - * A button for workshop coordinators to open voting for a poll - * @returns Begin Voting Button + * A button for workshop coordinators to delete a poll + * @param props - Poll ID + * @returns Delete poll Button */ export function DeletePollButton(props: Props): JSX.Element { - const { pollId, isSubmitting, setIsSubmitting } = props; + const { pollId } = props; + const [isSubmitting, setIsSubmitting] = useState(false); const session = useSession(); const router = useRouter(); diff --git a/src/components/buttons/endVoteButton.tsx b/src/components/buttons/endVoteButton.tsx index 5fe176d..56df7ee 100644 --- a/src/components/buttons/endVoteButton.tsx +++ b/src/components/buttons/endVoteButton.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import Button from '@mui/material/Button'; import { useSession } from 'next-auth/react'; import toast from 'react-hot-toast'; @@ -7,8 +8,6 @@ import { getPollResults } from '@/lib/helpers/getPollResults'; interface Props { pollId: string | string[] | undefined; - isSubmitting: boolean; - setIsSubmitting: (value: boolean) => void; updatePollResults: (newPollResults: { yes: { name: string; @@ -28,13 +27,12 @@ interface Props { /** * A button for workshop coordinators to end voting for a poll * @param pollId - The pollId of the poll to end voting for - * @param isSubmitting - Whether the button is in a submitting state - * @param setIsSubmitting - Function to set the submitting state * @param updatePollResults - Function to update the poll results after voting ends * @returns End Voting Button */ export function EndVoteButton(props: Props): JSX.Element { - const { pollId, isSubmitting, setIsSubmitting, updatePollResults } = props; + const { pollId, updatePollResults } = props; + const [isSubmitting, setIsSubmitting] = useState(false); const session = useSession(); diff --git a/src/components/buttons/voteOnPollButtons.tsx b/src/components/buttons/voteOnPollButtons.tsx index 9069f28..5bbf4e5 100644 --- a/src/components/buttons/voteOnPollButtons.tsx +++ b/src/components/buttons/voteOnPollButtons.tsx @@ -14,8 +14,6 @@ import { getPollVote } from '@/lib/helpers/getPollVote'; interface Props { pollName: string; pollId: string; - disabled: boolean; - setDisabled: (value: boolean) => void; isActiveVoter: boolean; } @@ -23,14 +21,13 @@ interface Props { * Yes, No, Abstain buttons to vote on a poll * @param pollName - The name of the poll * @param pollId - The ID of the poll - * @param disabled - Whether the buttons are disabled - * @param setDisabled - Function to set the disabled state * @param isActiveVoter - Whether the user is the active voter * @returns Vote on Poll Buttons */ export function VoteOnPollButtons(props: Props): JSX.Element { - const { pollName, pollId, disabled, setDisabled, isActiveVoter } = props; + const { pollName, pollId, isActiveVoter } = props; const [vote, setVote] = useState(''); + const [disabled, setDisabled] = useState(false); const session = useSession(); const theme = useTheme(); diff --git a/src/pages/polls/[pollId]/index.tsx b/src/pages/polls/[pollId]/index.tsx index 3300374..d4ed517 100644 --- a/src/pages/polls/[pollId]/index.tsx +++ b/src/pages/polls/[pollId]/index.tsx @@ -225,17 +225,11 @@ export default function ViewPoll(props: Props): JSX.Element { alignItems="center" > {poll.status === pollPhases.pending && ( - + )} {poll.status === pollPhases.voting && ( )} @@ -248,11 +242,7 @@ export default function ViewPoll(props: Props): JSX.Element { setIsTxUploading={updateIsTxUploading} /> )} - + )} @@ -278,8 +268,6 @@ export default function ViewPoll(props: Props): JSX.Element {