From 0fa41de85cf5935bd4e8b43f93cecebecffb34cf Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:57:51 -0400 Subject: [PATCH] feat: add governance proposal write flows (#16325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add governance proposal write flows * fix: pass tokenSymbol as prop instead of reading from config Co-Authored-By: Claude Sonnet 4.6 * fix: remove canConnect from ProposalWritePanel — replaced by authenticated check Co-Authored-By: Claude Sonnet 4.6 * fix: add snapshot block range to queryFilter and use live time for canExecute - Use proposalSnapshot() to bound queryFilter calls — avoids full chain scan - Replace server-rendered latestTimestamp with live Date.now() for canExecute - Show loading state while vote status fetches instead of "0 UP" - Use governanceConfig.chainName in vote success toast Co-Authored-By: Claude Sonnet 4.6 * fix: auto-update execute timer and show error state in vote status - Use useState + useEffect interval to tick now every 60s while Queued - Show 'Unavailable' + error message when voteStatusQuery fails - Remove unnecessary fragment wrapper in early return Co-Authored-By: Claude Sonnet 4.6 * fix: bound queryFilter to voting period and track pending vote button - Add proposalDeadline() as toBlock in queryFilter — prevents RPC range errors on proposals older than the provider's block cap (~2,000 blocks) - Track pendingSupport to show loading only on the clicked vote button - Tighten canExecute timer interval from 60s to 15s Co-Authored-By: Claude Sonnet 4.6 * fix: query subgraph for user vote instead of queryFilter queryFilter over the full voting period (300k+ blocks on Base) exceeds most RPC provider block-range caps. Query the subgraph vote entity directly using the - key instead. Co-Authored-By: Claude Sonnet 4.6 * fix: use GraphQL variables in subgraph query and check tx receipt status - Use variables: { id } instead of string interpolation to prevent query injection - Check receipt.status === 0 to surface on-chain reverts to the user Co-Authored-By: Claude Sonnet 4.6 * fix: throw on subgraph failure to prevent false-positive canVote fetchVoteFromSubgraph now throws on non-200 or GraphQL errors instead of returning null, so voteStatusQuery.isError becomes true and vote buttons stay disabled when vote status cannot be confirmed. Co-Authored-By: Claude Sonnet 4.6 * fix: disable lifecycle button during pending mutation and clean up minor issues - Add actionMutation.isPending to Queue/Execute button disabled state - Use cached getRpcProvider() instead of allocating a new one per query - Remove dead receipt.status checks (ethers v6 throws on revert) Co-Authored-By: Claude Sonnet 4.6 * fix: optimistic vote status update and show tx hash in toasts - Optimistically set query cache on vote success instead of refetching — subgraph lags 1-10 min behind chain state so immediate refetch returns stale data and re-enables vote buttons - Include tx hash prefix in submission toasts so users can track on explorer Co-Authored-By: Claude Sonnet 4.6 * fix: toast ordering and document subgraph vote ID format - 'submitted' toast fires before tx.wait(); 'confirmed' fires in onSuccess - Add comment citing the subgraph createVote() source for vote ID format Co-Authored-By: Claude Sonnet 4.6 * fix: consistent button label during timelock wait and invalidate after action - Show 'Waiting for timelock' on button when Queued but not yet executable - Invalidate voteStatusQuery after queue/execute so state refreshes Co-Authored-By: Claude Sonnet 4.6 * fix: prevent double-submit, clean error messages, and add subgraph timeout - Disable Queue/Execute button after isSuccess (prevents race during refresh) - Map ACTION_REJECTED and verbose ethers errors to clean user messages - Add 10s AbortSignal timeout to subgraph fetch - Remove address! assertion — add explicit null check in queryFn Co-Authored-By: Claude Sonnet 4.6 * fix: remove actionMutation.isSuccess from lifecycle button and add support bounds check After a successful Queue, isSuccess persists across router.refresh() since the component does not unmount. This left the Execute button permanently disabled once the timelock elapsed. Dropping isSuccess lets canExecute alone gate the button per the state machine. Also validates vote.support is in [0,1,2] before returning to guard against unexpected subgraph values. Co-Authored-By: Claude Sonnet 4.6 * fix: use ?? for votingPower fallback and type-safe ACTION_REJECTED check 0n is falsy so || 0n would replace a real zero with 0n unnecessarily — use ?? instead. Also switch to ethers v6 isError() for the ACTION_REJECTED guard and strip verbose RPC parenthetical data from error messages shown to users. Co-Authored-By: Claude Sonnet 4.6 * feat: render proposal markdown, fix connect button, reorganize layout, truncate IDs - Render proposal description as Markdown using react-markdown + prose styles - Fix "Connect wallet" button to use useConnectModal (same as header) instead of bare Privy login() — fixes the button doing nothing when clicked - Move ProposalWritePanel to top of right column, Lifecycle second, Governance settings last; add items-start so columns align at top - Remove Lifecycle section from left column (it's now in the right column) - Add TruncatedId component: shows first/last 4 chars with a copy button that stops link navigation propagation (safe inside cards) - Use TruncatedId in ProposalCard and proposal detail page Co-Authored-By: Claude Sonnet 4.6 * feat: 2-col layout from top, fix mobile order, human-friendly durations Layout: - Remove full-width header card; 2-column grid now starts at the top of
- DOM order [header → aside → main] so on mobile: title shows first, then cast vote panel, then vote breakdown/calls (no deep scroll to find actions) - aside uses lg:row-span-2 to span both rows on desktop, staying alongside both the header and the breakdown sections - Reduce padding on mobile (p-5 sm:p-8), title (text-2xl sm:text-4xl) - Vote breakdown always 3 columns (grid-cols-3) — panels are compact enough Formatting: - Add formatDuration() — converts seconds to "4d 2h 30m" style - Use formatDuration() for voting delay and voting period in governance settings Co-Authored-By: Claude Sonnet 4.6 * fix: address latest claude review on PR #16325 - TruncatedId: wrap clipboard.writeText in try/catch (fails silently in non-HTTPS or when permission is denied) - ReactMarkdown: add custom link renderer with target=_blank and rel=noopener noreferrer (proposal descriptions are user-controlled) - fetchVoteFromSubgraph: validate json.data exists before accessing .vote to catch malformed subgraph responses that have no data and no errors - formatDuration: add comment explaining seconds are intentionally dropped for multi-day periods Co-Authored-By: Claude Sonnet 4.6 * fix: address four issues from latest claude review on PR #16325 - Remove router.refresh() from vote onSuccess — RSC data doesn't change when a vote is cast; calling refresh raced against the optimistic update and could overwrite it with stale subgraph data, re-enabling vote buttons - ReactMarkdown href: only allow http/https schemes; render non-http links as plain to block data: and other non-http schemes from on-chain proposal descriptions - TruncatedId: store setTimeout ID in a ref and clear it on unmount to avoid updating state on an unmounted component - formatDuration: omit seconds when hours or days are present (not just days); update comment to match the actual cutoff Co-Authored-By: Claude Sonnet 4.6 * fix: address three issues from latest claude review on PR #16325 - ReactMarkdown: add img component filter with same http/https allowlist as the a component — blocks tracking pixels, fingerprinting, and NSFW images from on-chain proposal descriptions - ProposalWritePanel: guard BigInt(value || '0') to avoid SyntaxError on empty or non-integer strings from proposal.values - Add comment to ProposalWritePanel early-return explaining the no-hooks constraint that makes the pattern safe Co-Authored-By: Claude Sonnet 4.6 * fix: vote buttons always 3-column, small size to fit in narrow aside sm:grid-cols-3 never activated inside the 360px aside — switched to grid-cols-3 unconditionally and size=small so labels fit on one line. Co-Authored-By: Claude Sonnet 4.6 * fix: force-wrap long strings in proposal description on mobile Add break-words to the prose container so hex addresses, hashes, and other unbreakable strings in on-chain proposal descriptions wrap instead of overflowing the viewport on narrow screens. Co-Authored-By: Claude Sonnet 4.6 * fix: hide cast vote section when voting is not available Show the cast vote box only when state === Active or the user has already voted (so their vote record remains visible). Hide it entirely for Pending, Succeeded, Queued, Executed, Defeated, Canceled, etc. Co-Authored-By: Claude Sonnet 4.6 * fix: show clear revert reason for queue/execute failures toUserMessage was stripping the revert reason via a parenthesis regex — ethers v6 CALL_EXCEPTION errors carry a clean reason field, use it directly. For other errors, take only the first line instead of regex-stripping. Add a pre-flight on-chain state check before queue/execute: reads the governor's state() view and throws a human-readable error if the on-chain state doesn't match the expected one (catches subgraph lag mismatches before the user wastes gas on a doomed transaction). Co-Authored-By: Claude Sonnet 4.6 * feat(governance): hide vote form when no voting power, show delegation nudge When a connected user has zero voting power at the proposal snapshot, replace the cast-vote section with a WalletStateCard explaining the situation. If the user held tokens but had not delegated, include a link to /delegates so they can participate in future proposals. Co-Authored-By: Claude Sonnet 4.6 * feat(governance): hide empty lifecycle rows, explain not-yet-queued state Instead of showing "Not available" for Executed/Canceled rows that have no data, hide those rows entirely. For Queued/ETA, show an actionable message when the proposal has Succeeded but hasn't been queued yet. Co-Authored-By: Claude Sonnet 4.6 * fix(governance): hide lifecycle action when no action available; strip markdown heading from title - Only render the Lifecycle action section when canQueue or state===Queued; hide it for Pending/Active/Defeated/Canceled/Expired/Executed states. - Also hide the action button while waiting for timelock (no action to take). - Strip leading markdown heading markers (##, #, etc.) from proposal titles extracted from on-chain description text. Co-Authored-By: Claude Sonnet 4.6 * fix(governance): show vote confirmation instead of disabled form; remove duplicate title - Replace the disabled vote form with a WalletStateCard confirmation when the user has already voted on a proposal. - Strip the first line of the proposal description before rendering markdown, since the title is already shown as a separate

. Co-Authored-By: Claude Sonnet 4.6 * fix(governance): verify Succeeded/Queued proposals against on-chain state The subgraph-derived state can disagree with governor.state() — e.g. a proposal appears Succeeded via vote math but is Defeated on-chain due to quorum differences, or a Queued proposal is actually Expired on-chain. After deriving local states, fetch governor.state() for all proposals marked Succeeded or Queued and override with the authoritative on-chain result. Also adds Expired to ProposalState and ProposalStateBadge. Co-Authored-By: Claude Sonnet 4.6 * fix(governance): never show vote form after voting; show date + tx hash - Replace userSupport (nullable int) with castVote (object with support, createdAt, transactionHash) so the voted state is unambiguous. - Gate the vote form behind !isLoading to prevent it flashing for in-flight queries on Active proposals. - When castVote is set, show a confirmation card with the vote direction, date, and transaction hash instead of any form. - Fetch createdAt and transactionHash from the subgraph alongside support. - Optimistic cache after voting includes approximate timestamp; subgraph provides the real values on the next fetch. Co-Authored-By: Claude Sonnet 4.6 * feat(governance): link all transaction hashes to Basescan explorer Add txExplorerUrl() helper to governance config and use it in: - Vote confirmation card (full tx hash, links to basescan.org/tx/) - Governance settings "Transaction" row on proposal detail page Co-Authored-By: Claude Sonnet 4.6 * fix(governance): use hasVoted() on-chain to gate vote form, not subgraph alone The subgraph can lag by minutes after a vote is cast. Add governor.hasVoted() to the parallel fetch — if on-chain confirms a vote exists but the subgraph hasn't indexed it yet, return a partial castVote so the form is never shown. Show "Vote recorded / Confirming on the subgraph…" until details arrive. Co-Authored-By: Claude Sonnet 4.6 * feat(governance): improve proposal detail UI — explorer links, Address component, quorum bar - Vote confirmation: show "View in block explorer" link instead of raw tx hash - Governance settings: "Proposal submission transaction" link instead of truncated hash - Proposer chip: use @unlock-protocol/ui Address component with Basescan external link - Quorum section: replace raw numbers with a progress bar; show "Reached" badge when quorum is met, otherwise show remaining votes needed - Add addressExplorerUrl() helper to governance config Co-Authored-By: Claude Sonnet 4.6 * fix(governance): wrap Address in client component to fix server component build error The @unlock-protocol/ui bundle lacks "use client" directives, causing a Next.js build error when Address is imported in a server component. Add a thin 'use client' wrapper (AddressLink) and use it in the proposal detail page. Co-Authored-By: Claude Sonnet 4.6 * fix: address review nits — disabled button, canExecute guard, regex, url validation - Add disabled={actionMutation.isPending} to queue/execute button - Clarify canExecute: etaSeconds !== null && BigInt(etaSeconds) > 0n - Fix description regex to handle \r\n line endings - Validate tx hash and address format in explorer URL helpers Co-Authored-By: Claude Sonnet 4.6 * fix: address claude review — remark-gfm, image stripping, ON_CHAIN_STATE dedup, regex fix - Add remark-gfm plugin for GFM support (tables, strikethrough, task lists) - Strip all images in proposal markdown — user-controlled on-chain content poses tracking pixel risk to every visitor - Drop http:// links (only https:// allowed) to prevent mixed-content - Fix description regex to handle single-line descriptions (no newline): /^[^\r\n]*[\r\n]*/ now matches even without trailing newline - Export ON_CHAIN_STATE from proposals.ts; remove duplicate stateLabels map in ProposalWritePanel.tsx - Use BigInt(value || 0) instead of BigInt(value || '0') for numeric safety Co-Authored-By: Claude Sonnet 4.6 * fix: address claude review — conditional tx link, vote reason maxLength, canExecute comment - Render proposal submission transaction link conditionally (not href='#' fallback) when txExplorerUrl returns null - Add maxLength={1000} to vote reason TextBox with updated description text - Add inline comment explaining canExecute uses client-side clock with on-chain pre-flight as the authoritative guard - Add inline comment explaining description first-line strip to avoid confusion Co-Authored-By: Claude Sonnet 4.6 * fix: address claude review — anchor links, TruncatedId label prop, setInterval cleanup - Allow #anchor links in ReactMarkdown (they were blocked by https-only filter) - Add label prop to TruncatedId (default 'Copy full ID'); callers pass 'Copy full proposal ID' explicitly — prevents stale label if reused for hashes - Stop setInterval once canExecute is true (ETA has passed) to avoid indefinite 15s ticks after the Execute button appears Co-Authored-By: Claude Sonnet 4.6 * fix: anchor links should not open in a new tab #anchor links were getting target=\"_blank\" which opened a new tab where the fragment couldn't resolve. Now only https:// external links get target=\"_blank\"; #anchor links stay in the same tab. Also add comment explaining why blocked http:/etc links render as plain text without an error indicator. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- governance-app/app/proposals/[id]/page.tsx | 440 +++++++++------ governance-app/package.json | 4 +- governance-app/src/components/AddressLink.tsx | 5 + governance-app/src/components/TruncatedId.tsx | 61 ++ .../src/components/proposals/ProposalCard.tsx | 8 +- .../proposals/ProposalStateBadge.tsx | 1 + .../proposals/ProposalWritePanel.tsx | 523 ++++++++++++++++++ governance-app/src/config/governance.ts | 11 + governance-app/src/lib/governance/format.ts | 20 + .../src/lib/governance/proposals.ts | 38 +- governance-app/src/lib/governance/subgraph.ts | 4 +- governance-app/src/lib/governance/types.ts | 1 + yarn.lock | 4 +- 13 files changed, 950 insertions(+), 170 deletions(-) create mode 100644 governance-app/src/components/AddressLink.tsx create mode 100644 governance-app/src/components/TruncatedId.tsx create mode 100644 governance-app/src/components/proposals/ProposalWritePanel.tsx diff --git a/governance-app/app/proposals/[id]/page.tsx b/governance-app/app/proposals/[id]/page.tsx index 665ae583d39..b2c25726c2b 100644 --- a/governance-app/app/proposals/[id]/page.tsx +++ b/governance-app/app/proposals/[id]/page.tsx @@ -1,12 +1,17 @@ import { notFound } from 'next/navigation' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { AddressLink } from '~/components/AddressLink' +import { TruncatedId } from '~/components/TruncatedId' import { ProposalStateBadge } from '~/components/proposals/ProposalStateBadge' import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' +import { ProposalWritePanel } from '~/components/proposals/ProposalWritePanel' import { formatDateTime, + formatDuration, formatRelativeTime, formatTokenAmount, percentage, - truncateAddress, } from '~/lib/governance/format' import { decodeProposalCalldatas, @@ -14,6 +19,7 @@ import { getProposalById, } from '~/lib/governance/proposals' import { isExecutable } from '~/lib/governance/state' +import { addressExplorerUrl, txExplorerUrl } from '~/config/governance' export const dynamic = 'force-dynamic' @@ -50,202 +56,255 @@ export default async function ProposalDetailPage({ : 'Not queued' return ( -
-
-
-
-
+ // 3-item DOM order: [header] → [aside] → [main] + // Mobile stacks in that order: title → cast vote → breakdown/calls + // Desktop: 2-col grid — header (col1 row1), aside (col2 row1-2), main (col1 row2) +
+ {/* Col 1, Row 1 — proposal header */} +
+
+
+
- Proposal {proposal.id} + Proposal{' '} +
-

+

{proposal.title}

-

- {proposal.description} -

+
+ { + const isExternal = href?.startsWith('https://') + const isAnchor = href?.startsWith('#') + if (isExternal) { + return ( + + {children} + + ) + } + if (isAnchor) { + // Same-page anchor — no target="_blank" (would open a + // new tab without the fragment resolving correctly). + return {children} + } + return {children} + }, + // Strip all images — proposal content is user-controlled + // on-chain data; any https:// image is a potential tracking + // pixel or fingerprinting beacon shown to every visitor. + img: ({ alt }) => {alt}, + }} + > + {/* Strip the first line (the title) — it's already shown in + the

above. Handles \n, \r\n, and single-line bodies. */} + {proposal.description.replace(/^[^\r\n]*[\r\n]*/, '')} + +

-
- Proposed by {truncateAddress(proposal.proposer)} +
+ Proposed by{' '} +
-
+
-
-
-
-
-

- Vote breakdown -

-
- Quorum counts {overview.tokenSymbol} votes for + abstain -
-
-
- - - -
-
-
- Quorum progress -
-
- {formatTokenAmount(quorumVotes)} /{' '} - {formatTokenAmount(proposal.quorum)} {overview.tokenSymbol} -
-
-
+ {/* Col 2, Rows 1–2 — actions sidebar (shown after title on mobile) */} + -
-

- Proposed calls -

-
- {decodedCalls.map((call, index) => ( -
-
-
- Call {index + 1} + {/* Col 1, Row 2 — vote breakdown + proposed calls */} +
+
+

+ Vote breakdown +

+
+ + + +
+ +
+ +
+

+ Proposed calls +

+
+ {decodedCalls.map((call, index) => ( +
+
+
+ Call {index + 1} +
+
+ Value: {formatTokenAmount(call.value)} ETH +
+
+ {call.kind === 'decoded' ? ( +
+
+ {call.contractLabel}.{call.functionName}()
-
- Value: {formatTokenAmount(call.value)} ETH +
+ {call.args.length + ? call.args.join(', ') + : 'No arguments'}
- {call.kind === 'decoded' ? ( -
-
- {call.contractLabel}.{call.functionName}() -
-
- {call.args.length - ? call.args.join(', ') - : 'No arguments'} -
-
- ) : ( -
-
Target: {call.target}
-
- {call.calldata} -
+ ) : ( +
+
{call.target}
+
+ {call.calldata}
- )} -
- ))} -
-
-
- -
-
- + ))} +
+
- + ) } catch (error) { console.error('[proposals/[id]/page] governance data load failed:', error) @@ -288,6 +347,57 @@ function TimelineRow({ label, value }: { label: string; value: string }) { ) } +function QuorumPanel({ + quorum, + quorumVotes, + tokenSymbol, +}: { + quorum: bigint + quorumVotes: bigint + tokenSymbol: string +}) { + const reached = quorum === 0n || quorumVotes >= quorum + const progressPct = + quorum === 0n + ? 100 + : Math.min(100, Number((quorumVotes * 10000n) / quorum) / 100) + + return ( +
+
+
+ Quorum +
+ {reached && ( + + Reached + + )} +
+
+
+
+
+ {reached ? ( + <> + {formatTokenAmount(quorumVotes)} {tokenSymbol} voted (quorum:{' '} + {formatTokenAmount(quorum)}) + + ) : ( + <> + {formatTokenAmount(quorumVotes)} / {formatTokenAmount(quorum)}{' '} + {tokenSymbol} — {formatTokenAmount(quorum - quorumVotes)} more + needed + + )} +
+
+ ) +} + function DetailRow({ label, value }: { label: string; value: string }) { return (
diff --git a/governance-app/package.json b/governance-app/package.json index 12d85d2c07f..1dd325b00f8 100644 --- a/governance-app/package.json +++ b/governance-app/package.json @@ -16,7 +16,9 @@ "ethers": "6.15.0", "next": "14.2.35", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-markdown": "10.1.0", + "remark-gfm": "4.0.1" }, "devDependencies": { "@types/node": "22.13.10", diff --git a/governance-app/src/components/AddressLink.tsx b/governance-app/src/components/AddressLink.tsx new file mode 100644 index 00000000000..7e2f0b8895a --- /dev/null +++ b/governance-app/src/components/AddressLink.tsx @@ -0,0 +1,5 @@ +// ABOUTME: Client wrapper for the @unlock-protocol/ui Address component. +// ABOUTME: Required because the UI package bundle lacks "use client" directives. +'use client' + +export { Address as AddressLink } from '@unlock-protocol/ui' diff --git a/governance-app/src/components/TruncatedId.tsx b/governance-app/src/components/TruncatedId.tsx new file mode 100644 index 00000000000..1e15094f899 --- /dev/null +++ b/governance-app/src/components/TruncatedId.tsx @@ -0,0 +1,61 @@ +// ABOUTME: Client component that displays a truncated ID with a copy-to-clipboard button. +// Shows first and last N characters separated by "…" and copies the full value on click. +'use client' + +import { useEffect, useRef, useState } from 'react' +import { MdContentCopy as CopyIcon, MdCheck as CheckIcon } from 'react-icons/md' + +type TruncatedIdProps = { + id: string + keep?: number + label?: string +} + +export function TruncatedId({ + id, + keep = 4, + label = 'Copy full ID', +}: TruncatedIdProps) { + const [copied, setCopied] = useState(false) + const resetTimerRef = useRef | null>(null) + + useEffect(() => { + return () => { + if (resetTimerRef.current) clearTimeout(resetTimerRef.current) + } + }, []) + + const display = + id.length <= keep * 2 + 1 ? id : `${id.slice(0, keep)}…${id.slice(-keep)}` + + async function handleCopy(e: React.MouseEvent) { + e.preventDefault() + e.stopPropagation() + try { + await navigator.clipboard.writeText(id) + setCopied(true) + if (resetTimerRef.current) clearTimeout(resetTimerRef.current) + resetTimerRef.current = setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard API unavailable (non-HTTPS or permission denied) — fail silently. + } + } + + return ( + + {display} + + + ) +} diff --git a/governance-app/src/components/proposals/ProposalCard.tsx b/governance-app/src/components/proposals/ProposalCard.tsx index e1bd5a550c1..33e713ae39c 100644 --- a/governance-app/src/components/proposals/ProposalCard.tsx +++ b/governance-app/src/components/proposals/ProposalCard.tsx @@ -1,4 +1,5 @@ import Link from 'next/link' +import { TruncatedId } from '~/components/TruncatedId' import { formatDateTime, formatRelativeTime, @@ -37,7 +38,12 @@ export function ProposalCard({
- Proposal {proposal.id} + Proposal{' '} +

diff --git a/governance-app/src/components/proposals/ProposalStateBadge.tsx b/governance-app/src/components/proposals/ProposalStateBadge.tsx index 9c501f2eba0..154b3cdd9a5 100644 --- a/governance-app/src/components/proposals/ProposalStateBadge.tsx +++ b/governance-app/src/components/proposals/ProposalStateBadge.tsx @@ -5,6 +5,7 @@ const stateClassNames: Record = { Canceled: 'bg-slate-200 text-slate-700', Defeated: 'bg-rose-100 text-rose-700', Executed: 'bg-sky-100 text-sky-700', + Expired: 'bg-slate-200 text-slate-700', Pending: 'bg-amber-100 text-amber-700', Queued: 'bg-violet-100 text-violet-700', Succeeded: 'bg-teal-100 text-teal-700', diff --git a/governance-app/src/components/proposals/ProposalWritePanel.tsx b/governance-app/src/components/proposals/ProposalWritePanel.tsx new file mode 100644 index 00000000000..91eaa4e53c7 --- /dev/null +++ b/governance-app/src/components/proposals/ProposalWritePanel.tsx @@ -0,0 +1,523 @@ +'use client' + +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Button, TextBox, ToastHelper } from '@unlock-protocol/ui' +import { Contract, isError } from 'ethers' +import { useRouter } from 'next/navigation' +import { governanceEnv } from '~/config/env' +import { governanceConfig, txExplorerUrl } from '~/config/governance' +import { useConnectModal } from '~/hooks/useConnectModal' +import { useGovernanceWallet } from '~/hooks/useGovernanceWallet' +import { + formatDateTime, + formatRelativeTime, + formatTokenAmount, +} from '~/lib/governance/format' +import { + getTokenContract, + getRpcProvider, + governorAbi, +} from '~/lib/governance/rpc' +import { ON_CHAIN_STATE } from '~/lib/governance/proposals' +import type { ProposalState } from '~/lib/governance/types' + +type ProposalWritePanelProps = { + calldatas: string[] + descriptionHash: string + etaSeconds: string | null + proposalId: string + state: ProposalState + targets: string[] + tokenSymbol: string + values: string[] +} + +type CastVote = { + // null when voted on-chain but subgraph hasn't indexed the vote yet + support: number | null + createdAt: bigint + transactionHash: string +} + +type VoteStatus = { + castVote: CastVote | null + tokenBalance: bigint + votingPower: bigint +} + +// ProposalWritePanel intentionally contains no hooks — the early-return guard +// below is only safe because of this. All hooks live in ProposalWritePanelConnected. +export function ProposalWritePanel(props: ProposalWritePanelProps) { + if (!governanceEnv.privyAppId) { + return ( + + ) + } + + return +} + +function ProposalWritePanelConnected({ + calldatas, + descriptionHash, + etaSeconds, + proposalId, + state, + targets, + tokenSymbol, + values, +}: ProposalWritePanelProps) { + const { address, authenticated, getSigner, isReady } = useGovernanceWallet() + const { openConnectModal } = useConnectModal() + const router = useRouter() + const queryClient = useQueryClient() + const [reason, setReason] = useState('') + const [pendingSupport, setPendingSupport] = useState<0 | 1 | 2 | null>(null) + // Tick every 15s while Queued so canExecute activates without a reload. + // The interval stops once canExecute becomes true (ETA has passed). + const [now, setNow] = useState(() => BigInt(Math.floor(Date.now() / 1000))) + useEffect(() => { + if (state !== 'Queued') return + if ( + etaSeconds !== null && + BigInt(etaSeconds) > 0n && + now >= BigInt(etaSeconds) + ) + return + const id = setInterval( + () => setNow(BigInt(Math.floor(Date.now() / 1000))), + 15_000 + ) + return () => clearInterval(id) + }, [state, etaSeconds, now]) + + const voteStatusQuery = useQuery({ + enabled: Boolean(address), + queryKey: ['proposal-vote-status', proposalId, address], + queryFn: async (): Promise => { + const governor = new Contract( + governanceConfig.governorAddress, + governorAbi, + getRpcProvider() + ) + // Query votingPower and the user's cast vote in parallel. + // Vote lookup uses the subgraph to avoid queryFilter block-range limits. + const snapshotBlock = (await governor.proposalSnapshot( + BigInt(proposalId) + )) as bigint + if (!address) return { castVote: null, tokenBalance: 0n, votingPower: 0n } + const [votingPower, tokenBalance, hasVoted, castVoteFromSubgraph] = + await Promise.all([ + governor.getVotes(address, snapshotBlock) as Promise, + getTokenContract().balanceOf(address) as Promise, + governor.hasVoted(BigInt(proposalId), address) as Promise, + fetchVoteFromSubgraph(proposalId, address), + ]) + + // hasVoted is authoritative — if on-chain says voted but subgraph hasn't + // indexed it yet, return a partial castVote so the form is never shown. + const castVote = + castVoteFromSubgraph ?? + (hasVoted + ? { support: null, createdAt: 0n, transactionHash: '' } + : null) + + return { castVote, tokenBalance, votingPower } + }, + }) + + const voteMutation = useMutation({ + mutationFn: async (support: 0 | 1 | 2) => { + setPendingSupport(support) + // getSigner() calls ensureBaseNetwork() which switches to Base or throws. + const signer = await getSigner() + const governor = new Contract( + governanceConfig.governorAddress, + governorAbi, + signer + ) + const tx = reason.trim() + ? await governor.castVoteWithReason( + BigInt(proposalId), + support, + reason.trim() + ) + : await governor.castVote(BigInt(proposalId), support) + + ToastHelper.success(`Vote submitted — tx: ${tx.hash.slice(0, 10)}…`) + await tx.wait() + }, + onError: (error) => { + setPendingSupport(null) + ToastHelper.error(toUserMessage(error, 'Unable to cast vote.')) + }, + onSuccess: async (_, support) => { + ToastHelper.success(`Vote confirmed on ${governanceConfig.chainName}.`) + setPendingSupport(null) + setReason('') + // Optimistically update the query cache — the subgraph lags behind chain + // state, so refetching immediately would return stale data and re-enable + // the vote buttons. Setting the cache directly keeps the UI consistent. + // router.refresh() is intentionally omitted here: RSC page data does not + // change when a vote is cast, and calling it would race against the + // optimistic update by triggering a background refetch of stale data. + queryClient.setQueryData( + ['proposal-vote-status', proposalId, address], + (prev: VoteStatus | undefined) => + prev + ? { + ...prev, + castVote: { + support, + // Approximate timestamp; subgraph will have the real value on next fetch. + createdAt: BigInt(Math.floor(Date.now() / 1000)), + transactionHash: '', + }, + } + : undefined + ) + }, + }) + + const actionMutation = useMutation({ + mutationFn: async (action: 'queue' | 'execute') => { + const signer = await getSigner() + const governor = new Contract( + governanceConfig.governorAddress, + governorAbi, + signer + ) + + // Pre-flight: read on-chain state to give a clear error before spending + // gas. The subgraph state can lag, so this catches state mismatches early. + const onChainState = (await governor.state(BigInt(proposalId))) as bigint + const expectedState = action === 'queue' ? 4n : 5n // 4=Succeeded, 5=Queued + if (onChainState !== expectedState) { + const label = + ON_CHAIN_STATE[Number(onChainState)] ?? `state ${onChainState}` + throw new Error( + action === 'queue' + ? `Cannot queue: proposal is ${label} on-chain (expected Succeeded).` + : `Cannot execute: proposal is ${label} on-chain (expected Queued).` + ) + } + + const tx = + action === 'queue' + ? await governor.queue( + targets, + values.map((value) => BigInt(value || 0)), + calldatas, + descriptionHash + ) + : await governor.execute( + targets, + values.map((value) => BigInt(value || 0)), + calldatas, + descriptionHash + ) + + ToastHelper.success( + `${action === 'queue' ? 'Queue' : 'Execution'} submitted — tx: ${tx.hash.slice(0, 10)}…` + ) + await tx.wait() + }, + onError: (error) => { + ToastHelper.error(toUserMessage(error, 'Unable to submit action.')) + }, + onSuccess: async (_, action) => { + ToastHelper.success( + action === 'queue' ? 'Proposal queued.' : 'Proposal executed.' + ) + queryClient.invalidateQueries({ + queryKey: ['proposal-vote-status', proposalId, address], + }) + router.refresh() + }, + }) + + const castVote = voteStatusQuery.data?.castVote ?? null + + const canQueue = state === 'Succeeded' + // canExecute uses the client-side clock — if subgraph state lags, the button + // may appear prematurely. The on-chain pre-flight in actionMutation.mutationFn + // is the authoritative check and will revert before any gas is spent. + const canExecute = + state === 'Queued' && + etaSeconds !== null && + BigInt(etaSeconds) > 0n && + now >= BigInt(etaSeconds) + + return ( + <> + {!authenticated ? ( + Connect wallet} + title="Connect a wallet to interact with this proposal" + description="Voting, queueing, and execution all require a connected wallet on Base." + /> + ) : null} + + {authenticated && !address ? ( + + ) : null} + + {address ? ( + <> + {/* Already voted — show confirmation with date and tx. Never show form. */} + {castVote !== null ? ( +
+

+ Your vote +

+
+
+ {castVote.support !== null + ? supportLabel(castVote.support) + : 'Vote recorded'} +
+ {castVote.support === null && ( +

+ Confirming on the subgraph… +

+ )} + {castVote.createdAt > 0n && ( +

+ {formatDateTime(castVote.createdAt)} +

+ )} + {castVote.transactionHash && + txExplorerUrl(castVote.transactionHash) && ( + + View in block explorer + + )} +
+
+ ) : /* While loading, show nothing to avoid a flash of the vote form. */ + voteStatusQuery.isLoading ? null : state === 'Active' ? ( + /* No voting power — either no tokens or tokens not delegated. */ + !voteStatusQuery.isError && + voteStatusQuery.data?.votingPower === 0n ? ( + 0n + ? `You held ${formatTokenAmount(voteStatusQuery.data.tokenBalance)} ${tokenSymbol} but had not delegated at the proposal snapshot. Delegate your tokens before the next proposal to participate.` + : `You held no ${tokenSymbol} at the proposal snapshot block.` + } + action={ + voteStatusQuery.data.tokenBalance > 0n ? ( + + ) : undefined + } + /> + ) : ( +
+

+ Cast vote +

+
+
+ Voting power at snapshot +
+
+ {voteStatusQuery.isError + ? 'Unavailable' + : `${formatTokenAmount(voteStatusQuery.data?.votingPower ?? 0n)} ${tokenSymbol}`} +
+

+ {voteStatusQuery.isError + ? 'Could not load vote status. Check your connection and reload.' + : 'Choose For, Against, or Abstain and optionally include a reason.'} +

+
+ +
+ setReason(event.target.value)} + placeholder="Share your rationale" + rows={4} + value={reason} + /> +
+ +
+ + + +
+
+ ) + ) : null} + + {(canQueue || state === 'Queued') && ( +
+

+ Lifecycle action +

+
+
+ {canQueue + ? 'Queue proposal' + : canExecute + ? 'Execute proposal' + : 'Waiting for timelock'} +
+

+ {canQueue + ? 'This proposal passed and can now be queued in the timelock.' + : canExecute + ? 'The timelock delay has elapsed. Execution is now available.' + : etaSeconds + ? `Execution unlocks ${formatRelativeTime(BigInt(etaSeconds), now)}.` + : 'Queued — waiting for the timelock ETA to be set.'} +

+
+ + {(canQueue || canExecute) && ( +
+ +
+ )} +
+ )} + + ) : null} + + ) +} + +function WalletStateCard({ + action, + description, + title, +}: { + action?: ReactNode + description: string + title: string +}) { + return ( +
+

{title}

+

+ {description} +

+ {action ?
{action}
: null} +
+ ) +} + +async function fetchVoteFromSubgraph( + proposalId: string, + voter: string +): Promise { + // Vote ID in the subgraph is "-". + // Format defined in subgraph/src/governance.ts createVote(): + // new Vote(proposalId.toString().concat('-').concat(voter.toHexString())) + const id = `${proposalId}-${voter.toLowerCase()}` + // Use variables to avoid GraphQL string injection. + const query = `query ($id: ID!) { vote(id: $id) { support createdAt transactionHash } }` + const response = await fetch(governanceConfig.subgraphUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables: { id } }), + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) + throw new Error(`Subgraph request failed: ${response.status}`) + const json = await response.json() + if (json?.errors?.length) + throw new Error(`Subgraph error: ${json.errors[0].message}`) + // Validate that the response has the expected shape before reading data. + if (!json?.data) throw new Error('Unexpected subgraph response: missing data') + // null means the vote entity does not exist — user has not voted. + const vote = json.data.vote + if (!vote) return null + const support = Number(vote.support) + if (![0, 1, 2].includes(support)) return null + return { + support, + createdAt: BigInt(vote.createdAt ?? 0), + transactionHash: vote.transactionHash ?? '', + } +} + +function toUserMessage(error: unknown, fallback: string): string { + if (!(error instanceof Error)) return fallback + // ethers ACTION_REJECTED = user cancelled the wallet popup + if (isError(error, 'ACTION_REJECTED')) return 'Transaction rejected.' + // ethers CALL_EXCEPTION carries a clean reason string — use it directly. + if (isError(error, 'CALL_EXCEPTION') && error.reason) return error.reason + // For other errors, strip only verbose RPC context that appears after a + // newline, keeping the first line which usually has the useful message. + const firstLine = error.message.split('\n')[0].trim() + return firstLine.slice(0, 160) || fallback +} + +function supportLabel(support: number) { + if (support === 0) { + return 'Against' + } + + if (support === 1) { + return 'For' + } + + return 'Abstain' +} diff --git a/governance-app/src/config/governance.ts b/governance-app/src/config/governance.ts index 3f17e4cfc11..d19932eadd4 100644 --- a/governance-app/src/config/governance.ts +++ b/governance-app/src/config/governance.ts @@ -22,6 +22,7 @@ export const governanceConfig = { 'https://subgraph.unlock-protocol.com/8453', timelockAddress: '0xB34567C4cA697b39F72e1a8478f285329A98ed1b', tokenAddress: '0xaC27fa800955849d6D17cC8952Ba9dD6EAA66187', + explorerUrl: 'https://basescan.org', knownContracts: [ { label: 'UPGovernor', abi: UPGovernor, kind: 'governor' }, { label: 'UPToken', abi: UPToken, kind: 'token' }, @@ -31,6 +32,16 @@ export const governanceConfig = { ], } as const +export function txExplorerUrl(hash: string) { + if (!/^0x[0-9a-fA-F]{64}$/.test(hash)) return null + return `${governanceConfig.explorerUrl}/tx/${hash}` +} + +export function addressExplorerUrl(address: string) { + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return null + return `${governanceConfig.explorerUrl}/address/${address}` +} + export const governanceRoutes = [ { href: '/', label: 'Home' }, { href: '/proposals', label: 'Proposals' }, diff --git a/governance-app/src/lib/governance/format.ts b/governance-app/src/lib/governance/format.ts index 06b12956125..58222878231 100644 --- a/governance-app/src/lib/governance/format.ts +++ b/governance-app/src/lib/governance/format.ts @@ -58,6 +58,26 @@ export function formatRelativeTime(timestamp: bigint, now: bigint) { return 'just now' } +export function formatDuration(seconds: bigint | number) { + const s = Number(seconds) + if (s <= 0) return '0 seconds' + + const days = Math.floor(s / 86400) + const hours = Math.floor((s % 86400) / 3600) + const minutes = Math.floor((s % 3600) / 60) + const secs = s % 60 + + const parts: string[] = [] + if (days) parts.push(`${days}d`) + if (hours) parts.push(`${hours}h`) + if (minutes) parts.push(`${minutes}m`) + // Seconds are shown only for sub-hour durations — once hours or days are + // present the precision is not useful for governance delay/period display. + if (secs && !days && !hours) parts.push(`${secs}s`) + + return parts.join(' ') +} + export function truncateAddress(address: string, size = 4) { if (address.length <= size * 2 + 2) { return address diff --git a/governance-app/src/lib/governance/proposals.ts b/governance-app/src/lib/governance/proposals.ts index 66e01cc3ca9..b137e030f78 100644 --- a/governance-app/src/lib/governance/proposals.ts +++ b/governance-app/src/lib/governance/proposals.ts @@ -10,8 +10,21 @@ import type { DecodedCalldata, GovernanceOverview, ProposalRecord, + ProposalState, } from './types' +// Maps the numeric IProposalState enum returned by governor.state() to our type. +export const ON_CHAIN_STATE: Record = { + 0: 'Pending', + 1: 'Active', + 2: 'Canceled', + 3: 'Defeated', + 4: 'Succeeded', + 5: 'Queued', + 6: 'Expired', + 7: 'Executed', +} + export const getGovernanceOverview = cache( async (): Promise => { const governor = getGovernorContract() @@ -31,11 +44,34 @@ export const getGovernanceOverview = cache( const rawProposals = await getProposalsFromSubgraph(proposalThreshold) - const proposals: ProposalRecord[] = rawProposals.map((proposal) => ({ + const derived = rawProposals.map((proposal) => ({ ...proposal, state: deriveProposalState(proposal, latestTimestamp), })) + // Verify actionable states (Succeeded / Queued) against on-chain reality. + // The subgraph quorum/vote data can diverge from the governor's own state() + // (e.g. proposal is Defeated on-chain but appears Succeeded via vote math). + const actionable = derived.filter( + (p) => p.state === 'Succeeded' || p.state === 'Queued' + ) + const onChainStates = await Promise.all( + actionable.map((p) => + (governor.state(BigInt(p.id)) as Promise).catch(() => null) + ) + ) + const overrides = new Map() + actionable.forEach((p, i) => { + const raw = onChainStates[i] + if (raw === null) return + const mapped = ON_CHAIN_STATE[Number(raw)] + if (mapped && mapped !== p.state) overrides.set(p.id, mapped) + }) + + const proposals: ProposalRecord[] = derived.map((p) => + overrides.has(p.id) ? { ...p, state: overrides.get(p.id)! } : p + ) + return { latestTimestamp, proposalThreshold, diff --git a/governance-app/src/lib/governance/subgraph.ts b/governance-app/src/lib/governance/subgraph.ts index 6a19f880335..cc5b96e2c0f 100644 --- a/governance-app/src/lib/governance/subgraph.ts +++ b/governance-app/src/lib/governance/subgraph.ts @@ -73,7 +73,9 @@ async function fetchSubgraph(query: string): Promise { } function getTitle(description: string) { - return description.split('\n')[0]?.trim() || 'Untitled proposal' + const firstLine = description.split('\n')[0]?.trim() || 'Untitled proposal' + // Strip leading markdown heading markers (e.g. "## Title" → "Title") + return firstLine.replace(/^#+\s*/, '') || 'Untitled proposal' } export const getProposalsFromSubgraph = cache( diff --git a/governance-app/src/lib/governance/types.ts b/governance-app/src/lib/governance/types.ts index 26199708a23..6abf5726e63 100644 --- a/governance-app/src/lib/governance/types.ts +++ b/governance-app/src/lib/governance/types.ts @@ -6,6 +6,7 @@ export type ProposalState = | 'Queued' | 'Executed' | 'Canceled' + | 'Expired' export type DecodedCalldata = | { diff --git a/yarn.lock b/yarn.lock index e642d485843..54347e57705 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22501,6 +22501,8 @@ __metadata: postcss: "npm:8.4.49" react: "npm:18.3.1" react-dom: "npm:18.3.1" + react-markdown: "npm:10.1.0" + remark-gfm: "npm:4.0.1" tailwindcss: "npm:3.4.18" typescript: "npm:5.8.3" peerDependencies: @@ -50114,7 +50116,7 @@ __metadata: languageName: node linkType: hard -"remark-gfm@npm:^4.0.0": +"remark-gfm@npm:4.0.1, remark-gfm@npm:^4.0.0": version: 4.0.1 resolution: "remark-gfm@npm:4.0.1" dependencies: