From adbd592bac5aea1092747d5ba005c85303865cd0 Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:54:42 -0400 Subject: [PATCH 1/2] =?UTF-8?q?ci:=20fix=20Claude=20Code=20Review=20?= =?UTF-8?q?=E2=80=94=20always=20post=20a=20comment=20(#16341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci: replace opaque code-review plugin with direct review prompt The code-review plugin only posts comments when it finds issues, making it invisible on clean code. Replace with a direct prompt that always posts a review (LGTM or issues found). Also drops the plugin_marketplaces dependency entirely. Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/claude-code-review.yml | 31 +++++++++--------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 254d9d4ce1b..86793b43084 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -3,21 +3,9 @@ name: Claude Code Review on: pull_request: types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - runs-on: ubuntu-latest permissions: contents: read @@ -36,12 +24,17 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - claude_args: '--allowedTools mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr review:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep' - display_report: 'true' use_sticky_comment: 'true' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: '--allowedTools Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr review:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep' + prompt: | + Review the code changes in PR #${{ github.event.pull_request.number }} of this repository. + + Steps: + 1. Run `gh pr diff ${{ github.event.pull_request.number }}` to see the changes + 2. Run `gh pr view ${{ github.event.pull_request.number }}` to see the PR description + 3. Review the changes for bugs, security issues, and code quality problems + 4. Post your review using: + `gh pr review ${{ github.event.pull_request.number }} --comment --body "YOUR_REVIEW"` + Always post a review comment, even if the code looks good (write "LGTM" with a brief summary in that case). + Focus on real issues — not style nitpicks. From 5234db4aac2da3de745fdf45bc0eee7b9f87990f Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:54:11 -0400 Subject: [PATCH 2/2] feat: add governance delegation writes (#16324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add governance delegation writes * fix: pass tokenSymbol as prop to DelegateAccountPanel instead of reading from config Co-Authored-By: Claude Sonnet 4.6 * fix: thread tokenSymbol prop through to DelegateWalletPanel Co-Authored-By: Claude Sonnet 4.6 * fix: replace StateCard wallet-connect states with inline connect button Show a simple "Connect wallet" button (using @unlock-protocol/ui Button) when the user is not authenticated, matching the airdrops site UX pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: resolve ENS on mainnet and Basenames on Base ENS names are registered on Ethereum mainnet; resolving them on Base always fails. Use a mainnet JsonRpcProvider for ENS lookups and fall back to a Base JsonRpcProvider for Basename resolution. Co-Authored-By: Claude Sonnet 4.6 * fix: use shared staging Privy app ID from unlock-app as default Falls back to the same staging Privy app used by unlock-app so wallet connection works locally without setting NEXT_PUBLIC_PRIVY_APP_ID. Co-Authored-By: Claude Sonnet 4.6 * fix: match Privy UI config to unlock-app Use the same loginMethods, embeddedWallets, appearance, and _render settings as unlock-app. Remove the canConnect dead-code guard since privyAppId now always has a fallback value. Co-Authored-By: Claude Sonnet 4.6 * fix: use AddressInput for delegate target field Replace the plain Input with AddressInput from @unlock-protocol/ui, which includes a clear button, wallet icon, and built-in ENS/Basename resolution with debounce. Submit path validates the already-resolved address from the input's onChange callback. Co-Authored-By: Claude Sonnet 4.6 * feat: replace custom app shell header with HeaderNav + wallet connect Use HeaderNav from @unlock-protocol/ui with governance nav links. Add GovernanceHeader client component showing a Connect button when unauthenticated and an address/sign-out menu when connected. Add disconnect() to useGovernanceWallet via Privy's useLogout hook. Co-Authored-By: Claude Sonnet 4.6 * feat: add Footer to governance app shell Use the Footer component from @unlock-protocol/ui with governance- relevant links (DAO, Forum, Snapshot, Docs, Roadmap, Blog). Co-Authored-By: Claude Sonnet 4.6 * feat: add ConnectModal, TermsOfServiceModal, and fix header alignment - ConnectModal: wraps Privy LoginModal with useConnectModal context - TermsOfServiceModal: one-time ToS acceptance persisted in localStorage - ConnectModalProvider: simple open/close state wired to Privy login() - GovernanceHeader: use openConnectModal instead of calling login() directly - GovernanceHeader: wrap HeaderNav in max-w-7xl container to fix full-width alignment Co-Authored-By: Claude Sonnet 4.6 * fix: mark AppShell as client component Footer from @unlock-protocol/ui uses hooks without 'use client', which causes a Next.js build error when imported from a server component. Co-Authored-By: Claude Sonnet 4.6 * fix: constrain footer width to match page layout Wrap Footer in max-w-7xl container to match the header and main content alignment. Co-Authored-By: Claude Sonnet 4.6 * fix: remove inline connect button from delegation panel The header Connect button is the single entry point for wallet connection. Remove the !authenticated branch with its Button, the !address waiting StateCard, and all related hook destructuring (connect, isReady, canConnect). Panel now renders null when no wallet is connected. Also remove StateCard and ReactNode since they are no longer needed. Update HeroCard copy to drop the "Connect a wallet" prompt. Co-Authored-By: Claude Sonnet 4.6 * refactor: collapse delegation panel into page Flatten DelegateAccountPanel + DelegateWalletPanel into app/delegate/page.tsx directly. The component file added indirection with no benefit — one page, one component, all logic in one place. tokenSymbol is now fetched client-side via useEffect so the page can be a client component. Co-Authored-By: Claude Sonnet 4.6 * fix: remove unused canConnect from useGovernanceWallet canConnect was only used in the old DelegateAccountPanel guard which is now deleted. Also removes the governanceEnv import which was only needed for that field. Co-Authored-By: Claude Sonnet 4.6 * fix: remove dead code — getBrowserProvider, wallet, getGovernorInterface None of these are consumed outside their definition files. Inline the provider creation directly into ensureBaseNetwork. Co-Authored-By: Claude Sonnet 4.6 * fix: use ethers ZeroAddress instead of local constant Co-Authored-By: Claude Sonnet 4.6 * feat: move delegation form to /delegates, remove /delegate route Merges the personal delegation form (wallet balance, voting power, delegate management) directly into the /delegates page above the leaderboard. Removes the now-redundant /delegate route. Co-Authored-By: Claude Sonnet 4.6 * fix: self-delegate button and refresh on logout AddressInput manages its own internal state seeded from value on mount and does not re-sync when the value prop changes externally. Use a reset key to force remount when the value is set from outside (self-delegate button or auto-fill from on-chain delegate). Also call router.refresh() on logout so the UI clears correctly. Co-Authored-By: Claude Sonnet 4.6 * fix: remove redundant connected wallet address from delegation form Co-Authored-By: Claude Sonnet 4.6 * fix: remove Personal Delegation label from delegates page header Co-Authored-By: Claude Sonnet 4.6 * ci: fix claude-code-review allowed_tools — use claude_args instead allowed_tools was removed from claude-code-action@v1; the equivalent is now claude_args: '--allowedTools ...'. Without this the review agent cannot post PR comments. Co-Authored-By: Claude Sonnet 4.6 * Revert "ci: fix claude-code-review allowed_tools — use claude_args instead" This reverts commit 4b1451b63743ee04a02cf5e24fe55d537a77b8e9. * fix: hide delegation form when not authenticated Gate on authenticated (from usePrivy) in addition to address. Privy can return a cached wallet address before the session is confirmed, causing the form to show while the header still shows "Connect". Co-Authored-By: Claude Sonnet 4.6 * fix: remove hero card from delegates page Co-Authored-By: Claude Sonnet 4.6 * feat: ENS/Basename resolution and per-row Delegate button in leaderboard - Use
from @unlock-protocol/ui for all address display in leaderboard rows (resolves Basenames then ENS) - Add per-row Delegate button that pre-fills the delegation form via ?delegate= query param - Pre-format bigint values server-side before passing to client rows (bigints can't cross the server→client boundary in Next.js) Co-Authored-By: Claude Sonnet 4.6 * fix: add comments to empty catch blocks to satisfy no-empty lint rule Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- governance-app/app/delegate/page.tsx | 27 -- governance-app/app/delegates/page.tsx | 44 ++- governance-app/app/providers.tsx | 21 +- governance-app/public/images/unlock-logo.svg | 8 + .../src/components/ConnectModal.tsx | 19 ++ .../src/components/TermsOfServiceModal.tsx | 40 +++ .../delegates/DelegateFormSection.tsx | 250 ++++++++++++++++++ .../delegates/DelegateLeaderboardRow.tsx | 89 +++++-- .../src/components/layout/AppShell.tsx | 96 ++++--- .../components/layout/GovernanceHeader.tsx | 68 +++++ governance-app/src/config/env.ts | 5 +- governance-app/src/config/governance.ts | 4 +- governance-app/src/hooks/useConnectModal.tsx | 53 ++++ .../src/hooks/useGovernanceWallet.ts | 60 +++++ governance-app/src/hooks/useTermsOfService.ts | 34 +++ governance-app/src/lib/governance/rpc.ts | 10 +- 16 files changed, 696 insertions(+), 132 deletions(-) delete mode 100644 governance-app/app/delegate/page.tsx create mode 100644 governance-app/public/images/unlock-logo.svg create mode 100644 governance-app/src/components/ConnectModal.tsx create mode 100644 governance-app/src/components/TermsOfServiceModal.tsx create mode 100644 governance-app/src/components/delegates/DelegateFormSection.tsx create mode 100644 governance-app/src/components/layout/GovernanceHeader.tsx create mode 100644 governance-app/src/hooks/useConnectModal.tsx create mode 100644 governance-app/src/hooks/useGovernanceWallet.ts create mode 100644 governance-app/src/hooks/useTermsOfService.ts diff --git a/governance-app/app/delegate/page.tsx b/governance-app/app/delegate/page.tsx deleted file mode 100644 index ac162719a18..00000000000 --- a/governance-app/app/delegate/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import Link from 'next/link' - -export default function DelegatePage() { - return ( -
-

- Personal Delegation -

-

- Wallet-specific delegation comes next -

-

- This slice adds the public delegation leaderboard first. The next - transaction-focused slice will wire connected-wallet delegation status, - self-delegation messaging, and `UPToken.delegate()` writes. -

-
- - View delegate leaderboard - -
-
- ) -} diff --git a/governance-app/app/delegates/page.tsx b/governance-app/app/delegates/page.tsx index 410c48f2d4c..7bdbe934397 100644 --- a/governance-app/app/delegates/page.tsx +++ b/governance-app/app/delegates/page.tsx @@ -1,6 +1,16 @@ +// ABOUTME: Delegates page — personal delegation form (wallet-connected) and +// the full delegate leaderboard ordered by voting power. import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' -import { DelegateLeaderboardRow } from '~/components/delegates/DelegateLeaderboardRow' -import { getDelegateOverview } from '~/lib/governance/delegates' +import { + DelegateLeaderboardRow, + type DelegateRowData, +} from '~/components/delegates/DelegateLeaderboardRow' +import { DelegateFormSection } from '~/components/delegates/DelegateFormSection' +import { + getDelegateOverview, + formatDelegatedShare, +} from '~/lib/governance/delegates' +import { formatTokenAmount } from '~/lib/governance/format' export const dynamic = 'force-dynamic' @@ -8,29 +18,35 @@ export default async function DelegatesPage() { try { const overview = await getDelegateOverview() + const rows: DelegateRowData[] = overview.delegates.map((d, index) => ({ + address: d.address, + rank: index + 1, + votingPower: formatTokenAmount(d.votingPower), + tokenBalance: formatTokenAmount(d.tokenBalance), + delegatorCount: d.delegatorCount, + delegatedShare: formatDelegatedShare(d.votingPower, overview.totalSupply), + })) + return (
+ +

- Delegation Read Path + Delegate Leaderboard

-

- Delegate leaderboard +

+ Top delegates by voting power

Current delegation relationships reconstructed from on-chain - `DelegateChanged` events and hydrated with live voting power from - the UP token contract. + DelegateChanged events and hydrated with live voting power from the + UP token contract.

- {overview.delegates.map((delegate, index) => ( - + {rows.map((row) => ( + ))}
diff --git a/governance-app/app/providers.tsx b/governance-app/app/providers.tsx index abbb96c41d9..832b838e0bb 100644 --- a/governance-app/app/providers.tsx +++ b/governance-app/app/providers.tsx @@ -6,26 +6,27 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ToastProvider, UnlockUIProvider } from '@unlock-protocol/ui' import { useState } from 'react' import { governanceEnv } from '~/config/env' +import { ConnectModalProvider } from '~/hooks/useConnectModal' type ProviderProps = { children: React.ReactNode } function WalletProvider({ children }: ProviderProps) { - if (!governanceEnv.privyAppId) { - return <>{children} - } - return ( @@ -51,9 +52,11 @@ export function Providers({ children }: ProviderProps) { return ( - - {children} - + + + {children} + + ) diff --git a/governance-app/public/images/unlock-logo.svg b/governance-app/public/images/unlock-logo.svg new file mode 100644 index 00000000000..34221b76a0e --- /dev/null +++ b/governance-app/public/images/unlock-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/governance-app/src/components/ConnectModal.tsx b/governance-app/src/components/ConnectModal.tsx new file mode 100644 index 00000000000..6c0213afa59 --- /dev/null +++ b/governance-app/src/components/ConnectModal.tsx @@ -0,0 +1,19 @@ +// ABOUTME: Renders the Privy LoginModal inside an Unlock UI Modal wrapper. +// Visibility is controlled by ConnectModalProvider via useConnectModal. +'use client' + +import { Modal } from '@unlock-protocol/ui' +import { LoginModal } from '@privy-io/react-auth' +import { useConnectModal } from '~/hooks/useConnectModal' + +export function ConnectModal() { + const { open, closeConnectModal } = useConnectModal() + + return ( + +
+ +
+
+ ) +} diff --git a/governance-app/src/components/TermsOfServiceModal.tsx b/governance-app/src/components/TermsOfServiceModal.tsx new file mode 100644 index 00000000000..c3b95dcb5b7 --- /dev/null +++ b/governance-app/src/components/TermsOfServiceModal.tsx @@ -0,0 +1,40 @@ +// ABOUTME: Shows a one-time terms of service acceptance modal on first visit. +// Acceptance is persisted in localStorage so the modal only appears once. +'use client' + +import { Button, Modal } from '@unlock-protocol/ui' +import { useTermsOfService } from '~/hooks/useTermsOfService' + +export function TermsOfServiceModal() { + const { termsAccepted, saveTermsAccepted, termsLoading } = useTermsOfService() + const showTermsModal = !termsLoading && !termsAccepted + + return ( + +
+ + No account required ✨, but you need to agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + + +
+
+ ) +} diff --git a/governance-app/src/components/delegates/DelegateFormSection.tsx b/governance-app/src/components/delegates/DelegateFormSection.tsx new file mode 100644 index 00000000000..90fc44492af --- /dev/null +++ b/governance-app/src/components/delegates/DelegateFormSection.tsx @@ -0,0 +1,250 @@ +// ABOUTME: Client component for the personal delegation form — shows wallet balance, +// voting power, current delegate, and a form to change it. +'use client' + +import { useEffect, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { useMutation, useQuery } from '@tanstack/react-query' +import { AddressInput, Button, ToastHelper } from '@unlock-protocol/ui' +import { + Contract, + getAddress, + isAddress, + JsonRpcProvider, + Network, + ZeroAddress, +} from 'ethers' +import { governanceConfig } from '~/config/governance' +import { formatTokenAmount, truncateAddress } from '~/lib/governance/format' +import { getTokenSymbol, tokenAbi } from '~/lib/governance/rpc' +import { useGovernanceWallet } from '~/hooks/useGovernanceWallet' + +type DelegateAccountState = { + delegatedTo: string + tokenBalance: bigint + votingPower: bigint +} + +export function DelegateFormSection() { + const { address, authenticated, getSigner } = useGovernanceWallet() + const searchParams = useSearchParams() + const [delegateInput, setDelegateInput] = useState( + () => searchParams.get('delegate') ?? '' + ) + const [addressInputKey, setAddressInputKey] = useState(0) + const [tokenSymbol, setTokenSymbol] = useState('UP') + + // AddressInput manages its own internal state initialized from `value` on mount. + // This helper increments the key to force a remount when we set the value externally. + function setDelegateInputExternal(value: string) { + setDelegateInput(value) + setAddressInputKey((k) => k + 1) + } + + // Pre-fill when the ?delegate= query param changes (e.g. from leaderboard row button). + useEffect(() => { + const target = searchParams.get('delegate') + if (target && isAddress(target)) { + setDelegateInputExternal(getAddress(target)) + } + }, [searchParams]) + + useEffect(() => { + getTokenSymbol().then(setTokenSymbol) + }, []) + + const delegationQuery = useQuery({ + enabled: Boolean(address), + queryKey: ['delegate-account', address], + queryFn: async (): Promise => { + const provider = new JsonRpcProvider( + governanceConfig.rpcUrl, + governanceConfig.chainId + ) + const token = new Contract( + governanceConfig.tokenAddress, + tokenAbi, + provider + ) + const [delegatedTo, tokenBalance, votingPower] = await Promise.all([ + token.delegates(address) as Promise, + token.balanceOf(address) as Promise, + token.getVotes(address) as Promise, + ]) + + return { + delegatedTo: getAddress(delegatedTo), + tokenBalance, + votingPower, + } + }, + }) + + useEffect(() => { + if (!delegationQuery.data || delegateInput.trim()) return + const { delegatedTo } = delegationQuery.data + setDelegateInputExternal(delegatedTo === ZeroAddress ? '' : delegatedTo) + }, [delegateInput, delegationQuery.data]) + + const delegateMutation = useMutation({ + mutationFn: async () => { + if (!address) throw new Error('Connect a wallet before delegating.') + const candidate = delegateInput.trim() + if (!candidate) throw new Error('Enter a delegate address.') + if (!isAddress(candidate)) + throw new Error('Enter a valid Ethereum address.') + const resolvedAddress = getAddress(candidate) + const signer = await getSigner() + const token = new Contract( + governanceConfig.tokenAddress, + tokenAbi, + signer + ) + const tx = await token.delegate(resolvedAddress) + ToastHelper.success('Delegation transaction submitted.') + await tx.wait() + return resolvedAddress + }, + onError: (error) => { + ToastHelper.error( + error instanceof Error ? error.message : 'Unable to update delegation.' + ) + }, + onSuccess: async (resolvedAddress) => { + ToastHelper.success( + `Delegation updated to ${truncateAddress(resolvedAddress)}.` + ) + setDelegateInput(resolvedAddress) + await delegationQuery.refetch() + }, + }) + + const delegationState = delegationQuery.data?.delegatedTo || ZeroAddress + const isNotDelegated = delegationState === ZeroAddress + const isSelfDelegated = + !isNotDelegated && delegationState.toLowerCase() === address?.toLowerCase() + + if (!authenticated || !address) return null + + return ( +
+
+
+
+
+ UP balance +
+
+ {formatTokenAmount(delegationQuery.data?.tokenBalance || 0n)}{' '} + {tokenSymbol} +
+
+
+
+ Current voting power +
+
+ {formatTokenAmount(delegationQuery.data?.votingPower || 0n)}{' '} + {tokenSymbol} +
+
+
+ +
+
+ Current delegate +
+
+ {delegationQuery.isLoading + ? 'Loading...' + : isNotDelegated + ? 'Not delegated' + : truncateAddress(delegationState, 6)} +
+

+ {isNotDelegated + ? 'Voting power is inactive until you delegate, including self-delegation.' + : isSelfDelegated + ? 'Your voting power is active and delegated to your own wallet.' + : 'Your voting power is currently delegated to another address.'} +

+
+
+ +
+

+ Change delegate +

+

+ Delegate to an address or ENS name +

+

+ The transaction will prompt your wallet to switch to Base before + submitting if needed. +

+ +
+ setDelegateInput(value)} + onResolveName={resolveNameForInput} + placeholder="vitalik.eth or 0x..." + value={delegateInput} + withIcon + /> +
+ + +
+
+
+
+ ) +} + +async function resolveNameForInput(input: string) { + const candidate = input.trim() + + if (isAddress(candidate)) { + return { type: 'address', address: getAddress(candidate) } + } + + const mainnetProvider = new JsonRpcProvider( + governanceConfig.mainnetRpcUrl, + Network.from(1) + ) + const ensAddress = await mainnetProvider.resolveName(candidate) + if (ensAddress) { + return { type: 'name', address: getAddress(ensAddress) } + } + + const baseProvider = new JsonRpcProvider( + governanceConfig.rpcUrl, + Network.from(8453) + ) + const basenameAddress = await baseProvider.resolveName(candidate) + if (basenameAddress) { + return { type: 'name', address: getAddress(basenameAddress) } + } + + return { type: 'error' } +} diff --git a/governance-app/src/components/delegates/DelegateLeaderboardRow.tsx b/governance-app/src/components/delegates/DelegateLeaderboardRow.tsx index 9464fc87ce2..19b96a026f5 100644 --- a/governance-app/src/components/delegates/DelegateLeaderboardRow.tsx +++ b/governance-app/src/components/delegates/DelegateLeaderboardRow.tsx @@ -1,42 +1,83 @@ -import { formatTokenAmount, truncateAddress } from '~/lib/governance/format' -import { - formatDelegatedShare, - type DelegateRecord, -} from '~/lib/governance/delegates' - -type DelegateLeaderboardRowProps = { - delegate: DelegateRecord +// ABOUTME: Client component for a single delegate leaderboard row. +// Shows rank, address (with ENS/Basename resolution), voting power metrics, +// and a Delegate button that pre-fills the delegation form. +'use client' + +import { Address, Button } from '@unlock-protocol/ui' +import { JsonRpcProvider, Network } from 'ethers' +import { useRouter } from 'next/navigation' +import { governanceConfig } from '~/config/governance' + +export type DelegateRowData = { + address: string rank: number - totalSupply: bigint + votingPower: string + tokenBalance: string + delegatorCount: number + delegatedShare: string +} + +async function resolveAddressName( + address: string +): Promise { + try { + const baseProvider = new JsonRpcProvider( + governanceConfig.rpcUrl, + Network.from(8453) + ) + const basename = await baseProvider.lookupAddress(address) + if (basename) return basename + } catch (_) { + // Basename resolution failed — fall through to ENS + } + + try { + const mainnetProvider = new JsonRpcProvider( + governanceConfig.mainnetRpcUrl, + Network.from(1) + ) + const ensName = await mainnetProvider.lookupAddress(address) + if (ensName) return ensName + } catch (_) { + // ENS resolution failed — return undefined + } + + return undefined } export function DelegateLeaderboardRow({ - delegate, + address, rank, - totalSupply, -}: DelegateLeaderboardRowProps) { + votingPower, + tokenBalance, + delegatorCount, + delegatedShare, +}: DelegateRowData) { + const router = useRouter() + + function handleDelegate() { + router.push(`?delegate=${address}`, { scroll: true }) + } + return ( -
+
#{rank}
- {truncateAddress(delegate.address, 6)} +
- Token balance: {formatTokenAmount(delegate.tokenBalance)} UP + Balance: {tokenBalance} UP
- - - + + + +
) } diff --git a/governance-app/src/components/layout/AppShell.tsx b/governance-app/src/components/layout/AppShell.tsx index 7603d0fe639..5f5da54cbae 100644 --- a/governance-app/src/components/layout/AppShell.tsx +++ b/governance-app/src/components/layout/AppShell.tsx @@ -1,61 +1,59 @@ -import Link from 'next/link' -import { governanceConfig, governanceRoutes } from '~/config/governance' +// ABOUTME: Top-level app shell wrapping all governance pages. +// Renders GovernanceHeader (wallet connect), content area, and Footer. +'use client' + +import { Footer } from '@unlock-protocol/ui' +import { GovernanceHeader } from './GovernanceHeader' +import { ConnectModal } from '~/components/ConnectModal' +import { TermsOfServiceModal } from '~/components/TermsOfServiceModal' type AppShellProps = { children: React.ReactNode } +const FOOTER_CONFIG = { + logo: { url: 'https://unlock-protocol.com' }, + privacyUrl: 'https://unlock-protocol.com/privacy', + termsUrl: 'https://unlock-protocol.com/terms', + menuSections: [ + { + title: 'Governance', + options: [ + { + label: 'Unlock DAO', + url: 'https://unlock-protocol.com/blog/unlock-dao', + }, + { label: 'Forum', url: 'https://unlock.community/' }, + { + label: 'Snapshot', + url: 'https://snapshot.org/#/unlock-protocol.eth', + }, + ], + }, + { + title: 'Resources', + options: [ + { label: 'Docs', url: 'https://docs.unlock-protocol.com/' }, + { + label: 'Roadmap', + url: 'https://docs.unlock-protocol.com/governance/roadmap/', + }, + { label: 'Blog', url: 'https://unlock-protocol.com/blog' }, + ], + }, + ], +} + export function AppShell({ children }: AppShellProps) { return (
-
-
-
-

- Unlock DAO -

-
-

- Governance App Foundation -

-

- Initial app shell for the future `vote.unlock-protocol.com` - deployment on Base. -

-
-
-
- - Governor:{' '} - - {governanceConfig.governorAddress} - - - - Timelock:{' '} - - {governanceConfig.timelockAddress} - - - - Token:{' '} - {governanceConfig.tokenAddress} - -
-
- -
+ + +
{children}
+
+
+
) } diff --git a/governance-app/src/components/layout/GovernanceHeader.tsx b/governance-app/src/components/layout/GovernanceHeader.tsx new file mode 100644 index 00000000000..4522df8675d --- /dev/null +++ b/governance-app/src/components/layout/GovernanceHeader.tsx @@ -0,0 +1,68 @@ +// ABOUTME: Client-side governance app header with wallet connect/disconnect. +// Uses HeaderNav from @unlock-protocol/ui and useGovernanceWallet for auth state. +'use client' + +import { Button, HeaderNav } from '@unlock-protocol/ui' +import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react' +import { MdExitToApp as DisconnectIcon } from 'react-icons/md' +import { truncateAddress } from '~/lib/governance/format' +import { governanceRoutes } from '~/config/governance' +import { useGovernanceWallet } from '~/hooks/useGovernanceWallet' +import { useConnectModal } from '~/hooks/useConnectModal' + +export function GovernanceHeader() { + const { address, authenticated, disconnect, isReady } = useGovernanceWallet() + const { openConnectModal } = useConnectModal() + + const menuSections = governanceRoutes.map((route) => ({ + title: route.label, + url: route.href, + })) + + return ( +
+
+ + + + {truncateAddress(address, 6)} + + + + +
+ + {({ active }) => ( + + )} + +
+
+ + ) : ( + + ), + }, + ]} + /> +
+
+ ) +} diff --git a/governance-app/src/config/env.ts b/governance-app/src/config/env.ts index 44c9f050d60..d49bd9a7b88 100644 --- a/governance-app/src/config/env.ts +++ b/governance-app/src/config/env.ts @@ -1,5 +1,8 @@ export const governanceEnv = { baseRpcUrl: process.env.BASE_RPC_URL || '', baseSubgraphUrl: process.env.BASE_SUBGRAPH_URL || '', - privyAppId: process.env.NEXT_PUBLIC_PRIVY_APP_ID || '', + mainnetRpcUrl: process.env.MAINNET_RPC_URL || '', + // Staging Privy app shared with unlock-app; override via NEXT_PUBLIC_PRIVY_APP_ID for prod + privyAppId: + process.env.NEXT_PUBLIC_PRIVY_APP_ID || 'cm2oqudm203nny8z9ho6chvyv', } diff --git a/governance-app/src/config/governance.ts b/governance-app/src/config/governance.ts index 22d0af3fca6..3f17e4cfc11 100644 --- a/governance-app/src/config/governance.ts +++ b/governance-app/src/config/governance.ts @@ -5,12 +5,13 @@ import { PublicLock, Unlock, } from '@unlock-protocol/contracts' -import { base } from '@unlock-protocol/networks' +import { base, mainnet } from '@unlock-protocol/networks' import { governanceEnv } from './env' export const governanceConfig = { chainId: 8453, chainName: 'Base', + mainnetRpcUrl: governanceEnv.mainnetRpcUrl || mainnet.provider, governorAddress: base.dao?.governor || '0x65bA0624403Fc5Ca2b20479e9F626eD4D78E0aD9', governorStartBlock: base.startBlock || 1750000, @@ -35,6 +36,5 @@ export const governanceRoutes = [ { href: '/proposals', label: 'Proposals' }, { href: '/delegates', label: 'Delegates' }, { href: '/treasury', label: 'Treasury' }, - { href: '/delegate', label: 'My Delegation' }, { href: '/propose', label: 'New Proposal' }, ] as const diff --git a/governance-app/src/hooks/useConnectModal.tsx b/governance-app/src/hooks/useConnectModal.tsx new file mode 100644 index 00000000000..33a765e1c5e --- /dev/null +++ b/governance-app/src/hooks/useConnectModal.tsx @@ -0,0 +1,53 @@ +// ABOUTME: Context and hook for controlling the Privy connect modal visibility. +// Simplified version without locksmith dependencies — just wraps Privy login(). +'use client' + +import { createContext, useContext, useState } from 'react' +import { useLogin, usePrivy } from '@privy-io/react-auth' + +interface ConnectModalContextValue { + open: boolean + openConnectModal: () => void + closeConnectModal: () => void +} + +const ConnectModalContext = createContext({ + open: false, + openConnectModal: () => {}, + closeConnectModal: () => {}, +}) + +export function ConnectModalProvider({ + children, +}: { + children: React.ReactNode +}) { + const [open, setOpen] = useState(false) + const { ready } = usePrivy() + const { login } = useLogin({ + onComplete: () => setOpen(false), + onError: () => setOpen(false), + }) + + function openConnectModal() { + if (!ready) return + setOpen(true) + login() + } + + function closeConnectModal() { + setOpen(false) + } + + return ( + + {children} + + ) +} + +export function useConnectModal() { + return useContext(ConnectModalContext) +} diff --git a/governance-app/src/hooks/useGovernanceWallet.ts b/governance-app/src/hooks/useGovernanceWallet.ts new file mode 100644 index 00000000000..91a386c90ed --- /dev/null +++ b/governance-app/src/hooks/useGovernanceWallet.ts @@ -0,0 +1,60 @@ +'use client' + +import { useLogin, usePrivy, useWallets, useLogout } from '@privy-io/react-auth' +import { BrowserProvider } from 'ethers' +import { useRouter } from 'next/navigation' +import { governanceConfig } from '~/config/governance' + +function chainHex(chainId: number) { + return `0x${chainId.toString(16)}` +} + +export function useGovernanceWallet() { + const { authenticated, ready: privyReady } = usePrivy() + const { wallets, ready: walletsReady } = useWallets() + const { login } = useLogin() + const router = useRouter() + const { logout } = useLogout({ + onSuccess: () => router.refresh(), + }) + const wallet = wallets[0] || null + + async function ensureBaseNetwork() { + if (!wallet) { + throw new Error('Connect a wallet to continue.') + } + const ethereumProvider = await wallet.getEthereumProvider() + const provider = new BrowserProvider(ethereumProvider, 'any') + const network = await provider.getNetwork() + + if (Number(network.chainId) === governanceConfig.chainId) { + return provider + } + + try { + await provider.send('wallet_switchEthereumChain', [ + { + chainId: chainHex(governanceConfig.chainId), + }, + ]) + } catch { + throw new Error('Please switch your wallet to Base to continue.') + } + + return provider + } + + async function getSigner() { + const provider = await ensureBaseNetwork() + return provider.getSigner() + } + + return { + address: wallet?.address || null, + authenticated, + connect: login, + disconnect: logout, + getSigner, + isReady: privyReady && walletsReady, + } +} diff --git a/governance-app/src/hooks/useTermsOfService.ts b/governance-app/src/hooks/useTermsOfService.ts new file mode 100644 index 00000000000..9f9e1e55912 --- /dev/null +++ b/governance-app/src/hooks/useTermsOfService.ts @@ -0,0 +1,34 @@ +// ABOUTME: Tracks whether the user has accepted the terms of service. +// Persists acceptance in localStorage so the modal only shows once. +'use client' + +import { useState, useEffect, useCallback } from 'react' + +const STORAGE_KEY = '@unlock-governance.terms-of-service' + +export function useTermsOfService() { + const [termsLoading, setTermsLoading] = useState(true) + const [termsAccepted, setTermsAccepted] = useState(false) + + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY) + setTermsAccepted(stored === 'true') + } catch { + setTermsAccepted(false) + } finally { + setTermsLoading(false) + } + }, []) + + const saveTermsAccepted = useCallback(() => { + setTermsAccepted(true) + try { + localStorage.setItem(STORAGE_KEY, 'true') + } catch { + // ignore storage errors + } + }, []) + + return { termsAccepted, saveTermsAccepted, termsLoading } +} diff --git a/governance-app/src/lib/governance/rpc.ts b/governance-app/src/lib/governance/rpc.ts index 38d739e1b23..6d60a485843 100644 --- a/governance-app/src/lib/governance/rpc.ts +++ b/governance-app/src/lib/governance/rpc.ts @@ -1,10 +1,10 @@ -import { Contract, Interface, type InterfaceAbi, JsonRpcProvider } from 'ethers' +import { Contract, type InterfaceAbi, JsonRpcProvider } from 'ethers' import { cache } from 'react' import { UPGovernor, UPToken } from '@unlock-protocol/contracts' import { governanceConfig } from '~/config/governance' -const governorAbi = getContractAbi(UPGovernor) -const tokenAbi = getContractAbi(UPToken) +export const governorAbi = getContractAbi(UPGovernor) +export const tokenAbi = getContractAbi(UPToken) export const getRpcProvider = cache( () => new JsonRpcProvider(governanceConfig.rpcUrl, governanceConfig.chainId) @@ -19,8 +19,6 @@ export const getGovernorContract = cache( ) ) -export const getGovernorInterface = cache(() => new Interface(governorAbi)) - export const getTokenContract = cache( () => new Contract(governanceConfig.tokenAddress, tokenAbi, getRpcProvider()) ) @@ -49,7 +47,7 @@ export const getLatestTimestamp = cache(async () => { return BigInt(block.timestamp) }) -function getContractAbi(abi: unknown): InterfaceAbi { +export function getContractAbi(abi: unknown): InterfaceAbi { if (abi && typeof abi === 'object' && 'abi' in abi) { return (abi as { abi: InterfaceAbi }).abi }