Skip to content

Commit 0fa41de

Browse files
julien51claude
andauthored
feat: add governance proposal write flows (unlock-protocol#16325)
* feat: add governance proposal write flows * fix: pass tokenSymbol as prop instead of reading from config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove canConnect from ProposalWritePanel — replaced by authenticated check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <proposalId>-<lowercaseAddress> key instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <Link> cards) - Use TruncatedId in ProposalCard and proposal detail page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <main> - 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 <noreply@anthropic.com> * fix: address latest claude review on PR unlock-protocol#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 <noreply@anthropic.com> * fix: address four issues from latest claude review on PR unlock-protocol#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 <span> 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 <noreply@anthropic.com> * fix: address three issues from latest claude review on PR unlock-protocol#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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <h2>. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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/<hash>) - Governance settings "Transaction" row on proposal detail page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d240c4b commit 0fa41de

13 files changed

Lines changed: 950 additions & 170 deletions

File tree

governance-app/app/proposals/[id]/page.tsx

Lines changed: 275 additions & 165 deletions
Large diffs are not rendered by default.

governance-app/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
"ethers": "6.15.0",
1717
"next": "14.2.35",
1818
"react": "18.3.1",
19-
"react-dom": "18.3.1"
19+
"react-dom": "18.3.1",
20+
"react-markdown": "10.1.0",
21+
"remark-gfm": "4.0.1"
2022
},
2123
"devDependencies": {
2224
"@types/node": "22.13.10",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// ABOUTME: Client wrapper for the @unlock-protocol/ui Address component.
2+
// ABOUTME: Required because the UI package bundle lacks "use client" directives.
3+
'use client'
4+
5+
export { Address as AddressLink } from '@unlock-protocol/ui'
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// ABOUTME: Client component that displays a truncated ID with a copy-to-clipboard button.
2+
// Shows first and last N characters separated by "…" and copies the full value on click.
3+
'use client'
4+
5+
import { useEffect, useRef, useState } from 'react'
6+
import { MdContentCopy as CopyIcon, MdCheck as CheckIcon } from 'react-icons/md'
7+
8+
type TruncatedIdProps = {
9+
id: string
10+
keep?: number
11+
label?: string
12+
}
13+
14+
export function TruncatedId({
15+
id,
16+
keep = 4,
17+
label = 'Copy full ID',
18+
}: TruncatedIdProps) {
19+
const [copied, setCopied] = useState(false)
20+
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
21+
22+
useEffect(() => {
23+
return () => {
24+
if (resetTimerRef.current) clearTimeout(resetTimerRef.current)
25+
}
26+
}, [])
27+
28+
const display =
29+
id.length <= keep * 2 + 1 ? id : `${id.slice(0, keep)}${id.slice(-keep)}`
30+
31+
async function handleCopy(e: React.MouseEvent) {
32+
e.preventDefault()
33+
e.stopPropagation()
34+
try {
35+
await navigator.clipboard.writeText(id)
36+
setCopied(true)
37+
if (resetTimerRef.current) clearTimeout(resetTimerRef.current)
38+
resetTimerRef.current = setTimeout(() => setCopied(false), 2000)
39+
} catch {
40+
// Clipboard API unavailable (non-HTTPS or permission denied) — fail silently.
41+
}
42+
}
43+
44+
return (
45+
<span className="inline-flex items-center gap-1">
46+
<span className="font-mono">{display}</span>
47+
<button
48+
aria-label={label}
49+
className="text-brand-ui-primary/40 transition-colors hover:text-brand-ui-primary"
50+
onClick={handleCopy}
51+
type="button"
52+
>
53+
{copied ? (
54+
<CheckIcon className="text-green-500" size={14} />
55+
) : (
56+
<CopyIcon size={14} />
57+
)}
58+
</button>
59+
</span>
60+
)
61+
}

governance-app/src/components/proposals/ProposalCard.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Link from 'next/link'
2+
import { TruncatedId } from '~/components/TruncatedId'
23
import {
34
formatDateTime,
45
formatRelativeTime,
@@ -37,7 +38,12 @@ export function ProposalCard({
3738
<div className="flex flex-wrap items-center gap-3">
3839
<ProposalStateBadge state={proposal.state} />
3940
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-ui-primary/45">
40-
Proposal {proposal.id}
41+
Proposal{' '}
42+
<TruncatedId
43+
id={proposal.id}
44+
keep={4}
45+
label="Copy full proposal ID"
46+
/>
4147
</span>
4248
</div>
4349
<h2 className="text-2xl font-semibold text-brand-ui-primary">

governance-app/src/components/proposals/ProposalStateBadge.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const stateClassNames: Record<ProposalState, string> = {
55
Canceled: 'bg-slate-200 text-slate-700',
66
Defeated: 'bg-rose-100 text-rose-700',
77
Executed: 'bg-sky-100 text-sky-700',
8+
Expired: 'bg-slate-200 text-slate-700',
89
Pending: 'bg-amber-100 text-amber-700',
910
Queued: 'bg-violet-100 text-violet-700',
1011
Succeeded: 'bg-teal-100 text-teal-700',

0 commit comments

Comments
 (0)