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: