diff --git a/.changeset/dashboard-gasless-queue-execute.md b/.changeset/dashboard-gasless-queue-execute.md new file mode 100644 index 0000000000..30709ccc14 --- /dev/null +++ b/.changeset/dashboard-gasless-queue-execute.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": minor +--- + +Queue and execute proposals through the relayer for free when it is funded, with the wallet flow as fallback. diff --git a/apps/dashboard/features/governance/components/modals/GovernanceActionModal.tsx b/apps/dashboard/features/governance/components/modals/GovernanceActionModal.tsx index 752bb4cd72..c00278a06d 100644 --- a/apps/dashboard/features/governance/components/modals/GovernanceActionModal.tsx +++ b/apps/dashboard/features/governance/components/modals/GovernanceActionModal.tsx @@ -1,25 +1,87 @@ "use client"; -import { Check, Hourglass, PenLine } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import type { Address } from "viem"; -import { useAccount, useWalletClient } from "wagmi"; +import { proposalQueryKey, proposalsQueryKey } from "@anticapture/client/hooks"; +import type { ProposalPathParamsDaoEnumKey } from "@anticapture/client"; +import { useQueryClient } from "@tanstack/react-query"; +import { Check, ExternalLink, Hourglass, PenLine, Zap } from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { Address, Hash } from "viem"; +import { useAccount, usePublicClient, useWalletClient } from "wagmi"; import type { ProposalViewData } from "@/features/governance/types"; +import { + getStatusPollStep, + STATUS_POLL_MS, +} from "@/features/governance/utils/proposalStatusPolling"; +import { + canRelayGovernanceAction, + getRelayBlockedReason, + relayGovernanceAction, +} from "@/features/governance/utils/relayGovernanceAction"; import { showCustomToast } from "@/features/governance/utils/showCustomToast"; +import { + canSubmitAgain, + getModalEntryPoint, + needsProposalWatch, + SUBMISSION_FAILED, + type ActionMode, + type SettledSubmission, + type SubmissionState, + type WatchedSubmission, +} from "@/features/governance/utils/submissionState"; +import { + readSubmission, + submissionKey, + subscribeToSubmission, + writeSubmission, +} from "@/features/governance/utils/submissionStore"; +import { runWalletSubmission } from "@/features/governance/utils/walletSubmission"; import { executeProposal, queueProposal, type GovernanceAction, } from "@/features/governance/utils/submitGovernanceAction"; +import { InlineAlert } from "@/shared/components/design-system/alerts/inline-alert/InlineAlert"; +import { Button } from "@/shared/components/design-system/buttons/button/Button"; +import { DividerDefault } from "@/shared/components/design-system/divider/DividerDefault"; import { Modal } from "@/shared/components/design-system/modal/Modal"; import { SpinIcon } from "@/shared/components/icons/SpinIcon"; -import { DividerDefault } from "@/shared/components/design-system/divider/DividerDefault"; -import { cn } from "@/shared/utils/cn"; import daoConfigByDaoId from "@/shared/dao-config"; +import { useGaslessEnactment } from "@/shared/hooks/useGaslessRelayer"; import type { DaoIdEnum } from "@/shared/types/daos"; +import { cn } from "@/shared/utils/cn"; +import { + getRelayerRevertedHash, + mapRelayerEnactmentError, +} from "@/shared/utils/gaslessRelayerError"; -type ActionStep = "waiting-signature" | "pending-tx" | "success" | "error"; +/** + * "idle" lasts only while the relayer balance query settles. "choose" is + * reached when the relayer can sponsor the action: the user picks between the + * free path and their own wallet. Without a relayer the modal opens straight + * into the wallet flow, as it always did. + * + * "ambiguous" is every outcome that is neither a success nor a failure: a + * receipt that could not be read, a relayer that never answered, a send whose + * response was lost. It offers no retry, since a second submission could + * duplicate a governor call that is already on its way, and it shows an + * explorer link only when a hash exists. + */ +type ActionStep = + | "idle" + | "choose" + | "waiting-signature" + | "relaying" + | "pending-tx" + | "success" + | "ambiguous" + | "error"; interface GovernanceActionModalProps { isOpen: boolean; @@ -29,6 +91,39 @@ interface GovernanceActionModalProps { daoId: DaoIdEnum; } +const ACTION_COPY: Record< + GovernanceAction, + { + title: string; + verb: string; + pastTense: string; + walletStep: string; + relayStep: string; + } +> = { + queue: { + title: "Confirm Queue", + verb: "Queue", + pastTense: "queued", + walletStep: "Confirm queuing in your wallet", + relayStep: "Relayer submits the queue transaction (free)", + }, + execute: { + title: "Confirm Execution", + verb: "Execute", + pastTense: "executed", + walletStep: "Confirm execution in your wallet", + relayStep: "Relayer submits the execute transaction (free)", + }, +}; + +const REVERTED_MESSAGE = + "The transaction was mined but reverted on-chain. The proposal state did not change."; +const CONNECT_WALLET_MESSAGE = + "Connect a wallet to pay for this transaction yourself."; + +const shortenHash = (hash: string) => `${hash.slice(0, 10)}…${hash.slice(-8)}`; + export const GovernanceActionModal = ({ isOpen, onClose, @@ -36,26 +131,160 @@ export const GovernanceActionModal = ({ proposal, daoId, }: GovernanceActionModalProps) => { - const [step, setStep] = useState("waiting-signature"); + const [step, setStep] = useState("idle"); + const [mode, setMode] = useState("wallet"); const [error, setError] = useState(null); + const [txHash, setTxHash] = useState(null); + const [hasStarted, setHasStarted] = useState(false); + // The action a submission is still waiting to see indexed. Non-null while + // the proposal is being refetched; cleared once the action's successor + // status arrives or the attempt budget runs out. + const [awaitedAction, setAwaitedAction] = useState( + null, + ); + + // What is known about this proposal action, held outside the component so + // it survives the route unmounting while a request is still unresolved. The + // store is read synchronously wherever a decision depends on it, because + // the check that stops a second submission cannot wait for a render. + const stateKey = submissionKey(daoId, proposal.id, action); + const subscribe = useCallback( + (onChange: () => void) => subscribeToSubmission(stateKey, onChange), + [stateKey], + ); + const readState = useCallback(() => readSubmission(stateKey), [stateKey]); + const submission = useSyncExternalStore(subscribe, readState, readState); + + const moveSubmission = useCallback( + (next: SubmissionState) => writeSubmission(stateKey, next), + [stateKey], + ); + + // True once a submission was started from this mount. A mount that inherits + // someone else's request renders from the store instead, since the run that + // owns the screen belongs to a component that may be gone. + const ownsRunRef = useRef(false); + + // Claims the action for one submission. Dismissing the modal never releases + // it: the request carries on, and a wallet transaction started from a + // reopened modal would race whatever is already out there. + const startSubmission = useCallback( + (mode: ActionMode): boolean => { + if (!canSubmitAgain(readSubmission(stateKey))) return false; + ownsRunRef.current = true; + moveSubmission({ kind: "in-flight", mode }); + return true; + }, + [moveSubmission, stateKey], + ); + + // Frees the action unless the submission ended somewhere no retry is safe. + // What it sent is recorded with it, because a mount that inherits this + // state never saw the run and has to know whether a transaction exists. + const finishSubmission = useCallback( + (settled: SettledSubmission) => { + const current = readSubmission(stateKey); + if (current.kind !== "in-flight") return; + moveSubmission({ kind: "done", mode: current.mode, ...settled }); + }, + [moveSubmission, stateKey], + ); + + // Refetches issued for the current polling run, the immediate one at the + // start included. Held in a ref so a status that changes mid-run restarts + // the timer without refilling the budget. + const pollAttemptsRef = useRef(0); const { address } = useAccount(); const chain = daoConfigByDaoId[daoId].daoOverview.chain; const { data: walletClient } = useWalletClient({ chainId: chain.id }); + const publicClient = usePublicClient({ chainId: chain.id }); + const queryClient = useQueryClient(); - const title = action === "queue" ? "Confirm Queue" : "Confirm Execution"; - const confirmLabel = - action === "queue" - ? "Confirm queuing in your wallet" - : "Confirm execution in your wallet"; + const { isAvailable: isGaslessAvailable, isLoading: isGaslessLoading } = + useGaslessEnactment(daoId); + const relayBlockedReason = getRelayBlockedReason(action, proposal.status); + const canRelay = + isGaslessAvailable && canRelayGovernanceAction(action, proposal.status); - const handleAction = useCallback(async () => { + const copy = ACTION_COPY[action]; + const explorerBaseUrl = chain.blockExplorers?.default?.url; + + const refreshProposal = useCallback(() => { + const daoKey = daoId.toLowerCase() as ProposalPathParamsDaoEnumKey; + void queryClient.invalidateQueries({ + queryKey: proposalQueryKey(daoKey, proposal.id), + }); + void queryClient.invalidateQueries({ + queryKey: proposalsQueryKey(daoKey), + }); + }, [queryClient, daoId, proposal.id]); + + // Keep refetching until the submitted action's own successor status is + // indexed. Any other status, including a transient one the API serves when + // its RPC reads fail, keeps the poll alive. Runs independently of the modal + // being open, so a user who closes right after the receipt still gets the + // page updated. + useEffect(() => { + if (awaitedAction === null) return; + + const pollStep = () => + getStatusPollStep({ + action: awaitedAction, + proposalStatus: proposal.status, + attempts: pollAttemptsRef.current, + }); + + if (pollStep() !== "keep-polling") { + setAwaitedAction(null); + return; + } + + const interval = setInterval(() => { + // Checked against the refetches already issued, then counted, so the + // budget covers the immediate one below as well as these. + if (pollStep() !== "keep-polling") { + setAwaitedAction(null); + return; + } + pollAttemptsRef.current += 1; + refreshProposal(); + }, STATUS_POLL_MS); + return () => clearInterval(interval); + }, [awaitedAction, proposal.status, refreshProposal]); + + const startStatusPolling = useCallback(() => { + // This first refetch counts towards the budget, so the run is exactly + // STATUS_POLL_MAX_ATTEMPTS refetches spanning the two minutes promised. + pollAttemptsRef.current = 1; + setAwaitedAction(action); + refreshProposal(); + }, [action, refreshProposal]); + + // Terminal for this action: the page keeps polling so it updates if the + // transaction does land, the screen shows whatever is known about it, and + // no further submission can start from this modal. + const showAmbiguousOutcome = useCallback( + (mode: ActionMode, hash: Hash | null) => { + // Both the screen and the proposal watch come from the effect that + // follows the store, so every mount treats this outcome identically and + // there is one place that can forget to do either. + moveSubmission({ kind: "ambiguous", mode, hash }); + }, + [moveSubmission], + ); + + const runWalletAction = useCallback(async () => { if (!address || !walletClient) return; - if (step === "pending-tx" || step === "success") return; + if (!startSubmission("wallet")) return; + setMode("wallet"); setError(null); + setTxHash(null); setStep("waiting-signature"); + let settled: SettledSubmission = SUBMISSION_FAILED; + try { const handler = action === "queue" ? queueProposal : executeProposal; const targets = proposal.targets ?? []; @@ -71,57 +300,309 @@ export const GovernanceActionModal = ({ } return acc; }, []); - await handler( - validIndices.map((i) => targets[i] as Address), - validIndices.map((i) => values[i] as string), - validIndices.map((i) => calldatas[i] as Address), - proposal.description ?? "", - address, - daoId, - walletClient, - () => setStep("pending-tx"), - proposal.id, + const outcome = await runWalletSubmission((progress) => + handler( + validIndices.map((i) => targets[i] as Address), + validIndices.map((i) => values[i] as string), + validIndices.map((i) => calldatas[i] as Address), + proposal.description ?? "", + address, + daoId, + walletClient, + { + onSendAttempt: progress.onSendAttempt, + onBroadcast: (hash) => { + progress.onBroadcast(hash); + setTxHash(hash); + setStep("pending-tx"); + }, + }, + proposal.id, + ), ); + + if (outcome.status === "ambiguous") { + // The transaction may be on its way, with or without a hash to show + // for it. The proposal is polled exactly as it is on success, and the + // state stays terminal so nothing here can duplicate the call. + showAmbiguousOutcome("wallet", outcome.hash); + return; + } + settled = { + outcome: outcome.status === "reverted" ? "failed" : "landed", + hash: outcome.hash, + }; + if (outcome.status === "reverted") { + setTxHash(outcome.hash); + setError(REVERTED_MESSAGE); + setStep("error"); + return; + } + // A result that lands after the modal was closed still repaints it: the + // screen is the one the user comes back to if they reopen before the + // submission settles, and the page is refreshed either way. + showCustomToast(`Proposal ${copy.pastTense} successfully!`, "success"); + startStatusPolling(); + setTxHash(outcome.hash); setStep("success"); - showCustomToast( - action === "queue" - ? "Proposal queued successfully!" - : "Proposal executed successfully!", - "success", - ); - onClose(); - setTimeout(() => window.location.reload(), 2000); } catch (err) { + // Only pre-broadcast failures reach here: a rejected signature or a + // simulation revert sent nothing, so retrying is safe. const message = err instanceof Error ? (err.message.split("\n")[0]?.slice(0, 120) ?? "Action failed.") : "Action failed."; setError(message); setStep("error"); + } finally { + finishSubmission(settled); } - }, [address, walletClient, step, action, proposal, daoId, onClose, chain]); + }, [ + address, + walletClient, + action, + proposal, + daoId, + copy.pastTense, + startStatusPolling, + startSubmission, + finishSubmission, + showAmbiguousOutcome, + ]); + + const runGaslessAction = useCallback(async () => { + if (!startSubmission("gasless")) return; + + setMode("gasless"); + setError(null); + setTxHash(null); + setStep("relaying"); + let settled: SettledSubmission = SUBMISSION_FAILED; + + try { + const outcome = await relayGovernanceAction({ + action, + daoId, + proposalId: proposal.id, + publicClient, + onTxSubmitted: (hash) => { + setTxHash(hash); + setStep("pending-tx"); + }, + }); + + if (outcome.status === "success" || outcome.status === "reverted") { + settled = { + outcome: outcome.status === "reverted" ? "failed" : "landed", + hash: outcome.hash, + }; + } + if (outcome.status === "success") { + showCustomToast(`Proposal ${copy.pastTense} successfully!`, "success"); + startStatusPolling(); + setStep("success"); + return; + } + if (outcome.status === "reverted") { + setError(REVERTED_MESSAGE); + setStep("error"); + return; + } + // "unconfirmed" carries a hash and "unknown" does not, but neither says + // whether the governor call landed, so both are the same ambiguous + // terminal state: poll the proposal and offer no retry that could + // duplicate a relayed transaction already on its way. + showAmbiguousOutcome("gasless", outcome.hash); + } catch (err) { + // The relayer answered with a definitive rejection or a revert, so + // nothing is pending and the error path with its retry is correct. A + // revert names its transaction in the message, which is the only place + // the hash appears, so the explorer link matches the wallet path. + console.error(err); + // A reported revert was mined and changed nothing, and a refusal never + // reached the chain, so both leave the action retryable. + const revertedHash = getRelayerRevertedHash(err); + settled = { outcome: "failed", hash: revertedHash }; + setTxHash(revertedHash); + setError(mapRelayerEnactmentError(err, action)); + setStep("error"); + } finally { + finishSubmission(settled); + } + }, [ + action, + daoId, + proposal.id, + publicClient, + copy.pastTense, + startStatusPolling, + startSubmission, + finishSubmission, + showAmbiguousOutcome, + ]); + + // Follows the store, which is the only thing a mount can rely on when the + // run it is looking at belongs to a component that is already gone. useEffect(() => { - if (!isOpen || step !== "waiting-signature") return; + // Shows the inherited transaction and picks the proposal watch back up, + // since whoever was watching has finished or been unmounted. A landed + // transaction is confirmed and gets the success screen; only an + // ambiguous outcome gets the screen that claims nothing. The budget + // starts fresh rather than carrying across mounts, which is the right + // reading of a user who has just come back to look. + const inheritAndWatch = (inherited: WatchedSubmission) => { + setMode(inherited.mode); + setTxHash(inherited.hash); + setStep(inherited.kind === "ambiguous" ? "ambiguous" : "success"); + startStatusPolling(); + }; + + // An ambiguous outcome is repainted on every mount, the one that recorded + // it included, so the screen and the store cannot drift apart. A settled + // run is left alone on the mount that made it, which is already showing + // the success or the error it saw and is watching the proposal itself. + const isOwnSettledRun = ownsRunRef.current && submission.kind === "done"; + + if (!isOwnSettledRun && needsProposalWatch(submission)) { + inheritAndWatch(submission); + return; + } + if (ownsRunRef.current) return; + if (submission.kind === "in-flight") { + setMode(submission.mode); + setStep(submission.mode === "gasless" ? "relaying" : "waiting-signature"); + return; + } + // Nothing was ever sent, so the modal goes back through its entry point + // and the action is offered again. + setHasStarted(false); + }, [submission, startStatusPolling]); + + const failWithoutWallet = useCallback((message: string) => { + setMode("wallet"); + setError(message); + setStep("error"); + }, []); + + // Opening decides the entry point once: with a funded relayer the user gets + // to choose, otherwise the wallet flow starts on its own as before. While + // the balance query settles the modal shows a neutral loading state instead + // of a wallet prompt that is not actually in flight. + useEffect(() => { + if (!isOpen) { + setHasStarted(false); + return; + } + if (hasStarted || isGaslessLoading) return; + setHasStarted(true); + + switch ( + getModalEntryPoint({ + submission, + isGaslessAvailable, + hasAddress: Boolean(address), + hasWalletClient: Boolean(walletClient), + }) + ) { + // Reopened on top of a submission that is out, ambiguous, or landed + // and not yet indexed. Its screen is already standing, painted by the + // run that made it or by the effect that follows the store, so nothing + // is offered here that could race or duplicate it. + case "mirror-submission": + return; + case "choose": + setStep("choose"); + return; + case "connect-wallet": + failWithoutWallet(CONNECT_WALLET_MESSAGE); + return; + case "switch-network": + failWithoutWallet( + `Please switch your wallet to the ${chain.name} network.`, + ); + return; + case "wallet": + void runWalletAction(); + return; + } + }, [ + isOpen, + hasStarted, + isGaslessLoading, + isGaslessAvailable, + address, + walletClient, + chain.name, + runWalletAction, + failWithoutWallet, + submission, + ]); + + const handleUseWallet = () => { + // No screen offering this button renders while a submission is pending or + // ambiguous, but the check keeps that invariant local to the action + // rather than spread across the render branches. + if (!canSubmitAgain(readSubmission(stateKey))) return; + if (!address) { + failWithoutWallet(CONNECT_WALLET_MESSAGE); + return; + } if (!walletClient) { - setError(`Please switch your wallet to the ${chain.name} network.`); - setStep("error"); + failWithoutWallet( + `Please switch your wallet to the ${chain.name} network.`, + ); return; } - handleAction(); - }, [isOpen, walletClient, handleAction, step, chain.name]); + void runWalletAction(); + }; const handleClose = () => { - setStep("waiting-signature"); - setError(null); + // A submission that is unresolved or ambiguous keeps its screen: it may + // yet land, so reopening has to show what is known rather than offer a + // submission that could duplicate it. The modal resets only in the states + // where starting another one is allowed anyway. + if (canSubmitAgain(readSubmission(stateKey))) { + setStep("idle"); + setMode("wallet"); + setError(null); + setTxHash(null); + } + setHasStarted(false); onClose(); }; + const isGaslessRun = mode === "gasless"; + const isFinished = step === "success" || step === "ambiguous"; + + const txHashRow = txHash && ( +
+ + Transaction + + {explorerBaseUrl ? ( + + {shortenHash(txHash)} + + + ) : ( + + {txHash} + + )} +
+ ); + return ( !open && handleClose()} - title={title} + title={copy.title} > {/* Proposal info */}
@@ -129,7 +610,9 @@ export const GovernanceActionModal = ({ Proposal ID - {proposal.id} + + {proposal.id} +
@@ -141,25 +624,120 @@ export const GovernanceActionModal = ({
- {/* Stepper */} -
- } - label={confirmLabel} - error={step === "error" ? error : undefined} - /> - - - - } - label="Wait for transaction to complete" - /> -
+ {step === "idle" ? ( +
+ + Checking whether this action can be sponsored... +
+ ) : step === "choose" ? ( +
+ {canRelay ? ( + + ) : ( + + )} +
+ + +
+
+ ) : ( +
+ {/* Stepper */} +
+ + ) : ( + + ) + } + label={isGaslessRun ? copy.relayStep : copy.walletStep} + error={step === "error" ? error : undefined} + /> + + + + } + label="Wait for transaction to complete" + /> +
+ + {txHashRow} + + {step === "ambiguous" && ( + + )} + + {step === "error" && ( +
+ + {isGaslessRun ? ( + + ) : ( + <> + {canRelay && ( + + )} + + + )} +
+ )} + + {isFinished && ( +
+ +
+ )} +
+ )}
); }; diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx index b26d1a4cb5..ce00d588d4 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx @@ -9,12 +9,16 @@ import type { Address } from "viem"; import { OffchainVoteLabelChip } from "@/features/governance/components/proposal-overview/OffchainVoteLabelChip"; import { OffchainVotedChip } from "@/features/governance/components/proposal-overview/OffchainVotedChip"; +import { canRelayGovernanceAction } from "@/features/governance/utils/relayGovernanceAction"; import { BadgeStatus, Button } from "@/shared/components"; import { ReportPanelButton } from "@/shared/components/report/ReportPanelButton"; import { ConnectWalletCustom } from "@/shared/components/wallet/ConnectWalletCustom"; import { WhitelabelConnectWallet } from "@/shared/components/wallet/WhitelabelConnectWallet"; import daoConfigByDaoId from "@/shared/dao-config"; -import { useGaslessEligibility } from "@/shared/hooks/useGaslessRelayer"; +import { + useGaslessEligibility, + useGaslessEnactment, +} from "@/shared/hooks/useGaslessRelayer"; import { DaoIdEnum } from "@/shared/types/daos"; import { getDaoGovernanceListPath } from "@/shared/utils/whitelabel"; @@ -182,36 +186,60 @@ const ProposalExecutionButtons = ({ setIsQueueModalOpen: (isOpen: boolean) => void; setIsExecuteModalOpen: (isOpen: boolean) => void; }) => { - if (!address) return null; + const daoIdEnum = daoId.toUpperCase() as DaoIdEnum; + const { isAvailable: isGaslessAvailable } = useGaslessEnactment(daoIdEnum); + + const isShu = daoIdEnum === DaoIdEnum.SHU; + const isTorn = daoIdEnum === DaoIdEnum.TORN; - const isShu = daoId.toUpperCase() === DaoIdEnum.SHU; - const isTorn = daoId.toUpperCase() === DaoIdEnum.TORN; + // Relayed queue/execute carry no signer, so a funded relayer makes them + // available to a disconnected visitor too. The wallet path still needs an + // address, which the modal asks for when chosen. + const canRelayQueue = + isGaslessAvailable && canRelayGovernanceAction("queue", proposalStatus); + const canRelayExecute = + isGaslessAvailable && canRelayGovernanceAction("execute", proposalStatus); + + const showQueue = + proposalStatus === "succeeded" && !isShu && (!!address || canRelayQueue); + const showExecute = + (proposalStatus === "pending_execution" || + // Azorius (SHU) and Tornado (TORN) proposals are QUEUED while + // timelocked and executing reverts until PENDING_EXECUTION + (proposalStatus === "queued" && !isShu && !isTorn)) && + (!!address || canRelayExecute); return ( <> - {proposalStatus === "succeeded" && !isShu && ( + {showQueue && ( )} - {(proposalStatus === "pending_execution" || - // Azorius (SHU) and Tornado (TORN) proposals are QUEUED while - // timelocked and executing reverts until PENDING_EXECUTION - (proposalStatus === "queued" && !isShu && !isTorn)) && ( + {showExecute && ( )} ); }; +/** Marks an action the relayer will pay for, matching the vote button. */ +export const GaslessBadge = () => ( + + Free + +); + export const ProposalHeader = ({ daoId, votingPower, diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx index fcc09a462f..41172f927c 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx @@ -18,6 +18,7 @@ import { OffchainVotingModal } from "@/features/governance/components/modals/Off import { VotingModal } from "@/features/governance/components/modals/VotingModal"; import { OffchainVoteLabelChip } from "@/features/governance/components/proposal-overview/OffchainVoteLabelChip"; import { + GaslessBadge, getVoteText, type OffchainVoteIndicator, ProposalHeader, @@ -39,6 +40,7 @@ import type { ProposalViewData, } from "@/features/governance/types"; import { isProposalNotFoundError } from "@/features/governance/utils/proposalErrors"; +import { canRelayGovernanceAction } from "@/features/governance/utils/relayGovernanceAction"; import { getOffchainProposalStatusView, normalizeChoices, @@ -55,6 +57,7 @@ import { Button } from "@/shared/components"; import { BlankSlate } from "@/shared/components/design-system/blank-slate/BlankSlate"; import { ConnectWalletCustom } from "@/shared/components/wallet/ConnectWalletCustom"; import daoConfig from "@/shared/dao-config"; +import { useGaslessEnactment } from "@/shared/hooks/useGaslessRelayer"; import { DaoIdEnum } from "@/shared/types/daos"; /** How often to re-ask the API for a vote Snapshot has already accepted. */ @@ -628,6 +631,22 @@ const MobileBottomBar = ({ offchainVote?: OffchainVoteIndicator; }) => { const isOngoing = proposalStatus.toLowerCase() === "ongoing"; + const { isAvailable: isGaslessAvailable } = useGaslessEnactment(daoId); + + const isShu = daoId === DaoIdEnum.SHU; + const isTorn = daoId === DaoIdEnum.TORN; + const isQueueable = proposalStatus === "succeeded" && !isShu; + const isExecutable = + proposalStatus === "pending_execution" || + // Azorius (SHU) and Tornado (TORN) proposals are QUEUED while + // timelocked and executing reverts until PENDING_EXECUTION + (proposalStatus === "queued" && !isShu && !isTorn); + // Relayed queue/execute carry no signer, so a funded relayer makes them + // available to a disconnected visitor too (see ProposalExecutionButtons). + const canRelayQueue = + isGaslessAvailable && canRelayGovernanceAction("queue", proposalStatus); + const canRelayExecute = + isGaslessAvailable && canRelayGovernanceAction("execute", proposalStatus); let content: React.ReactNode = null; @@ -658,30 +677,22 @@ const MobileBottomBar = ({ ); } } + } else if (isQueueable && (address || canRelayQueue)) { + content = ( + + ); + } else if (isExecutable && (address || canRelayExecute)) { + content = ( + + ); } else if (address) { - if ( - proposalStatus === "succeeded" && - daoId.toUpperCase() !== DaoIdEnum.SHU - ) { - content = ( - - ); - } else if ( - proposalStatus === "pending_execution" || - // Azorius (SHU) and Tornado (TORN) proposals are QUEUED while - // timelocked and executing reverts until PENDING_EXECUTION - (proposalStatus === "queued" && - daoId.toUpperCase() !== DaoIdEnum.SHU && - daoId.toUpperCase() !== DaoIdEnum.TORN) - ) { - content = ( - - ); - } else if (supportValue === undefined) { + if (supportValue === undefined) { if (isOngoing) { content = (