diff --git a/.changeset/relayer-simulation-revert-409.md b/.changeset/relayer-simulation-revert-409.md new file mode 100644 index 0000000000..5afac33107 --- /dev/null +++ b/.changeset/relayer-simulation-revert-409.md @@ -0,0 +1,7 @@ +--- +"@anticapture/relayer": patch +"@anticapture/gateful": patch +"@anticapture/dashboard": patch +--- + +Answer a gasless vote or delegation that would revert on-chain (a vote already cast, a used nonce) with 409 SIMULATION_FAILED instead of 500 INTERNAL, documented in the relay routes Gateful exposes, and make a gasless vote reach the relayer once: the dashboard checks hasVoted before asking for a signature, confirms the relayed transaction on the DAO's chain instead of through the wallet, never reports a relayed vote as failed, and keeps the vote's submission state across modal closes and remounts so the ballot is not offered again diff --git a/apps/dashboard/features/governance/components/modals/VoteAlreadyCastContent.tsx b/apps/dashboard/features/governance/components/modals/VoteAlreadyCastContent.tsx new file mode 100644 index 0000000000..e91466c6df --- /dev/null +++ b/apps/dashboard/features/governance/components/modals/VoteAlreadyCastContent.tsx @@ -0,0 +1,35 @@ +import { CheckCircle2 } from "lucide-react"; + +import { Button } from "@/shared/components"; + +interface VoteAlreadyCastContentProps { + onClose: () => void; +} + +/** + * The governor already holds a vote from this address, so the ballot on + * screen was never cast. Ends the flow with a way out rather than the ballot, + * which could only send the same refusal again. + */ +export const VoteAlreadyCastContent = ({ + onClose, +}: VoteAlreadyCastContentProps) => ( +
+
+ +
+ +

+ You have already voted on this proposal +

+ +

+ Your earlier vote is the one that counts. It shows on this page once it is + indexed. +

+ + +
+); diff --git a/apps/dashboard/features/governance/components/modals/VotePendingContent.tsx b/apps/dashboard/features/governance/components/modals/VotePendingContent.tsx new file mode 100644 index 0000000000..f9f440462f --- /dev/null +++ b/apps/dashboard/features/governance/components/modals/VotePendingContent.tsx @@ -0,0 +1,52 @@ +import { ExternalLink, Hourglass } from "lucide-react"; +import type { Hash } from "viem"; + +import { InlineAlert } from "@/shared/components/design-system/alerts/inline-alert/InlineAlert"; + +interface VotePendingContentProps { + hash: Hash | null; + explorerBaseUrl?: string; +} + +const shortenHash = (hash: string) => `${hash.slice(0, 10)}…${hash.slice(-8)}`; + +/** + * A vote that may be on-chain but could not be confirmed here. It offers no + * way to vote again: the wallet would sign the same ballot, and the relayer + * would only be asked to replay it. + */ +export const VotePendingContent = ({ + hash, + explorerBaseUrl, +}: VotePendingContentProps) => ( +
+
+ +
+ +

+ Your vote was sent +

+ + {hash && explorerBaseUrl && ( + + {shortenHash(hash)} + + + )} + + +
+); diff --git a/apps/dashboard/features/governance/components/modals/VotingModal.tsx b/apps/dashboard/features/governance/components/modals/VotingModal.tsx index 3a4a4b24eb..d57d50e540 100644 --- a/apps/dashboard/features/governance/components/modals/VotingModal.tsx +++ b/apps/dashboard/features/governance/components/modals/VotingModal.tsx @@ -1,17 +1,36 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { Check, User2Icon, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { Address } from "viem"; import { formatUnits } from "viem"; -import { useAccount, useWalletClient } from "wagmi"; +import { useAccount, usePublicClient, useWalletClient } from "wagmi"; + +import { getRateLimitQueryKey } from "@anticapture/client/hooks"; +import type { GetRateLimitPathParamsDaoEnumKey } from "@anticapture/client"; import { LoadingComponent } from "@/features/governance/components/modals/LoadingContent"; +import { VoteAlreadyCastContent } from "@/features/governance/components/modals/VoteAlreadyCastContent"; +import { VotePendingContent } from "@/features/governance/components/modals/VotePendingContent"; import { VoteSuccessContent } from "@/features/governance/components/modals/VoteSuccessContent"; import { VoteOption } from "@/features/governance/components/proposal-overview/VoteOption"; +import { useVoteSubmission } from "@/features/governance/hooks/useVoteSubmission"; import type { ProposalDetails } from "@/features/governance/types"; +import { relayGaslessVote } from "@/features/governance/utils/relayGaslessVote"; +import { showCustomToast } from "@/features/governance/utils/showCustomToast"; +import { readSubmission } from "@/features/governance/utils/submissionStore"; import { buildTornDelegatedVoteFrom } from "@/features/governance/utils/tornDelegatedVote"; import { voteOnProposal } from "@/features/governance/utils/voteOnProposal"; +import { + canStartVote, + claimVote, + getVoteScreen, + settlementForGaslessVote, + settlementForWalletVote, + settleVote, + type VoteSettlement, +} from "@/features/governance/utils/voteSubmission"; import { BadgeStatus } from "@/shared/components/design-system/badges/badge-status/BadgeStatus"; import { Button } from "@/shared/components/design-system/buttons/button/Button"; import { @@ -24,8 +43,27 @@ import { useRelayerConfig, } from "@/shared/hooks/useGaslessRelayer"; import { useDelegators } from "@/shared/hooks/useDelegators"; +import daoConfigByDaoId from "@/shared/dao-config"; import { DaoIdEnum } from "@/shared/types/daos"; import { formatNumberUserReadable } from "@/shared/utils/formatNumberUserReadable"; +import { + isUserRejection, + mapRelayerError, +} from "@/shared/utils/gaslessRelayerError"; + +const VOTE_NUMBER = { for: 1, against: 0, abstain: 2 } as const; + +const SUPPORT_LABEL: Record = { + 1: "for", + 0: "against", + 2: "abstain", +}; + +const NOTHING_SENT: VoteSettlement = { + kind: "done", + outcome: "failed", + hash: null, +}; interface VotingModalProps { isOpen: boolean; @@ -48,9 +86,11 @@ export const VotingModal = ({ }: VotingModalProps) => { const [vote, setVote] = useState(""); const [comment, setComment] = useState(""); - const [isLoading, setIsLoading] = useState(false); const [transactionhash, setTransactionhash] = useState(""); - const [isSuccess, setIsSuccess] = useState(false); + // A vote that landed from this open of the modal. Where votes can be + // changed the store offers the ballot again, so this keeps the success + // screen up until the modal is closed. + const [hasLandedHere, setHasLandedHere] = useState(false); const { isMobile } = useScreenSize(); @@ -114,6 +154,25 @@ export const VotingModal = ({ const { address, chain } = useAccount(); const { data: walletClient } = useWalletClient(); + const daoConfig = daoConfigByDaoId[daoId]; + const daoChain = daoConfig.daoOverview.chain; + // The relayed vote is read and confirmed on the DAO's own chain. The wallet + // never sent it, and its provider may be on another chain or refuse the call. + const publicClient = usePublicClient({ chainId: daoChain.id }); + const queryClient = useQueryClient(); + + const canChangeVote = daoConfig.daoOverview.rules?.changeVote ?? false; + const { + key: voteKey, + submission, + details: voteDetails, + } = useVoteSubmission(daoId, proposal?.id, address); + const screen = hasLandedHere + ? "landed" + : getVoteScreen(submission, { + canChangeVote, + alreadyVoted: voteDetails.alreadyVoted, + }); const { delegators: tornDelegators, loading: isLoadingTornDelegators, @@ -180,16 +239,22 @@ export const VotingModal = ({ const { isEligible: isGaslessEligible, remaining: voteRemaining } = useGaslessEligibility(daoId, address, "vote"); - // Reset state when modal opens to prevent stale data from previous sessions + // Opening starts a fresh ballot only when a new submission may start. A + // vote that is out, landed or in doubt keeps its screen: resetting it here + // used to put the ballot back while the relayer already had the vote. useEffect(() => { - if (isOpen) { - setVote(""); - setComment(""); - setIsLoading(false); - setTransactionhash(""); - setIsSuccess(false); + if (!isOpen) return; + setHasLandedHere(false); + if ( + voteKey !== null && + !canStartVote(readSubmission(voteKey), canChangeVote) + ) { + return; } - }, [isOpen]); + setVote(""); + setComment(""); + setTransactionhash(""); + }, [isOpen, voteKey, canChangeVote]); // Prevent body scroll when desktop modal is open useEffect(() => { @@ -222,29 +287,109 @@ export const VotingModal = ({ }, [isOpen, onClose, isMobile]); const handleSubmit = async () => { - if (!address || !chain || !walletClient) return; + if (!address || !chain || !walletClient || !voteKey) return; + if (vote !== "for" && vote !== "against" && vote !== "abstain") return; // Fail closed: an errored or still-paginating delegator query leaves a // partial list that would otherwise read as complete and cast without the // delegated voting power shown for this account. if (isTorn && (isTornDelegatorListIncomplete || tornDelegatorsError)) return; - setIsLoading(true); - const hash = await voteOnProposal( - vote as "for" | "against" | "abstain", - proposal?.id as string, - { address, type: "json-rpc" }, - chain, - daoId, - walletClient, - setTransactionhash, - comment, - minVotingPower, - isGaslessEligible, - tornDelegatedVoteAddresses, - ); - setIsLoading(false); - if (hash) { - setIsSuccess(true); + + const voteNumber = VOTE_NUMBER[vote]; + // castVoteBySig carries no reason, so a vote with a comment goes through + // the wallet rather than dropping the comment. + const isGasless = + isGaslessEligible && + daoConfig.gaslessRelayer === true && + comment.trim().length === 0; + if (isGasless && !publicClient) return; + + // Taken before the first await, so a double click or a reopened modal + // finds the vote claimed and sends nothing. + if ( + !claimVote( + voteKey, + isGasless ? "gasless" : "wallet", + voteNumber, + canChangeVote, + ) + ) { + return; + } + setTransactionhash(""); + + let settlement: VoteSettlement = NOTHING_SENT; + try { + if (isGasless && publicClient) { + const outcome = await relayGaslessVote({ + daoId, + proposalId: proposal.id, + support: voteNumber, + account: { address, type: "json-rpc" }, + signer: walletClient, + publicClient, + onTxSubmitted: setTransactionhash, + }); + settlement = settlementForGaslessVote(outcome); + + if (outcome.hash) { + void queryClient.invalidateQueries({ + queryKey: getRateLimitQueryKey( + daoId.toLowerCase() as GetRateLimitPathParamsDaoEnumKey, + address, + ), + }); + } + if (outcome.status === "reverted") { + showCustomToast( + "Your vote was mined but reverted on-chain.", + "error", + ); + } + } else { + const receipt = await voteOnProposal( + vote, + proposal.id, + { address, type: "json-rpc" }, + chain, + daoId, + walletClient, + setTransactionhash, + comment, + tornDelegatedVoteAddresses, + ); + if (receipt?.status === "reverted") { + showCustomToast("Your vote transaction reverted on-chain.", "error"); + } + settlement = settlementForWalletVote( + receipt?.status === "success" ? receipt.transactionHash : null, + ); + } + } catch (error) { + // relayGaslessVote rejects only when nothing was sent: a declined + // signature, or a relayer refusal before broadcasting. Both are safe to + // retry, so the ballot comes back. + console.error(error); + if (!isUserRejection(error)) { + showCustomToast( + mapRelayerError(error, { + operation: "vote", + minVotingPower, + decimals: daoConfig.decimals, + symbol: daoConfig.name, + }), + "error", + ); + } + } finally { + settleVote(voteKey, settlement); + if ( + settlement.kind === "done" && + settlement.outcome === "landed" && + !settlement.alreadyVoted + ) { + setHasLandedHere(true); + } } }; @@ -253,7 +398,8 @@ export const VotingModal = ({ !chain || !vote || !walletClient || - isLoading || + !voteKey || + screen !== "ballot" || (isTorn && (isTornDelegatorListIncomplete || tornDelegatorsError !== null)) || !rawVotingPower || @@ -273,10 +419,7 @@ export const VotingModal = ({