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. 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 }