diff --git a/AGENTS.md b/AGENTS.md index 3e5569a1..284c8dc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,10 +76,7 @@ GoodWidget/ - Story interaction checks: `pnpm test:storybook`. - Playwright QA/state-flow checks: `pnpm test:demo`. - Root Playwright runtime artifacts (trace/video/attachments): `/test-results/` (gitignored). -- Nested widget Playwright test-runs (`tests/widgets//test-results/`) are gitignored - transient run output. The canonical visual evidence for a widget lives in - `examples/storybook/src/stories//screenshots/` (curated, deterministic, - generated by the screenshot regen script) — commit only that curated set. +- Nested widget Playwright test-runs are output (`tests/widgets//test-results/`) - Detailed workflow, fixture behavior, and QA reporting template live in [`docs/demo-environment.md`](docs/demo-environment.md) and [`docs/qa-guide.md`](docs/qa-guide.md). diff --git a/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx new file mode 100644 index 00000000..56f77ac6 --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx @@ -0,0 +1,347 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Card, Text, YStack } from '@goodwidget/ui' +import { + GovernanceWidget, + type GovernanceWidgetAdapterFactory, + type GovernanceWidgetAdapterState, + type GovernanceWidgetStatus, +} from '@goodwidget/governance-widget' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} from '../../fixtures/injectedEip1193' + +const meta: Meta = { + title: 'QA/GovernanceWidget Runtime Fixtures', + component: GovernanceWidget, + parameters: { + layout: 'padded', + goodWidgetProvider: { useShell: false, useProvider: false }, + }, +} + +export default meta +type Story = StoryObj + +const connectedAddress = '0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08' as const + +function createDashboard(overrides: Partial = {}) { + return { + impact: { + title: 'Distributed', + metrics: [ + { label: 'UBI Pool', amount: { value: 12400000, token: 'G$' } }, + { + label: 'Impact Pool', + amount: { value: 5234891, token: 'G$', isStreaming: true, streamLabel: 'Live stream active' }, + }, + ], + description: + 'Empowering 640k+ people worldwide through transparent, decentralized funding for public goods.', + ctaLabel: 'View Impact Report Q3', + }, + activeMembers: { + icon: 'check' as const, + title: 'Active Members', + amount: 12402, + amountType: 'raw' as const, + metadataType: 'time-window' as const, + metadata: { label: 'Active members only', tone: 'muted' as const, icon: 'info' as const }, + }, + alignmentVoting: { + voteId: 'alignment-current', + title: 'Q3 House Of Alignment Funding Allocation', + summaryLabel: 'Current top 3 voted', + options: [ + { id: 'food-chain', label: 'Local Food Chain', percentage: 42 }, + { id: 'web3-literacy', label: 'Web3 Literacy', percentage: 31 }, + { id: 'civic-onboarding', label: 'Civic Onboarding', percentage: 27 }, + ], + recipients: [], + allocationsBps: { 'food-chain': 4200, 'web3-literacy': 3100, 'civic-onboarding': 2700 }, + allocationTotalBps: 10000, + canVote: false, + hasVoted: false, + isVotingOpen: true, + executed: false, + finalizedUnits: {}, + disabledReason: 'Only active House of Alignment members can vote.', + }, + fundingDistribution: { + title: 'Funding distribution', + centerLabel: 'Mocked pool total', + totalAmount: { value: 450000, token: 'G$', isStreaming: true, streamLabel: 'Mock pool data' }, + projects: [ + { id: 'education', name: 'Education Hubs', amount: { value: 157500, token: 'G$' }, percentage: 35 }, + { id: 'merchant', name: 'Merchant Onboard', amount: { value: 112500, token: 'G$' }, percentage: 25 }, + { id: 'grants', name: 'Dev Grants', amount: { value: 90000, token: 'G$' }, percentage: 20 }, + { id: 'creator', name: 'Creator Fund', amount: { value: 90000, token: 'G$' }, percentage: 20 }, + ], + isStreaming: true, + emptyStateLabel: 'No active funding distribution yet.', + }, + ...overrides, + } +} + +function createState( + status: GovernanceWidgetStatus, + overrides: Partial = {}, +): GovernanceWidgetAdapterState { + const isConnected = status !== 'disconnected' + const member = + status === 'active_citizenship' || status === 'active_alignment' || status === 'restake_required' + ? { + house: status === 'active_alignment' ? 'alignment' : 'citizenship', + status: status === 'restake_required' ? 'unstaked' : 'active', + stakedAmount: 250000000000000000000n, + joinedAt: Date.UTC(2026, 0, 10), + updatedAt: Date.UTC(2026, 2, 1), + unstakedAt: status === 'restake_required' ? Date.UTC(2026, 5, 1) : null, + memberIndex: 0n, + name: status === 'active_alignment' ? 'Solar Commons' : 'Maya Citizen', + socialLinks: 'https://twitter.com/gooddollar', + projectWebpage: 'https://solar.example', + missionStatement: 'Expand regenerative local access.', + distributionStrategy: 'Allocate quarterly grants through community review.', + } + : null + + return { + status, + address: isConnected ? connectedAddress : null, + chainId: status === 'unsupported_chain' ? 1 : 42220, + identityStatus: status === 'onboarding_required' ? 'unverified' : 'verified', + identityVerificationUrl: null, + member, + dashboard: createDashboard(), + selectedHouse: 'citizenship', + disabledHouseOptions: status === 'onboarding_required' ? ['alignment'] : [], + onboardingStepId: undefined, + profileDraft: {}, + stakeAmountLabel: '250 G$', + minimumStakeAmounts: { citizenship: 250000000000000000000n, alignment: 500000000000000000000n }, + transactionSteps: [ + { id: 'prepare', title: 'Prepare wallet balance', status: 'completed' }, + { id: 'approve', title: 'Approve governance stake', status: 'active' }, + { id: 'stake', title: 'Lock the membership stake', status: 'pending' }, + { id: 'finalize', title: 'Finalize governance access', status: 'pending' }, + ], + registrationHash: null, + error: null, + ...overrides, + } +} + +function createAdapterFactory(state: GovernanceWidgetAdapterState): GovernanceWidgetAdapterFactory { + return () => ({ + state, + actions: { + connect: async () => {}, + switchToCelo: async () => {}, + refresh: async () => {}, + retry: async () => {}, + selectHouse: () => {}, + register: async () => {}, + restake: async () => {}, + openVote: () => {}, + closeVote: () => {}, + setVoteAllocation: () => {}, + submitVote: async () => {}, + startIdentityVerification: async () => {}, + }, + }) +} + +function RuntimeStory({ + state, + defaultTheme = 'light', + useInjectedProvider = false, +}: { + state: GovernanceWidgetAdapterState + defaultTheme?: 'light' | 'dark' + useInjectedProvider?: boolean +}) { + const injectedProvider = getInjectedEip1193Provider() + + if (useInjectedProvider && !isInjectedProviderUsable(injectedProvider)) { + return ( + + + No injected wallet found + Install or enable an injected EIP-1193 wallet, then refresh Storybook. + + + ) + } + + const provider = useInjectedProvider ? injectedProvider : createCustodialEip1193Provider() + + return ( + + ) +} + +export const DisconnectedDashboard: Story = { + render: () => , +} + +export const LoadingConnected: Story = { + render: () => , +} + +export const OnboardingRequiredHoaUnavailable: Story = { + render: () => ( + + ), +} + +export const PendingAlignment: Story = { + render: () => , +} + +export const ActiveCitizenship: Story = { + render: () => , +} + +export const ActiveAlignmentInjected: Story = { + render: () => ( + + ), +} + +export const VoteDetailOpen: Story = { + render: () => ( + + ), +} + +export const AlreadyVoted: Story = { + render: () => ( + + ), +} + +export const VoteClosedExecuted: Story = { + render: () => ( + + ), +} + +export const EmptyRecipients: Story = { + render: () => ( + + ), +} + +export const PoolUnavailableMocked: Story = { + render: () => ( + + ), +} + +export const UnsupportedChain: Story = { + render: () => , +} + +export const RestakeRequired: Story = { + render: () => , +} + +export const FriendlyContractError: Story = { + render: () => ( + + ), +} diff --git a/packages/governance-widget/package.json b/packages/governance-widget/package.json index dc91ce9f..882176c1 100644 --- a/packages/governance-widget/package.json +++ b/packages/governance-widget/package.json @@ -30,10 +30,12 @@ } }, "dependencies": { + "@goodsdks/citizen-sdk": "1.2.5", "@goodwidget/core": "workspace:*", "@goodwidget/ui": "workspace:*", "react-native-svg": "15.15.5", - "tamagui": "1.121.0" + "tamagui": "1.121.0", + "viem": "^2.0.0" }, "devDependencies": { "@types/react": "^18.3.0", diff --git a/packages/governance-widget/src/FundingDistributionChart.tsx b/packages/governance-widget/src/FundingDistributionChart.tsx index 3957911b..5f1902eb 100644 --- a/packages/governance-widget/src/FundingDistributionChart.tsx +++ b/packages/governance-widget/src/FundingDistributionChart.tsx @@ -133,6 +133,7 @@ function FundingDistributionChartContent({ totalAmount, projects, isStreaming = false, + stateLabel, onProjectPress, }: FundingDistributionChartProps) { const theme = useTheme() @@ -145,6 +146,11 @@ function FundingDistributionChartContent({ {title} + {stateLabel ? ( + + {stateLabel} + + ) : null} diff --git a/packages/governance-widget/src/GovernanceWidget.tsx b/packages/governance-widget/src/GovernanceWidget.tsx new file mode 100644 index 00000000..beb1e3a9 --- /dev/null +++ b/packages/governance-widget/src/GovernanceWidget.tsx @@ -0,0 +1,377 @@ +import { useMemo } from 'react' +import { Button, ButtonText, Card, Heading, Input, Spinner, Text, XStack, YStack } from '@goodwidget/ui' +import { AlignmentVotingProposalCard } from './AlignmentVotingProposalCard' +import { BalanceCard } from './BalanceCard' +import { FundingDistributionChart } from './FundingDistributionChart' +import { GovernanceOnboardingWidget } from './GovernanceOnboardingWidget' +import { GovernanceWidgetProvider } from './GovernanceWidgetProvider' +import { ImpactCard } from './ImpactCard' +import { useGovernanceAdapter } from './adapter' +import type { + GovernanceWidgetAdapterActions, + GovernanceWidgetAdapterFactoryInput, + GovernanceWidgetAdapterState, + GovernanceWidgetProps, +} from './widgetRuntimeContract' +import { isActiveStatus } from './adapter' + +function formatMemberDate(timestamp: number | null): string { + if (!timestamp) return 'Not available' + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format( + new Date(timestamp), + ) +} + +function GovernanceHeader({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + const addressLabel = state.address ? `${state.address.slice(0, 6)}…${state.address.slice(-4)}` : null + + return ( + + + + GoodDAO Governance + + Browse governance impact, funding distribution, and active Alignment voting. + + + {state.address ? ( + + + Connected wallet + + {addressLabel} + + ) : ( + + )} + + + ) +} + +function RuntimeNotice({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + if (state.status === 'loading') { + return ( + + + + Loading wallet, identity, membership, and governance data… + + + ) + } + + if (state.status === 'unsupported_chain') { + return ( + + + + Switch to Celo Mainnet + + + GoodDAO Houses are deployed on Celo Mainnet. Switch networks to continue with membership actions. + + + + + ) + } + + if (state.status === 'friendly_error') { + return ( + + + + Governance data unavailable + + {state.error ?? 'Please try again.'} + + + + ) + } + + return null +} + +function GovernanceDashboard({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + return ( + + + + actions.openVote()} + /> + {state.dashboard.alignmentVoting.options.length === 0 ? ( + + + {state.dashboard.alignmentVoting.disabledReason ?? + 'No House of Alignment members have been assigned yet. Voting will open shortly.'} + + + ) : null} + {state.dashboard.alignmentVoting.hasVoted ? ( + + + You already voted in this cycle. Ballot updates are not available for this contract version. + + + ) : null} + + + ) +} + +function PendingAlignmentState({ state }: { state: GovernanceWidgetAdapterState }) { + return ( + + + Alignment membership pending + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Your House of Alignment application is waiting for committee approval. + + + Wallet: {state.address ?? 'Not connected'} + + + + ) +} + +function RestakeState({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + return ( + + + Restore governance membership + + This member is currently {state.member?.status ?? 'inactive'}. You can restake to rejoin the selected house. + + + + + ) +} + +function MemberFooter({ state }: { state: GovernanceWidgetAdapterState }) { + if (!state.member || !isActiveStatus(state.status)) return null + + return ( + + + + House: {state.member.house === 'alignment' ? 'House of Alignment' : 'House of Citizenship'} + + + Joined: {formatMemberDate(state.member.joinedAt)} + + + Status: {state.member.status} + + + + ) +} + +function GovernanceVoteDetail({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + const vote = state.dashboard.alignmentVoting + const canSubmit = vote.canVote && vote.allocationTotalBps === 10000 && !vote.hasVoted && vote.isVotingOpen + const isReadOnly = vote.hasVoted || vote.executed + + return ( + + + + {vote.title} + + + + Placeholder voting detail. Enter allocation basis points; totals must equal 10,000 before voting. + + + {vote.options.map((option) => + isReadOnly ? ( + + {option.label}: {vote.executed ? `${vote.finalizedUnits[option.id] ?? '0'} finalized units` : `${vote.allocationsBps[option.id] ?? 0} bps`} + + ) : ( + actions.setVoteAllocation(option.id, Number.parseInt(value || '0', 10))} + /> + ), + )} + + + Allocation total: {vote.allocationTotalBps} / 10,000 bps + + {vote.hasVoted ? ( + + Already voted — this contract does not support ballot replacement. + + ) : null} + {!canSubmit ? {vote.disabledReason ?? 'Voting is unavailable.'} : null} + + + + ) +} + +function GovernanceWidgetContent({ + adapterFactory, + adapterInput, + testId, +}: { + adapterFactory?: GovernanceWidgetProps['adapterFactory'] + adapterInput: GovernanceWidgetAdapterFactoryInput + testId?: string +}) { + const defaultAdapter = useGovernanceAdapter(adapterInput) + const adapter = adapterFactory ? adapterFactory(adapterInput) : defaultAdapter + const { state, actions } = adapter + const shouldShowDashboard = + state.status === 'disconnected' || + state.status === 'loading' || + state.status === 'unsupported_chain' || + state.status === 'friendly_error' || + isActiveStatus(state.status) + + return ( + + + + {state.status === 'vote_detail' ? : null} + {state.status === 'onboarding_required' ? ( + { + void actions.startIdentityVerification() + }} + onProfileSubmit={(profileDraft) => { + void actions.register(profileDraft) + }} + /> + ) : null} + {state.status === 'pending_alignment' ? : null} + {state.status === 'restake_required' ? : null} + {shouldShowDashboard ? : null} + + + ) +} + +export function GovernanceWidget({ + provider, + themeOverrides, + config, + defaultTheme = 'light', + adapterFactory, + testId, + environment, + celoRpcUrl, + addresses, +}: GovernanceWidgetProps) { + const adapterInput = useMemo( + () => ({ environment, celoRpcUrl, addresses }), + [addresses, celoRpcUrl, environment], + ) + + return ( + + + + ) +} diff --git a/packages/governance-widget/src/adapter.ts b/packages/governance-widget/src/adapter.ts new file mode 100644 index 00000000..88e2fa21 --- /dev/null +++ b/packages/governance-widget/src/adapter.ts @@ -0,0 +1,669 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useWallet } from '@goodwidget/core' +import { getAddress, isAddress, type Address, type Hex } from 'viem' +import type { StepperStepItem } from '@goodwidget/ui' +import type { GovernanceHouse, GovernanceProfileDraft, RankedVotingOption } from './types' +import type { + GovernanceDashboardState, + GovernanceWidgetAdapterActions, + GovernanceWidgetAdapterFactoryInput, + GovernanceWidgetAdapterResult, + GovernanceWidgetAdapterState, + GovernanceWidgetStatus, + GovernanceVotingState, +} from './widgetRuntimeContract' +import { + CELO_CHAIN_ID, + ZERO_ADDRESS, + createGovernancePublicClient, + createGovernanceWalletClient, + formatStakeAmount, + requestCeloMainnetSwitch, + resolveGovernanceAddresses, + type GovernanceMemberRecord, +} from './sdks/contracts' +import { + readFlowSplitterConfig, + readGovernanceMembership, + readGovernanceSchedule, + readGovernanceVote, + stakeRequirementLabel, + type GovernanceSchedule, + type GovernanceStakeRequirements, +} from './sdks/contractReads' +import { castGovernanceVote, registerWithTransferAndCall, restakeWithTransferAndCall, type GovernanceTransactionStage } from './sdks/transactions' +import { fetchFundingReceivedSoFar } from './sdks/funding' +import { createGovernanceIdentitySdk, createGovernanceIdentityVerificationLink } from './sdks/identity' + +const IMPACT_METRICS: GovernanceDashboardState['impact'] = { + title: 'Distributed', + metrics: [ + { label: 'UBI Pool', amount: { value: 'Unavailable', token: '' } }, + { + label: 'Impact Pool', + amount: { value: 'Live total below', token: '' }, + }, + ], + description: + 'Empowering people worldwide through transparent, decentralized funding for public goods.', +} + +const EMPTY_STAKES: GovernanceStakeRequirements = { + citizenship: 0n, + alignment: 0n, +} + +function shortAddress(address: Address): string { + return `${address.slice(0, 6)}…${address.slice(-4)}` +} + +function createTransactionSteps(stage: GovernanceTransactionStage | 'idle' | 'failed', failedMessage?: string): StepperStepItem[] { + const statuses: Record = { + prepare: stage === 'idle' ? 'active' : 'completed', + approve: stage === 'wallet_confirmation' ? 'active' : stage === 'idle' ? 'pending' : 'completed', + stake: stage === 'submitted' ? 'active' : stage === 'confirmed' ? 'completed' : stage === 'failed' ? 'failed' : 'pending', + finalize: stage === 'confirmed' ? 'completed' : 'pending', + } + + return [ + { + id: 'prepare', + title: 'Prepare wallet balance', + description: 'Keep the required G$ amount available before the staking transaction starts.', + status: statuses.prepare, + }, + { + id: 'approve', + title: 'Confirm in wallet', + description: 'Approve the G$ transferAndCall transaction from your wallet.', + status: statuses.approve, + }, + { + id: 'stake', + title: 'Transaction submitted', + description: failedMessage ?? 'Wait for the Celo transaction receipt before continuing.', + status: statuses.stake, + }, + { + id: 'finalize', + title: 'Confirmed on-chain', + status: statuses.finalize, + }, + ] +} + +function nextVotingWindowLabel(schedule: GovernanceSchedule, nowMs = Date.now()): string { + if (!schedule.cycleStartTime || schedule.termDurationSeconds === 0n) { + return 'Contract schedule unavailable' + } + const termMs = Number(schedule.termDurationSeconds) * 1000 + const cycleStart = schedule.cycleStartTime + const nextStart = nowMs < cycleStart ? cycleStart : cycleStart + (Math.floor((nowMs - cycleStart) / termMs) + 1) * termMs + return `Next window starts ${new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(nextStart))}` +} + +function createVotingState(params: { + status: GovernanceWidgetStatus + member: GovernanceMemberRecord | null + identityRoot: Address + activeAlignment: Address[] + voteId: bigint + isVotingOpen: boolean + voteConfig: { startTime: number | null; endTime: number | null; executedAt: number | null; executed: boolean } + recipients: Address[] + hasVoted: boolean + finalizedUnits: Record + schedule: GovernanceSchedule +}): GovernanceVotingState { + const recipients = params.recipients.map((recipient) => getAddress(recipient)) + const totalUnits = Object.values(params.finalizedUnits).reduce((total, amount) => total + amount, 0n) + const options: RankedVotingOption[] = recipients.map((recipient) => { + const units = params.finalizedUnits[recipient.toLowerCase()] ?? params.finalizedUnits[recipient] ?? 0n + return { + id: recipient, + label: shortAddress(recipient), + percentage: totalUnits > 0n ? Number((units * 100n) / totalUnits) : 0, + } + }) + const allocationsBps = Object.fromEntries(options.map((option) => [option.id, 0])) + const isActiveMember = params.member?.status === 'active' + const hasCitizenIdentity = params.member?.house !== 'citizenship' || params.identityRoot.toLowerCase() !== ZERO_ADDRESS + const canVote = Boolean(isActiveMember && hasCitizenIdentity && params.isVotingOpen && !params.hasVoted && recipients.length > 0) + + let title = 'Alignment vote' + let summaryLabel = 'Allocate 10,000 bps to active House of Alignment recipients' + let disabledReason: string | undefined + + if (!params.isVotingOpen) { + title = 'Upcoming Alignment vote' + summaryLabel = nextVotingWindowLabel(params.schedule) + disabledReason = 'Voting is currently closed.' + } else if (params.activeAlignment.length === 0) { + disabledReason = 'No House of Alignment members have been assigned yet. Voting will open shortly.' + } else if (params.hasVoted) { + disabledReason = 'You already voted in this allocation cycle.' + } else if (!isActiveMember) { + disabledReason = 'Only active members can vote.' + } else if (!hasCitizenIdentity) { + disabledReason = 'Verify your GoodID before voting as a Citizen.' + } + + if (params.voteConfig.executed) { + title = 'Executed Alignment vote' + summaryLabel = 'Final units executed' + disabledReason = 'This vote has already been executed.' + } + + return { + voteId: params.voteId.toString(), + title, + summaryLabel, + options, + recipients, + allocationsBps, + allocationTotalBps: 0, + canVote, + hasVoted: params.hasVoted, + isVotingOpen: params.isVotingOpen, + executed: params.voteConfig.executed, + finalizedUnits: Object.fromEntries(Object.entries(params.finalizedUnits).map(([recipient, units]) => [recipient, units.toString()])), + disabledReason, + } +} + +function createDashboardState(params: { + activeMemberCount?: number + voting?: GovernanceVotingState + funding?: GovernanceDashboardState['fundingDistribution'] +} = {}): GovernanceDashboardState { + return { + impact: IMPACT_METRICS, + activeMembers: { + icon: 'check', + title: 'Active Members', + amount: params.activeMemberCount ?? 0, + amountType: 'raw', + metadataType: 'time-window', + metadata: { label: 'Active members only', tone: 'muted', icon: 'info' }, + }, + alignmentVoting: + params.voting ?? + { + voteId: '0', + title: 'Upcoming Alignment vote', + summaryLabel: 'Contract schedule unavailable', + options: [], + recipients: [], + allocationsBps: {}, + allocationTotalBps: 0, + canVote: false, + hasVoted: false, + isVotingOpen: false, + executed: false, + finalizedUnits: {}, + disabledReason: 'Connect a wallet to load governance voting state.', + }, + fundingDistribution: + params.funding ?? + { + title: 'Funding received so far', + centerLabel: 'Loading live funding', + totalAmount: { value: '0', token: 'G$', isStreaming: true, streamLabel: 'Loading Superfluid streams' }, + projects: [], + isStreaming: true, + emptyStateLabel: 'Loading funding streams…', + }, + } +} + +function createInitialState(status: GovernanceWidgetStatus = 'disconnected'): GovernanceWidgetAdapterState { + return { + status, + address: null, + chainId: null, + identityStatus: 'unverified', + identityVerificationUrl: null, + member: null, + dashboard: createDashboardState(), + selectedHouse: 'citizenship', + disabledHouseOptions: ['alignment'], + onboardingStepId: undefined, + profileDraft: {}, + stakeAmountLabel: '0 G$', + minimumStakeAmounts: EMPTY_STAKES, + transactionSteps: createTransactionSteps('idle'), + registrationHash: null, + error: null, + } +} + +function friendlyGovernanceError(err: unknown): string { + if (!(err instanceof Error)) { + return 'Something went wrong. Please try again.' + } + + const message = err.message + if (message.includes('User rejected') || message.includes('4001')) { + return 'Transaction rejected in the wallet.' + } + if (message.includes('Already voted')) { + return 'You already voted in this allocation cycle.' + } + if (message.includes('Alloc != 10000')) { + return 'Allocation totals must equal exactly 10,000 basis points.' + } + if (message.includes('insufficient funds')) { + return 'Your wallet does not have enough funds for this governance action.' + } + if (message.includes('Not HoA eligible')) { + return 'This wallet is not currently eligible for House of Alignment registration.' + } + if (message.includes('revert') || message.includes('reverted')) { + return 'The governance contract rejected this action. Review your details and try again.' + } + if (message.includes('fetch') || message.includes('network') || message.includes('HTTP')) { + return 'Unable to reach Celo Mainnet. Check your connection and try again.' + } + + return 'Unable to complete the governance action. Please try again.' +} + +function statusFromMember(member: GovernanceMemberRecord | null): GovernanceWidgetStatus { + if (!member || member.status === 'none') return 'onboarding_required' + if (member.status === 'pending' && member.house === 'alignment') return 'pending_alignment' + if (member.status === 'active' && member.house === 'alignment') return 'active_alignment' + if (member.status === 'active') return 'active_citizenship' + if (member.status === 'revoked' || member.status === 'unstaked') return 'restake_required' + return 'onboarding_required' +} + +function isActiveStatus(status: GovernanceWidgetStatus): boolean { + return status === 'active_alignment' || status === 'active_citizenship' +} + +function transactionStageToStep(stage: GovernanceTransactionStage, hash?: Hex): Partial { + return { + registrationHash: hash ?? null, + transactionSteps: createTransactionSteps(stage), + } +} + +export function useGovernanceAdapter({ + environment = 'production', + celoRpcUrl, + addresses: addressOverrides, +}: GovernanceWidgetAdapterFactoryInput = {}): GovernanceWidgetAdapterResult { + const { address, chainId, provider, connect } = useWallet() + const [state, setState] = useState(() => createInitialState()) + const publicClient = useMemo(() => createGovernancePublicClient(celoRpcUrl), [celoRpcUrl]) + const addresses = useMemo(() => resolveGovernanceAddresses(addressOverrides), [addressOverrides]) + const account = address as Address | null + + const loadRuntimeState = useCallback(async () => { + if (!account) { + setState((previous) => ({ ...previous, ...createInitialState('disconnected'), chainId: chainId ?? null })) + return + } + + if (chainId !== CELO_CHAIN_ID) { + setState((previous) => ({ + ...previous, + status: 'unsupported_chain', + address: account, + chainId: chainId ?? null, + error: null, + })) + return + } + + if (!addresses.houses) { + setState((previous) => ({ + ...previous, + status: 'friendly_error', + address: account, + chainId, + error: 'Governance contract address is not configured yet.', + })) + return + } + + setState((previous) => ({ ...previous, status: 'loading', address: account, chainId, error: null })) + + try { + const membership = await readGovernanceMembership({ + publicClient, + housesAddress: addresses.houses, + goodIdAddress: addresses.goodId, + account, + }) + const status = statusFromMember(membership.member) + const schedule = await readGovernanceSchedule({ publicClient, housesAddress: addresses.houses }) + const voterKey = membership.member.house === 'citizenship' && membership.identityRoot.toLowerCase() !== ZERO_ADDRESS + ? membership.identityRoot + : account + const vote = await readGovernanceVote({ + publicClient, + housesAddress: addresses.houses, + voterKey, + activeAlignment: membership.activeAlignment, + }) + const votingState = createVotingState({ + status, + member: membership.member, + identityRoot: membership.identityRoot, + activeAlignment: membership.activeAlignment, + voteId: vote.voteId, + isVotingOpen: vote.isVotingPeriod, + voteConfig: vote.voteConfig, + recipients: vote.recipients, + hasVoted: vote.hasVoted, + finalizedUnits: vote.finalizedUnits, + schedule, + }) + let funding: GovernanceDashboardState['fundingDistribution'] = { + title: 'Funding received so far', + centerLabel: 'Funding unavailable', + totalAmount: { value: '0', token: 'G$', isStreaming: false, streamLabel: 'Superfluid stream data unavailable' }, + projects: [], + emptyStateLabel: 'Funding data is unavailable right now.', + } + + try { + const flowConfig = await readFlowSplitterConfig({ publicClient, housesAddress: addresses.houses }) + if (flowConfig.poolAddress.toLowerCase() === ZERO_ADDRESS) { + funding = { + title: 'Funding received so far', + centerLabel: 'No receiver configured', + totalAmount: { value: '0', token: 'G$', isStreaming: false, streamLabel: 'No FlowSplitter pool receiver yet' }, + projects: [], + emptyStateLabel: 'No funding receiver has been configured yet.', + } + } else { + const fundingTotal = await fetchFundingReceivedSoFar({ receiver: flowConfig.poolAddress, token: addresses.gToken }) + funding = { + title: 'Funding received so far', + centerLabel: fundingTotal.streamCount > 0 ? 'Live Superfluid total' : 'No streams yet', + totalAmount: { + value: fundingTotal.formattedAmount, + token: 'G$', + isStreaming: fundingTotal.streamCount > 0, + streamLabel: fundingTotal.streamCount > 0 ? `${fundingTotal.streamCount} inbound stream${fundingTotal.streamCount === 1 ? '' : 's'}` : 'No inbound streams found', + }, + projects: [], + isStreaming: fundingTotal.streamCount > 0, + emptyStateLabel: fundingTotal.streamCount > 0 ? 'Distribution breakdown unavailable until outgoing stream data exists.' : 'No funding streams have been received yet.', + } + } + } catch { + // Keep membership and voting usable if the subgraph is unavailable. + } + + const selectedHouse = membership.member.status === 'none' ? state.selectedHouse : membership.member.house + setState((previous) => ({ + ...previous, + status, + address: account, + chainId, + identityStatus: membership.identityRoot.toLowerCase() !== ZERO_ADDRESS ? 'verified' : 'unverified', + member: membership.member, + dashboard: createDashboardState({ + activeMemberCount: membership.activeCitizens.length + membership.activeAlignment.length, + voting: votingState, + funding, + }), + selectedHouse, + disabledHouseOptions: membership.hoaEligibility.isEligible ? [] : ['alignment'], + minimumStakeAmounts: membership.minimumStakes, + stakeAmountLabel: stakeRequirementLabel(membership.minimumStakes, selectedHouse), + error: null, + })) + } catch (err: unknown) { + setState((previous) => ({ + ...previous, + status: 'friendly_error', + address: account, + chainId, + error: friendlyGovernanceError(err), + })) + } + }, [account, addresses, chainId, publicClient, state.selectedHouse]) + + useEffect(() => { + void loadRuntimeState() + }, [loadRuntimeState]) + + useEffect(() => { + if (!account || chainId !== CELO_CHAIN_ID || !addresses.houses) return undefined + const interval = globalThis.setInterval(() => { + void loadRuntimeState() + }, 30_000) + return () => globalThis.clearInterval(interval) + }, [account, addresses.houses, chainId, loadRuntimeState]) + + const switchToCelo = useCallback(async () => { + try { + await requestCeloMainnetSwitch(provider) + } finally { + await loadRuntimeState() + } + }, [loadRuntimeState, provider]) + + const selectHouse = useCallback((house: GovernanceHouse) => { + setState((previous) => ({ + ...previous, + selectedHouse: house, + stakeAmountLabel: formatStakeAmount(previous.minimumStakeAmounts[house]), + })) + }, []) + + const register = useCallback( + async (profileDraft: GovernanceProfileDraft) => { + if (!account || !addresses.houses) return + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) return + + setState((previous) => ({ + ...previous, + onboardingStepId: 'stake', + profileDraft, + transactionSteps: createTransactionSteps('wallet_confirmation'), + error: null, + })) + + try { + const selectedHouse = state.selectedHouse + const hash = await registerWithTransferAndCall({ + publicClient, + walletClient, + account, + addresses: { ...addresses, houses: addresses.houses }, + selectedHouse, + profileDraft, + stakeAmountWei: state.minimumStakeAmounts[selectedHouse], + onStage: (stage, hash) => setState((previous) => ({ ...previous, ...transactionStageToStep(stage, hash) })), + }) + + setState((previous) => ({ + ...previous, + registrationHash: hash, + transactionSteps: createTransactionSteps('confirmed'), + onboardingStepId: 'success', + })) + await loadRuntimeState() + } catch (err: unknown) { + setState((previous) => ({ + ...previous, + status: 'onboarding_required', + error: friendlyGovernanceError(err), + transactionSteps: createTransactionSteps('failed', friendlyGovernanceError(err)), + })) + } + }, + [account, addresses, loadRuntimeState, provider, publicClient, state.minimumStakeAmounts, state.selectedHouse], + ) + + const restake = useCallback(async () => { + if (!account || !addresses.houses) return + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) return + const house = state.member?.house ?? state.selectedHouse + + try { + const hash = await restakeWithTransferAndCall({ + publicClient, + walletClient, + account, + addresses: { ...addresses, houses: addresses.houses }, + stakeAmountWei: state.minimumStakeAmounts[house], + onStage: (stage, hash) => setState((previous) => ({ ...previous, ...transactionStageToStep(stage, hash) })), + }) + setState((previous) => ({ ...previous, registrationHash: hash, error: null })) + await loadRuntimeState() + } catch (err: unknown) { + setState((previous) => ({ + ...previous, + status: 'restake_required', + error: friendlyGovernanceError(err), + transactionSteps: createTransactionSteps('failed', friendlyGovernanceError(err)), + })) + } + }, [account, addresses, loadRuntimeState, provider, publicClient, state.member?.house, state.minimumStakeAmounts, state.selectedHouse]) + + const openVote = useCallback(() => { + setState((previous) => ({ ...previous, status: 'vote_detail' })) + }, []) + + const closeVote = useCallback(() => { + setState((previous) => ({ + ...previous, + status: previous.member ? statusFromMember(previous.member) : 'disconnected', + })) + }, []) + + const setVoteAllocation = useCallback((recipientId: string, basisPoints: number) => { + setState((previous) => { + const allocationsBps = { + ...previous.dashboard.alignmentVoting.allocationsBps, + [recipientId]: Math.max(0, Math.min(10000, basisPoints)), + } + const allocationTotalBps = Object.values(allocationsBps).reduce((total, amount) => total + amount, 0) + return { + ...previous, + dashboard: { + ...previous.dashboard, + alignmentVoting: { + ...previous.dashboard.alignmentVoting, + allocationsBps, + allocationTotalBps, + disabledReason: allocationTotalBps === 10000 ? previous.dashboard.alignmentVoting.disabledReason : 'Allocation totals must equal exactly 10,000 basis points.', + }, + }, + } + }) + }, []) + + const submitVote = useCallback(async () => { + const vote = state.dashboard.alignmentVoting + if (!account || !addresses.houses || vote.allocationTotalBps !== 10000) return + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) return + + try { + const recipients = vote.options + .map((option) => option.id) + .filter((recipient): recipient is Address => isAddress(recipient)) + .map((recipient) => getAddress(recipient)) + const allocations = recipients.map((recipient) => BigInt(vote.allocationsBps[recipient] ?? 0)) + await castGovernanceVote({ + publicClient, + walletClient, + account, + housesAddress: addresses.houses, + recipients, + allocationsBps: allocations, + }) + setState((previous) => ({ + ...previous, + dashboard: { + ...previous.dashboard, + alignmentVoting: { ...previous.dashboard.alignmentVoting, hasVoted: true, canVote: false, disabledReason: 'You already voted in this allocation cycle.' }, + }, + })) + await loadRuntimeState() + } catch (err: unknown) { + setState((previous) => ({ ...previous, status: 'friendly_error', error: friendlyGovernanceError(err) })) + } + }, [account, addresses.houses, loadRuntimeState, provider, publicClient, state.dashboard.alignmentVoting]) + + const startIdentityVerification = useCallback(async () => { + if (!account) return + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) return + + const identitySdk = createGovernanceIdentitySdk({ + publicClient, + walletClient, + environment, + }) + const returnUrl = typeof window !== 'undefined' ? window.location.href : undefined + const identityVerificationUrl = await createGovernanceIdentityVerificationLink({ + identitySdk, + returnUrl, + chainId: chainId ?? undefined, + }) + setState((previous) => ({ ...previous, identityVerificationUrl })) + if (typeof window !== 'undefined') { + window.open(identityVerificationUrl, '_blank', 'noopener,noreferrer') + } + }, [account, chainId, environment, provider, publicClient]) + + const actions = useMemo( + () => ({ + connect, + switchToCelo, + refresh: loadRuntimeState, + retry: loadRuntimeState, + selectHouse, + register, + restake, + openVote, + closeVote, + setVoteAllocation, + submitVote, + startIdentityVerification, + }), + [ + closeVote, + connect, + loadRuntimeState, + openVote, + register, + restake, + selectHouse, + setVoteAllocation, + startIdentityVerification, + submitVote, + switchToCelo, + ], + ) + + const resolvedState = useMemo( + () => ({ + ...state, + address: account, + chainId: chainId ?? null, + dashboard: { + ...state.dashboard, + impact: { + ...state.dashboard.impact, + ctaLabel: 'View Impact Report Q3', + }, + }, + }), + [account, chainId, state], + ) + + return { state: resolvedState, actions } +} + +export { createDashboardState, createInitialState, friendlyGovernanceError, isActiveStatus } diff --git a/packages/governance-widget/src/index.ts b/packages/governance-widget/src/index.ts index 141a7b1e..fb2a25cf 100644 --- a/packages/governance-widget/src/index.ts +++ b/packages/governance-widget/src/index.ts @@ -5,6 +5,7 @@ export { OptimisticVotingProposalCard } from './OptimisticVotingProposalCard' export { FundingDistributionChart } from './FundingDistributionChart' export { GovernanceWidgetProvider } from './GovernanceWidgetProvider' export { GovernanceOnboardingWidget } from './GovernanceOnboardingWidget' +export { GovernanceWidget } from './GovernanceWidget' export type { GovernanceWidgetProviderProps } from './types' export type { GovernanceAmount, @@ -35,3 +36,28 @@ export type { } from './types' export { DEFAULT_TRANSACTION_STEPS } from './onboarding/constants' +export { useGovernanceAdapter } from './adapter' +export type { + GovernanceDashboardState, + GovernanceVotingState, + GovernanceWidgetAdapterActions, + GovernanceWidgetAdapterFactory, + GovernanceWidgetAdapterFactoryInput, + GovernanceWidgetAdapterResult, + GovernanceWidgetAdapterState, + GovernanceWidgetProps, + GovernanceWidgetStatus, +} from './widgetRuntimeContract' +export { + CELO_CHAIN_ID, + CELO_GOODID_ADDRESS, + DEFAULT_CELO_RPC_URL, + G_TOKEN_CELO_ADDRESS, + encodeGovernanceRegistrationData, + mapFlowSplitterConfig, + mapHoaEligibilityRecord, + mapMemberRecord, + mapVoteConfig, + resolveGovernanceAddresses, +} from './sdks/contracts' +export { calculateStreamAmountWei, fetchFundingReceivedSoFar } from './sdks/funding' diff --git a/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx index 1bd80102..202ffff8 100644 --- a/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx +++ b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx @@ -28,6 +28,9 @@ interface GovernanceOnboardingFlowProps { stakeAmountLabel: string transactionSteps: StepperStepItem[] finalActions: GovernanceOnboardingAction[] + onHouseChange?: (house: GovernanceHouse) => void + onIdentityVerificationPress?: () => void + onProfileSubmit?: (profileDraft: GovernanceWizardData['profileDraft'], house: GovernanceHouse) => void onFinalActionPress?: (actionId: string) => void dataTestId?: string } @@ -40,6 +43,9 @@ export function GovernanceOnboardingFlow({ stakeAmountLabel, transactionSteps = DEFAULT_TRANSACTION_STEPS, finalActions = DEFAULT_FINAL_ACTIONS, + onHouseChange, + onIdentityVerificationPress, + onProfileSubmit, onFinalActionPress, dataTestId, }: GovernanceOnboardingFlowProps) { @@ -95,12 +101,14 @@ export function GovernanceOnboardingFlow({ setFieldErrors(nextFieldErrors) if (Object.keys(nextFieldErrors).length === 0) { + onProfileSubmit?.(profileDraft, resolvedHouse) next() } } const handleHouseSelect = (nextHouse: GovernanceHouse) => { setData({ selectedHouse: nextHouse }) + onHouseChange?.(nextHouse) } let shellTitle = 'Governance onboarding' @@ -121,6 +129,7 @@ export function GovernanceOnboardingFlow({ walletAddress={walletAddress} isIdentityVerified={isIdentityVerified} onProceedPress={next} + onVerifyPress={onIdentityVerificationPress} /> ) // Footer is null — "Proceed to Membership" is inside OnboardingIdentityCard diff --git a/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx b/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx index ebb2467e..0430b601 100644 --- a/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx +++ b/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx @@ -6,6 +6,7 @@ interface OnboardingIdentityCardProps { identityStatus: GovernanceIdentityStatus walletAddress?: string onProceedPress?: () => void + onVerifyPress?: () => void } /** Left-border accent row used for the Identity Status field when verified. */ @@ -41,6 +42,7 @@ export function OnboardingIdentityCard({ identityStatus, walletAddress, onProceedPress, + onVerifyPress, }: OnboardingIdentityCardProps) { const isVerified = identityStatus === 'verified' @@ -123,18 +125,27 @@ export function OnboardingIdentityCard({ {/* ── CTA button ─────────────────────────────────────────── */} - {/* Figma: single "Proceed to Membership" button, blue when verified, - disabled (grey outline) when unverified. No separate "Verify" button. */} - + {isVerified ? ( + + ) : ( + + )} ) diff --git a/packages/governance-widget/src/onboarding/steps/WelcomeStepContent.tsx b/packages/governance-widget/src/onboarding/steps/WelcomeStepContent.tsx index fe34952a..3ce2c8da 100644 --- a/packages/governance-widget/src/onboarding/steps/WelcomeStepContent.tsx +++ b/packages/governance-widget/src/onboarding/steps/WelcomeStepContent.tsx @@ -7,6 +7,7 @@ interface WelcomeStepContentProps { walletAddress?: string isIdentityVerified: boolean onProceedPress?: () => void + onVerifyPress?: () => void } export function WelcomeStepContent({ @@ -14,6 +15,7 @@ export function WelcomeStepContent({ walletAddress, isIdentityVerified, onProceedPress, + onVerifyPress, }: WelcomeStepContentProps) { return ( @@ -21,6 +23,7 @@ export function WelcomeStepContent({ identityStatus={identityStatus} walletAddress={walletAddress} onProceedPress={isIdentityVerified ? onProceedPress : undefined} + onVerifyPress={onVerifyPress} /> ) diff --git a/packages/governance-widget/src/sdks/contractReads.ts b/packages/governance-widget/src/sdks/contractReads.ts new file mode 100644 index 00000000..826227a2 --- /dev/null +++ b/packages/governance-widget/src/sdks/contractReads.ts @@ -0,0 +1,207 @@ +import type { Address, PublicClient } from 'viem' +import type { GovernanceHouse } from '../types' +import { + GOODDAO_HOUSES_ABI, + formatStakeAmount, + houseToContractValue, + mapFlowSplitterConfig, + mapHoaEligibilityRecord, + mapMemberRecord, + mapVoteConfig, + readGoodIdRoot, + type GovernanceFlowSplitterConfig, + type GovernanceHoaEligibilityRecord, + type GovernanceMemberRecord, + type GovernanceVoteConfig, +} from './contracts' + +export interface GovernanceStakeRequirements { + citizenship: bigint + alignment: bigint +} + +export interface GovernanceSchedule { + cycleStartTime: number | null + termDurationSeconds: bigint + votingTermLengthSeconds: bigint +} + +export interface GovernanceMembershipReads { + member: GovernanceMemberRecord + minimumStakes: GovernanceStakeRequirements + hoaEligibility: GovernanceHoaEligibilityRecord + identityRoot: Address + activeCitizens: Address[] + activeAlignment: Address[] +} + +export async function readGovernanceMembership(params: { + publicClient: PublicClient + housesAddress: Address + goodIdAddress: Address + account: Address +}): Promise { + const { publicClient, housesAddress, goodIdAddress, account } = params + const [member, citizenshipStake, alignmentStake, hoaEligibility, identityRoot, activeCitizens, activeAlignment] = + await Promise.all([ + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getMember', + args: [account], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'minimumStake', + args: [houseToContractValue('citizenship')], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'minimumStake', + args: [houseToContractValue('alignment')], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getHoaEligibility', + args: [account], + }), + readGoodIdRoot(publicClient, goodIdAddress, account), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getActiveMembers', + args: [houseToContractValue('citizenship')], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getActiveMembers', + args: [houseToContractValue('alignment')], + }), + ]) + + return { + member: mapMemberRecord(member), + minimumStakes: { + citizenship: citizenshipStake, + alignment: alignmentStake, + }, + hoaEligibility: mapHoaEligibilityRecord(hoaEligibility), + identityRoot, + activeCitizens, + activeAlignment, + } +} + +export async function readGovernanceSchedule(params: { + publicClient: PublicClient + housesAddress: Address +}): Promise { + const { publicClient, housesAddress } = params + const [cycleStartTime, termDuration, votingTermLength] = await Promise.all([ + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'cycleStartTime', + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'termDuration', + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'votingTermLength', + }), + ]) + + return { + cycleStartTime: cycleStartTime > 0n ? Number(cycleStartTime) * 1000 : null, + termDurationSeconds: termDuration, + votingTermLengthSeconds: votingTermLength, + } +} + +export async function readGovernanceVote(params: { + publicClient: PublicClient + housesAddress: Address + voterKey: Address + activeAlignment: Address[] +}): Promise<{ + isVotingPeriod: boolean + voteId: bigint + voteConfig: GovernanceVoteConfig + recipients: Address[] + hasVoted: boolean + finalizedUnits: Record +}> { + const { publicClient, housesAddress, voterKey, activeAlignment } = params + const [isVotingPeriod, voteId] = await Promise.all([ + publicClient.readContract({ address: housesAddress, abi: GOODDAO_HOUSES_ABI, functionName: 'isVotingPeriod' }), + publicClient.readContract({ address: housesAddress, abi: GOODDAO_HOUSES_ABI, functionName: 'getCurrentVoteId' }), + ]) + + const [rawVoteConfig, rawRecipients, hasVoted] = await Promise.all([ + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getVoteConfig', + args: [voteId], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getVoteRecipients', + args: [voteId], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getHasVoted', + args: [voteId, voterKey], + }), + ]) + + const voteConfig = mapVoteConfig(rawVoteConfig) + const recipients = rawRecipients.length > 0 ? rawRecipients : isVotingPeriod ? activeAlignment : rawRecipients + const finalizedEntries = await Promise.all( + recipients.map(async (recipient) => { + const units = await publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getFinalizedUnits', + args: [voteId, recipient], + }) + return [recipient, units] as const + }), + ) + + return { + isVotingPeriod, + voteId, + voteConfig, + recipients, + hasVoted, + finalizedUnits: Object.fromEntries(finalizedEntries) as Record, + } +} + +export async function readFlowSplitterConfig(params: { + publicClient: PublicClient + housesAddress: Address +}): Promise { + const rawConfig = await params.publicClient.readContract({ + address: params.housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'flowSplitterConfig', + }) + return mapFlowSplitterConfig(rawConfig) +} + +export function stakeRequirementLabel(minimumStakes: GovernanceStakeRequirements, house: GovernanceHouse): string { + return formatStakeAmount(minimumStakes[house]) +} diff --git a/packages/governance-widget/src/sdks/contracts.ts b/packages/governance-widget/src/sdks/contracts.ts new file mode 100644 index 00000000..6af432e8 --- /dev/null +++ b/packages/governance-widget/src/sdks/contracts.ts @@ -0,0 +1,316 @@ +import { + createPublicClient, + createWalletClient, + custom, + encodeAbiParameters, + formatUnits, + http, + parseAbi, + type Address, + type Chain, + type EIP1193Provider as ViemEip1193Provider, + type Hex, + type PublicClient, + type TransactionReceipt, + type WalletClient, +} from 'viem' + +import type { EIP1193Provider } from '@goodwidget/core' +import type { GovernanceHouse, GovernanceProfileDraft } from '../types' + +export const CELO_CHAIN_ID = 42220 +export const DEFAULT_CELO_RPC_URL = 'https://forno.celo.org' +export const G_TOKEN_CELO_ADDRESS: Address = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A' +export const CELO_GOODID_ADDRESS: Address = '0xC361A6E67822a0EDc17D899227dd9FC50BD62F42' +export const ZERO_ADDRESS: Address = '0x0000000000000000000000000000000000000000' + +export const CELO_CHAIN: Chain = { + id: CELO_CHAIN_ID, + name: 'Celo', + nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 }, + rpcUrls: { + default: { http: [DEFAULT_CELO_RPC_URL] }, + public: { http: [DEFAULT_CELO_RPC_URL] }, + }, +} + +export const GOODDAO_HOUSES_ABI = parseAbi([ + 'function minimumStake(uint8 house) view returns (uint256)', + 'function getMember(address account) view returns ((uint8 house, uint8 status, uint256 stakedAmount, uint64 joinedAt, uint64 updatedAt, uint64 unstakedAt, uint256 memberIndex, string name, string socialLinks, string projectWebpage, string missionStatement, string distributionStrategy))', + 'function getHoaEligibility(address account) view returns ((bool isEligible, uint64 listedAt, uint64 updatedAt, uint64 delistedAt))', + 'function getActiveMembers(uint8 house) view returns (address[])', + 'function getActiveMembers(uint8 house, uint256 startIndex, uint256 endIndex) view returns (address[])', + 'function cycleStartTime() view returns (uint64)', + 'function termDuration() view returns (uint64)', + 'function votingTermLength() view returns (uint64)', + 'function isVotingPeriod() view returns (bool)', + 'function getCurrentVoteId() view returns (uint256)', + 'function getVoteConfig(uint256 voteId) view returns ((uint64 startTime, uint64 endTime, uint64 executedAt, bool executed))', + 'function getVoteRecipients(uint256 voteId) view returns (address[])', + 'function getHasVoted(uint256 voteId, address voter) view returns (bool)', + 'function getFinalizedUnits(uint256 voteId, address recipient) view returns (uint128)', + 'function flowSplitterConfig() view returns (address splitter, uint256 poolId, address poolAddress)', + 'function castVote(address[] recipients, uint256[] allocations)', +]) + +export const G_TOKEN_ABI = parseAbi([ + 'function balanceOf(address account) view returns (uint256)', + 'function decimals() view returns (uint8)', + 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)', +]) + +export const GOODID_ABI = parseAbi([ + 'function getWhitelistedRoot(address account) view returns (address)', +]) + +export type GovernanceMemberStatus = 'none' | 'pending' | 'active' | 'revoked' | 'unstaked' + +export interface GovernanceMemberRecord { + house: GovernanceHouse + status: GovernanceMemberStatus + stakedAmount: bigint + joinedAt: number | null + updatedAt: number | null + unstakedAt: number | null + memberIndex: bigint + name: string + socialLinks: string + projectWebpage: string + missionStatement: string + distributionStrategy: string +} + +export interface GovernanceVoteConfig { + startTime: number | null + endTime: number | null + executedAt: number | null + executed: boolean +} + +export interface GovernanceFlowSplitterConfig { + splitter: Address + poolId: bigint + poolAddress: Address +} + +export interface GovernanceHoaEligibilityRecord { + isEligible: boolean + listedAt: number | null + updatedAt: number | null + delistedAt: number | null +} + +export interface GovernanceContractAddresses { + houses?: Address + gToken: Address + goodId: Address +} + +export interface GovernanceClientOptions { + provider?: EIP1193Provider | null + account?: Address | null + rpcUrl?: string +} + +export interface GovernanceEnvironmentConfig { + housesAddress?: Address + gTokenAddress?: Address + goodIdAddress?: Address +} + +const HOUSE_TO_CONTRACT: Record = { + citizenship: 0, + alignment: 1, +} + +const CONTRACT_TO_HOUSE: Record = { + 0: 'citizenship', + 1: 'alignment', +} + +const CONTRACT_TO_STATUS: Record = { + 0: 'none', + 1: 'pending', + 2: 'active', + 3: 'revoked', + 4: 'unstaked', +} + +type PublicRuntimeEnv = Record + +type RuntimeGlobal = typeof globalThis & { + process?: { env?: PublicRuntimeEnv } + __GOODWIDGET_GOVERNANCE__?: GovernanceEnvironmentConfig +} + +function readConfiguredAddress(key: keyof GovernanceEnvironmentConfig, envNames: string[]): Address | undefined { + const runtimeGlobal = globalThis as RuntimeGlobal + const explicitAddress = runtimeGlobal.__GOODWIDGET_GOVERNANCE__?.[key] + if (explicitAddress) return explicitAddress + + const publicEnv = runtimeGlobal.process?.env + const configuredValue = envNames.map((envName) => publicEnv?.[envName]).find(Boolean) + return configuredValue as Address | undefined +} + +export function resolveGovernanceAddresses( + overrides: GovernanceEnvironmentConfig = {}, +): GovernanceContractAddresses { + return { + houses: + overrides.housesAddress ?? + readConfiguredAddress('housesAddress', [ + 'GOODWIDGET_GOVERNANCE_HOUSES_ADDRESS', + 'VITE_GOODWIDGET_GOVERNANCE_HOUSES_ADDRESS', + 'NEXT_PUBLIC_GOODWIDGET_GOVERNANCE_HOUSES_ADDRESS', + ]), + gToken: overrides.gTokenAddress ?? G_TOKEN_CELO_ADDRESS, + goodId: overrides.goodIdAddress ?? CELO_GOODID_ADDRESS, + } +} + +export function createGovernancePublicClient(rpcUrl = DEFAULT_CELO_RPC_URL): PublicClient { + return createPublicClient({ chain: CELO_CHAIN, transport: http(rpcUrl) }) +} + +export function createGovernanceWalletClient({ + provider, + account, +}: GovernanceClientOptions): WalletClient | null { + if (!provider || !account) return null + + return createWalletClient({ + account, + chain: CELO_CHAIN, + transport: custom(provider as ViemEip1193Provider), + }) +} + +export function houseToContractValue(house: GovernanceHouse): 0 | 1 { + return HOUSE_TO_CONTRACT[house] +} + +export function encodeGovernanceRegistrationData( + house: GovernanceHouse, + profileDraft: GovernanceProfileDraft, +): Hex { + return encodeAbiParameters( + [ + { type: 'uint8' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, + ], + [ + houseToContractValue(house), + profileDraft.name ?? '', + profileDraft.socialLinks ?? '', + profileDraft.projectWebpage ?? '', + profileDraft.missionStatement ?? '', + profileDraft.distributionStrategy ?? '', + ], + ) +} + +function timestampFromSeconds(value: bigint): number | null { + return value > 0n ? Number(value) * 1000 : null +} + +function tupleValue(rawValue: unknown, index: number, key: string): unknown { + if (Array.isArray(rawValue)) return rawValue[index] + return (rawValue as Record | undefined)?.[key] +} + +export function mapMemberRecord(rawMember: readonly unknown[] | Record): GovernanceMemberRecord { + const houseValue = Number(tupleValue(rawMember, 0, 'house') ?? 0) + const statusValue = Number(tupleValue(rawMember, 1, 'status') ?? 0) + + return { + house: CONTRACT_TO_HOUSE[houseValue] ?? 'citizenship', + status: CONTRACT_TO_STATUS[statusValue] ?? 'none', + stakedAmount: BigInt(String(tupleValue(rawMember, 2, 'stakedAmount') ?? 0)), + joinedAt: timestampFromSeconds(BigInt(String(tupleValue(rawMember, 3, 'joinedAt') ?? 0))), + updatedAt: timestampFromSeconds(BigInt(String(tupleValue(rawMember, 4, 'updatedAt') ?? 0))), + unstakedAt: timestampFromSeconds(BigInt(String(tupleValue(rawMember, 5, 'unstakedAt') ?? 0))), + memberIndex: BigInt(String(tupleValue(rawMember, 6, 'memberIndex') ?? 0)), + name: String(tupleValue(rawMember, 7, 'name') ?? ''), + socialLinks: String(tupleValue(rawMember, 8, 'socialLinks') ?? ''), + projectWebpage: String(tupleValue(rawMember, 9, 'projectWebpage') ?? ''), + missionStatement: String(tupleValue(rawMember, 10, 'missionStatement') ?? ''), + distributionStrategy: String(tupleValue(rawMember, 11, 'distributionStrategy') ?? ''), + } +} + +export function mapVoteConfig(rawConfig: readonly unknown[] | Record): GovernanceVoteConfig { + return { + startTime: timestampFromSeconds(BigInt(String(tupleValue(rawConfig, 0, 'startTime') ?? 0))), + endTime: timestampFromSeconds(BigInt(String(tupleValue(rawConfig, 1, 'endTime') ?? 0))), + executedAt: timestampFromSeconds(BigInt(String(tupleValue(rawConfig, 2, 'executedAt') ?? 0))), + executed: Boolean(tupleValue(rawConfig, 3, 'executed')), + } +} + +export function mapFlowSplitterConfig(rawConfig: readonly unknown[] | Record): GovernanceFlowSplitterConfig { + return { + splitter: (tupleValue(rawConfig, 0, 'splitter') ?? ZERO_ADDRESS) as Address, + poolId: BigInt(String(tupleValue(rawConfig, 1, 'poolId') ?? 0)), + poolAddress: (tupleValue(rawConfig, 2, 'poolAddress') ?? ZERO_ADDRESS) as Address, + } +} + +export function mapHoaEligibilityRecord(rawRecord: readonly unknown[] | Record): GovernanceHoaEligibilityRecord { + return { + isEligible: Boolean(tupleValue(rawRecord, 0, 'isEligible')), + listedAt: timestampFromSeconds(BigInt(String(tupleValue(rawRecord, 1, 'listedAt') ?? 0))), + updatedAt: timestampFromSeconds(BigInt(String(tupleValue(rawRecord, 2, 'updatedAt') ?? 0))), + delistedAt: timestampFromSeconds(BigInt(String(tupleValue(rawRecord, 3, 'delistedAt') ?? 0))), + } +} + +export function formatStakeAmount(amountWei: bigint): string { + return `${formatUnits(amountWei, 18)} G$` +} + +export async function readGoodIdRoot( + publicClient: PublicClient, + goodIdAddress: Address, + account: Address, +): Promise
{ + return publicClient.readContract({ + address: goodIdAddress, + abi: GOODID_ABI, + functionName: 'getWhitelistedRoot', + args: [account], + }) +} + +export async function readGoodIdVerification( + publicClient: PublicClient, + goodIdAddress: Address, + account: Address, +): Promise { + const root = await readGoodIdRoot(publicClient, goodIdAddress, account) + return root.toLowerCase() !== ZERO_ADDRESS +} + +export async function waitForSuccessfulReceipt( + publicClient: PublicClient, + hash: Hex, +): Promise { + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') { + throw new Error('Transaction reverted') + } + return receipt +} + +export async function requestCeloMainnetSwitch(provider: EIP1193Provider | null): Promise { + if (!provider) return + + await provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${CELO_CHAIN_ID.toString(16)}` }], + }) +} diff --git a/packages/governance-widget/src/sdks/funding.ts b/packages/governance-widget/src/sdks/funding.ts new file mode 100644 index 00000000..ff63c9fd --- /dev/null +++ b/packages/governance-widget/src/sdks/funding.ts @@ -0,0 +1,92 @@ +import { formatUnits, getAddress, type Address } from 'viem' + +export const SUPERFLUID_CELO_SUBGRAPH_URL = 'https://subgraph-endpoints.superfluid.dev/celo-mainnet/protocol-v1' +export const FUNDING_STREAMS_QUERY = `query FundingStreams($receiver: String!, $token: String!, $first: Int!, $skip: Int!) { + streams( + first: $first + skip: $skip + where: { receiver: $receiver, token: $token } + ) { + sender { id } + currentFlowRate + streamedUntilUpdatedAt + updatedAtTimestamp + } +}` + +export interface FundingStreamRecord { + sender: { id: string } + currentFlowRate: string + streamedUntilUpdatedAt: string + updatedAtTimestamp: string +} + +export interface FundingTotalResult { + amountWei: bigint + formattedAmount: string + streamCount: number +} + +interface FundingStreamsResponse { + data?: { streams?: FundingStreamRecord[] } + errors?: Array<{ message?: string }> +} + +export function calculateStreamAmountWei(stream: FundingStreamRecord, nowSeconds: bigint): bigint { + const streamedUntilUpdatedAt = BigInt(stream.streamedUntilUpdatedAt) + const currentFlowRate = BigInt(stream.currentFlowRate) + const updatedAtTimestamp = BigInt(stream.updatedAtTimestamp) + const elapsedSeconds = nowSeconds > updatedAtTimestamp ? nowSeconds - updatedAtTimestamp : 0n + return streamedUntilUpdatedAt + currentFlowRate * elapsedSeconds +} + +export async function fetchFundingReceivedSoFar(params: { + receiver: Address + token: Address + nowSeconds?: bigint + endpoint?: string + fetcher?: typeof fetch +}): Promise { + const endpoint = params.endpoint ?? SUPERFLUID_CELO_SUBGRAPH_URL + const fetcher = params.fetcher ?? fetch + const receiver = getAddress(params.receiver).toLowerCase() + const token = getAddress(params.token).toLowerCase() + const nowSeconds = params.nowSeconds ?? BigInt(Math.floor(Date.now() / 1000)) + const first = 1000 + let skip = 0 + let total = 0n + for (;;) { + const response = await fetcher(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: FUNDING_STREAMS_QUERY, + variables: { receiver, token, first, skip }, + }), + }) + + if (!response.ok) { + throw new Error(`Funding subgraph returned HTTP ${response.status}`) + } + + const json = (await response.json()) as FundingStreamsResponse + if (json.errors?.length) { + throw new Error(json.errors.map((error) => error.message).filter(Boolean).join('; ') || 'Funding subgraph error') + } + + const streams = json.data?.streams ?? [] + for (const stream of streams) { + total += calculateStreamAmountWei(stream, nowSeconds) + } + + if (streams.length < first) { + return { + amountWei: total, + formattedAmount: formatUnits(total, 18), + streamCount: skip + streams.length, + } + } + + skip += first + } +} diff --git a/packages/governance-widget/src/sdks/identity.ts b/packages/governance-widget/src/sdks/identity.ts new file mode 100644 index 00000000..6df5a0b1 --- /dev/null +++ b/packages/governance-widget/src/sdks/identity.ts @@ -0,0 +1,30 @@ +import { IdentitySDK } from '@goodsdks/citizen-sdk' +import type { PublicClient, WalletClient } from 'viem' + +export type GovernanceIdentityEnvironment = 'production' | 'staging' | 'development' + +export interface GovernanceIdentitySdkInput { + publicClient: PublicClient + walletClient: WalletClient + environment: GovernanceIdentityEnvironment +} + +export function createGovernanceIdentitySdk({ + publicClient, + walletClient, + environment, +}: GovernanceIdentitySdkInput): IdentitySDK { + return new IdentitySDK({ publicClient, walletClient, env: environment }) +} + +export async function createGovernanceIdentityVerificationLink({ + identitySdk, + returnUrl, + chainId, +}: { + identitySdk: IdentitySDK + returnUrl?: string + chainId?: number +}): Promise { + return identitySdk.generateFVLink(false, returnUrl, chainId) +} diff --git a/packages/governance-widget/src/sdks/transactions.ts b/packages/governance-widget/src/sdks/transactions.ts new file mode 100644 index 00000000..ea122150 --- /dev/null +++ b/packages/governance-widget/src/sdks/transactions.ts @@ -0,0 +1,85 @@ +import type { Address, Hex, PublicClient, WalletClient } from 'viem' +import type { GovernanceHouse, GovernanceProfileDraft } from '../types' +import { + CELO_CHAIN, + G_TOKEN_ABI, + GOODDAO_HOUSES_ABI, + encodeGovernanceRegistrationData, + waitForSuccessfulReceipt, + type GovernanceContractAddresses, +} from './contracts' + +export type GovernanceTransactionStage = 'wallet_confirmation' | 'submitted' | 'confirmed' + +export async function registerWithTransferAndCall(params: { + publicClient: PublicClient + walletClient: WalletClient + account: Address + addresses: GovernanceContractAddresses & { houses: Address } + selectedHouse: GovernanceHouse + profileDraft: GovernanceProfileDraft + stakeAmountWei: bigint + onStage?: (stage: GovernanceTransactionStage, hash?: Hex) => void +}): Promise { + params.onStage?.('wallet_confirmation') + const registrationData = encodeGovernanceRegistrationData(params.selectedHouse, params.profileDraft) + const hash = await params.walletClient.writeContract({ + account: params.account, + chain: CELO_CHAIN, + address: params.addresses.gToken, + abi: G_TOKEN_ABI, + functionName: 'transferAndCall', + args: [params.addresses.houses, params.stakeAmountWei, registrationData], + }) + params.onStage?.('submitted', hash) + await waitForSuccessfulReceipt(params.publicClient, hash) + params.onStage?.('confirmed', hash) + return hash +} + +export async function restakeWithTransferAndCall(params: { + publicClient: PublicClient + walletClient: WalletClient + account: Address + addresses: GovernanceContractAddresses & { houses: Address } + stakeAmountWei: bigint + onStage?: (stage: GovernanceTransactionStage, hash?: Hex) => void +}): Promise { + params.onStage?.('wallet_confirmation') + const hash = await params.walletClient.writeContract({ + account: params.account, + chain: CELO_CHAIN, + address: params.addresses.gToken, + abi: G_TOKEN_ABI, + functionName: 'transferAndCall', + args: [params.addresses.houses, params.stakeAmountWei, '0x'], + }) + params.onStage?.('submitted', hash) + await waitForSuccessfulReceipt(params.publicClient, hash) + params.onStage?.('confirmed', hash) + return hash +} + +export async function castGovernanceVote(params: { + publicClient: PublicClient + walletClient: WalletClient + account: Address + housesAddress: Address + recipients: Address[] + allocationsBps: bigint[] + onStage?: (stage: GovernanceTransactionStage, hash?: Hex) => void +}): Promise { + params.onStage?.('wallet_confirmation') + const hash = await params.walletClient.writeContract({ + account: params.account, + chain: CELO_CHAIN, + address: params.housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'castVote', + args: [params.recipients, params.allocationsBps], + }) + params.onStage?.('submitted', hash) + await waitForSuccessfulReceipt(params.publicClient, hash) + params.onStage?.('confirmed', hash) + return hash +} diff --git a/packages/governance-widget/src/types.ts b/packages/governance-widget/src/types.ts index ec010f84..52c777b6 100644 --- a/packages/governance-widget/src/types.ts +++ b/packages/governance-widget/src/types.ts @@ -58,6 +58,9 @@ export interface GovernanceOnboardingWidgetProps { transactionSteps?: StepperStepItem[] finalActions?: GovernanceOnboardingAction[] dataTestId?: string + onHouseChange?: (house: GovernanceHouse) => void + onIdentityVerificationPress?: () => void + onProfileSubmit?: (profileDraft: GovernanceProfileDraft, house: GovernanceHouse) => void onStepChange?: (stepId: GovernanceOnboardingStepId) => void onFinalActionPress?: (actionId: string) => void } @@ -160,6 +163,7 @@ export interface FundingDistributionChartProps { totalAmount: GovernanceAmount projects: FundingProjectAllocation[] isStreaming?: boolean + stateLabel?: string onProjectPress?: (id: string) => void testID?: string } diff --git a/packages/governance-widget/src/widgetRuntimeContract.ts b/packages/governance-widget/src/widgetRuntimeContract.ts new file mode 100644 index 00000000..19b0aaae --- /dev/null +++ b/packages/governance-widget/src/widgetRuntimeContract.ts @@ -0,0 +1,108 @@ +import type { Address, Hex } from 'viem' +import type { StepperStepItem } from '@goodwidget/ui' +import type { EIP1193Provider, GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/core' +import type { + BalanceCardProps, + FundingDistributionChartProps, + GovernanceHouse, + GovernanceIdentityStatus, + GovernanceOnboardingStepId, + GovernanceProfileDraft, + ImpactCardProps, + RankedVotingOption, +} from './types' +import type { GovernanceEnvironmentConfig, GovernanceMemberRecord } from './sdks/contracts' +import type { GovernanceIdentityEnvironment } from './sdks/identity' + +export type GovernanceWidgetStatus = + | 'disconnected' + | 'loading' + | 'unsupported_chain' + | 'onboarding_required' + | 'pending_alignment' + | 'active_citizenship' + | 'active_alignment' + | 'restake_required' + | 'vote_detail' + | 'friendly_error' + +export interface GovernanceVotingState { + voteId: string + title: string + summaryLabel: string + options: RankedVotingOption[] + recipients: Address[] + allocationsBps: Record + allocationTotalBps: number + canVote: boolean + hasVoted: boolean + isVotingOpen: boolean + executed: boolean + finalizedUnits: Record + disabledReason?: string +} + +export interface GovernanceDashboardState { + impact: ImpactCardProps + activeMembers: BalanceCardProps + alignmentVoting: GovernanceVotingState + fundingDistribution: FundingDistributionChartProps +} + +export interface GovernanceWidgetAdapterState { + status: GovernanceWidgetStatus + address: Address | null + chainId: number | null + identityStatus: GovernanceIdentityStatus + identityVerificationUrl: string | null + member: GovernanceMemberRecord | null + dashboard: GovernanceDashboardState + selectedHouse: GovernanceHouse + disabledHouseOptions: GovernanceHouse[] + onboardingStepId?: GovernanceOnboardingStepId + profileDraft: GovernanceProfileDraft + stakeAmountLabel: string + minimumStakeAmounts: Record + transactionSteps: StepperStepItem[] + registrationHash: Hex | null + error: string | null +} + +export interface GovernanceWidgetAdapterActions { + connect: () => Promise + switchToCelo: () => Promise + refresh: () => Promise + retry: () => Promise + selectHouse: (house: GovernanceHouse) => void + register: (profileDraft: GovernanceProfileDraft) => Promise + restake: () => Promise + openVote: () => void + closeVote: () => void + setVoteAllocation: (recipientId: string, basisPoints: number) => void + submitVote: () => Promise + startIdentityVerification: () => Promise +} + +export interface GovernanceWidgetAdapterResult { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +} + +export interface GovernanceWidgetAdapterFactoryInput { + environment?: GovernanceIdentityEnvironment + celoRpcUrl?: string + addresses?: GovernanceEnvironmentConfig +} + +export type GovernanceWidgetAdapterFactory = ( + input: GovernanceWidgetAdapterFactoryInput, +) => GovernanceWidgetAdapterResult + +export interface GovernanceWidgetProps extends GovernanceWidgetAdapterFactoryInput { + provider?: EIP1193Provider + themeOverrides?: GoodWidgetThemeOverrides + config?: GoodWidgetConfig + defaultTheme?: 'light' | 'dark' + adapterFactory?: GovernanceWidgetAdapterFactory + testId?: string +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 979627a2..d1ca3171 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,9 @@ importers: packages/governance-widget: dependencies: + '@goodsdks/citizen-sdk': + specifier: 1.2.5 + version: 1.2.5(@swc/core@1.15.30)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))(wagmi@3.6.8(@tanstack/query-core@5.100.6)(@tanstack/react-query@5.100.6(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(yaml@2.8.3) '@goodwidget/core': specifier: workspace:* version: link:../core @@ -490,6 +493,9 @@ importers: tamagui: specifier: 1.121.0 version: 1.121.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) + viem: + specifier: ^2.0.0 + version: 2.48.4(typescript@5.9.3)(zod@4.1.11) devDependencies: '@types/react': specifier: ^18.3.0 diff --git a/tests/widgets/governance-widget/adapter-logic.spec.ts b/tests/widgets/governance-widget/adapter-logic.spec.ts new file mode 100644 index 00000000..fbee68bd --- /dev/null +++ b/tests/widgets/governance-widget/adapter-logic.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from '@playwright/test' +import type { Address, Hex, PublicClient, WalletClient } from 'viem' +import { + calculateStreamAmountWei, + fetchFundingReceivedSoFar, +} from '../../../packages/governance-widget/src/sdks/funding' +import { + encodeGovernanceRegistrationData, + mapFlowSplitterConfig, + mapHoaEligibilityRecord, + mapMemberRecord, + mapVoteConfig, +} from '../../../packages/governance-widget/src/sdks/contracts' +import { + registerWithTransferAndCall, + restakeWithTransferAndCall, +} from '../../../packages/governance-widget/src/sdks/transactions' + +const account = '0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08' as Address +const houses = '0x1111111111111111111111111111111111111111' as Address +const token = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A' as Address +const hash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex + +test('maps final GoodDaoHouses ABI tuples', () => { + const member = mapMemberRecord([ + 1, + 2, + 2000n, + 100n, + 110n, + 0n, + 7n, + 'Solar Commons', + 'https://social.example', + 'https://project.example', + 'Mission', + 'Distribution strategy', + ]) + + expect(member).toMatchObject({ + house: 'alignment', + status: 'active', + stakedAmount: 2000n, + memberIndex: 7n, + name: 'Solar Commons', + distributionStrategy: 'Distribution strategy', + }) + expect(member.joinedAt).toBe(100000) + + expect(mapVoteConfig([123n, 456n, 789n, true])).toMatchObject({ + startTime: 123000, + endTime: 456000, + executedAt: 789000, + executed: true, + }) + expect(mapFlowSplitterConfig(['0x2222222222222222222222222222222222222222', 9n, houses])).toMatchObject({ + splitter: '0x2222222222222222222222222222222222222222', + poolId: 9n, + poolAddress: houses, + }) + expect(mapHoaEligibilityRecord([true, 10n, 20n, 0n])).toMatchObject({ + isEligible: true, + listedAt: 10000, + updatedAt: 20000, + delistedAt: null, + }) +}) + +test('encodes house selection in registration payload', () => { + const citizenshipData = encodeGovernanceRegistrationData('citizenship', { name: 'Citizen' }) + const alignmentData = encodeGovernanceRegistrationData('alignment', { name: 'Alignment' }) + + expect(citizenshipData).not.toBe(alignmentData) + expect(citizenshipData).toContain('436974697a656e') + expect(alignmentData).toContain('416c69676e6d656e74') +}) + +test('waits for successful registration and restake receipts', async () => { + const writes: unknown[] = [] + const walletClient = { + writeContract: async (request: unknown) => { + writes.push(request) + return hash + }, + } as WalletClient + const publicClient = { + waitForTransactionReceipt: async () => ({ status: 'success' }), + } as PublicClient + + await expect(registerWithTransferAndCall({ + publicClient, + walletClient, + account, + addresses: { houses, gToken: token, goodId: account }, + selectedHouse: 'alignment', + profileDraft: { name: 'Alignment' }, + stakeAmountWei: 2000n, + })).resolves.toBe(hash) + + await expect(restakeWithTransferAndCall({ + publicClient, + walletClient, + account, + addresses: { houses, gToken: token, goodId: account }, + stakeAmountWei: 1000n, + })).resolves.toBe(hash) + + expect(writes).toHaveLength(2) + expect(JSON.stringify(writes[1], (_, value) => (typeof value === 'bigint' ? value.toString() : value))).toContain('0x') +}) + +test('does not report success for reverted transaction receipts', async () => { + const walletClient = { writeContract: async () => hash } as WalletClient + const publicClient = { + waitForTransactionReceipt: async () => ({ status: 'reverted' }), + } as PublicClient + + await expect(registerWithTransferAndCall({ + publicClient, + walletClient, + account, + addresses: { houses, gToken: token, goodId: account }, + selectedHouse: 'citizenship', + profileDraft: { name: 'Citizen' }, + stakeAmountWei: 1000n, + })).rejects.toThrow('Transaction reverted') +}) + +test('aggregates active stopped multiple and paginated Superfluid streams', async () => { + const nowSeconds = 2000n + expect(calculateStreamAmountWei({ + sender: { id: account.toLowerCase() }, + currentFlowRate: '5', + streamedUntilUpdatedAt: '100', + updatedAtTimestamp: '1990', + }, nowSeconds)).toBe(150n) + + let calls = 0 + const fetcher: typeof fetch = async (_url, init) => { + calls += 1 + const body = JSON.parse(String(init?.body)) as { variables: { first: number; skip: number } } + const count = body.variables.skip === 0 ? 1000 : 2 + return new Response(JSON.stringify({ + data: { + streams: Array.from({ length: count }, (_, index) => ({ + sender: { id: `${account.toLowerCase()}-${index}` }, + currentFlowRate: index === 0 && body.variables.skip === 1000 ? '0' : '1', + streamedUntilUpdatedAt: '10', + updatedAtTimestamp: '1995', + })), + }, + }), { status: 200 }) + } + + const result = await fetchFundingReceivedSoFar({ receiver: houses, token, nowSeconds, fetcher }) + expect(calls).toBe(2) + expect(result.streamCount).toBe(1002) + expect(result.amountWei).toBe(15025n) +}) + +test('surfaces failed Superfluid stream queries', async () => { + await expect(fetchFundingReceivedSoFar({ + receiver: houses, + token, + fetcher: async () => new Response('nope', { status: 500 }), + })).rejects.toThrow('HTTP 500') +}) diff --git a/tests/widgets/governance-widget/onboarding.spec.ts b/tests/widgets/governance-widget/onboarding.spec.ts index 8e149966..31f620d5 100644 --- a/tests/widgets/governance-widget/onboarding.spec.ts +++ b/tests/widgets/governance-widget/onboarding.spec.ts @@ -92,7 +92,7 @@ test('Governance onboarding interactive flow persists selected house into profil test('Governance onboarding shows the unverified welcome state', async ({ page }) => { await gotoStory(page, STORY_IDS.custodialWelcomeUnverified) await expect(page.getByText('Verification required')).toBeVisible() - await expect(page.getByRole('button', { name: 'Proceed to Membership' })).toBeDisabled() + await expect(page.getByRole('button', { name: 'Verify with GoodID' })).toBeVisible() await page.screenshot({ path: 'tests/widgets/governance-widget/test-results/gwo-06-welcome-unverified.png', fullPage: true, diff --git a/tests/widgets/governance-widget/runtime.spec.ts b/tests/widgets/governance-widget/runtime.spec.ts new file mode 100644 index 00000000..0a738a3e --- /dev/null +++ b/tests/widgets/governance-widget/runtime.spec.ts @@ -0,0 +1,150 @@ +import { expect, test, type Page } from '@playwright/test' + +type RuntimeStoryCase = { + id: string + testId: string + expectedText: string + screenshot: string + mockInjected?: boolean +} + +const RUNTIME_STORIES: RuntimeStoryCase[] = [ + { + id: 'qa-governancewidget-runtime-fixtures--disconnected-dashboard', + testId: 'GovernanceWidget-disconnected', + expectedText: 'Connect Wallet', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-01-disconnected-dashboard.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--loading-connected', + testId: 'GovernanceWidget-loading', + expectedText: 'Loading wallet, identity, membership, and governance data…', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-02-loading-connected.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--onboarding-required-hoa-unavailable', + testId: 'GovernanceWidget-onboarding', + expectedText: 'House of Alignment', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-03-onboarding-hoa-unavailable.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--pending-alignment', + testId: 'GovernanceWidget-pending-alignment', + expectedText: 'Alignment membership pending', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-04-pending-alignment.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--active-citizenship', + testId: 'GovernanceWidget-active_citizenship', + expectedText: 'House of Citizenship', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-05-active-citizenship.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--active-alignment-injected', + testId: 'GovernanceWidget-active_alignment', + expectedText: 'House of Alignment', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-06-active-alignment-injected.png', + mockInjected: true, + }, + { + id: 'qa-governancewidget-runtime-fixtures--vote-detail-open', + testId: 'GovernanceWidget-vote-detail', + expectedText: 'Allocation total: 10000 / 10,000 bps', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-07-vote-detail-open.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--already-voted', + testId: 'GovernanceWidget-already-voted', + expectedText: 'Ballot updates are not available', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-08-already-voted.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--vote-closed-executed', + testId: 'GovernanceWidget-active-governance', + expectedText: 'Final units executed', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-09-vote-closed-executed.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--empty-recipients', + testId: 'GovernanceWidget-active-governance', + expectedText: 'No House of Alignment members have been assigned yet', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-10-empty-recipients.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--pool-unavailable-mocked', + testId: 'GovernanceWidget-funding-distribution', + expectedText: 'No active funding distribution yet.', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-11-pool-unavailable-mocked.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--unsupported-chain', + testId: 'GovernanceWidget-unsupported-chain', + expectedText: 'Switch to Celo Mainnet', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-12-unsupported-chain.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--restake-required', + testId: 'GovernanceWidget-restake', + expectedText: 'Restore governance membership', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-13-restake-required.png', + }, + { + id: 'qa-governancewidget-runtime-fixtures--friendly-contract-error', + testId: 'GovernanceWidget-friendly-error', + expectedText: 'Governance data unavailable', + screenshot: 'tests/widgets/governance-widget/test-results/gwr-14-friendly-contract-error.png', + }, +] + +async function installInjectedProvider(page: Page): Promise { + await page.addInitScript(() => { + window.ethereum = { + request: async ({ method }: { method: string }) => { + if (method === 'eth_accounts' || method === 'eth_requestAccounts') { + return ['0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08'] + } + if (method === 'eth_chainId') return '0xa4ec' + if (method === 'wallet_switchEthereumChain') return null + return null + }, + on: () => {}, + removeListener: () => {}, + } + }) +} + +async function gotoStory(page: Page, storyId: string): Promise { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`) + await page.waitForLoadState('domcontentloaded') + await page.locator('#storybook-root').waitFor({ state: 'attached' }) + await page.waitForLoadState('networkidle') +} + +for (const storyCase of RUNTIME_STORIES) { + test(`${storyCase.id} renders runtime state`, async ({ page }) => { + await page.setViewportSize({ width: 430, height: 900 }) + if (storyCase.mockInjected) { + await installInjectedProvider(page) + } + + await gotoStory(page, storyCase.id) + + await expect(page.getByTestId(storyCase.testId).first()).toBeVisible({ timeout: 15_000 }) + await expect(page.getByText(storyCase.expectedText).first()).toBeVisible() + await page.screenshot({ path: storyCase.screenshot, fullPage: true }) + }) +} + +test('onboarding runtime fixture greys out House of Alignment for non-whitelisted wallets', async ({ + page, +}) => { + await gotoStory(page, 'qa-governancewidget-runtime-fixtures--onboarding-required-hoa-unavailable') + + const alignmentCard = page.getByTestId('GovernanceOnboardingWidget-house-alignment') + await expect(alignmentCard).toBeVisible() + await expect(alignmentCard).toHaveCSS('pointer-events', 'none') + await page.screenshot({ + path: 'tests/widgets/governance-widget/test-results/gwr-15-hoa-greyed-out.png', + fullPage: true, + }) +}) diff --git a/tests/widgets/governance-widget/test-results/gwo-06-welcome-unverified.png b/tests/widgets/governance-widget/test-results/gwo-06-welcome-unverified.png index 3ed43687..87d862be 100644 Binary files a/tests/widgets/governance-widget/test-results/gwo-06-welcome-unverified.png and b/tests/widgets/governance-widget/test-results/gwo-06-welcome-unverified.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-01-disconnected-dashboard.png b/tests/widgets/governance-widget/test-results/gwr-01-disconnected-dashboard.png new file mode 100644 index 00000000..69f5834a Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-01-disconnected-dashboard.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-02-loading-connected.png b/tests/widgets/governance-widget/test-results/gwr-02-loading-connected.png new file mode 100644 index 00000000..bda2ffd5 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-02-loading-connected.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-03-onboarding-hoa-unavailable.png b/tests/widgets/governance-widget/test-results/gwr-03-onboarding-hoa-unavailable.png new file mode 100644 index 00000000..a2971274 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-03-onboarding-hoa-unavailable.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-04-pending-alignment.png b/tests/widgets/governance-widget/test-results/gwr-04-pending-alignment.png new file mode 100644 index 00000000..fdc95b21 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-04-pending-alignment.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-05-active-citizenship.png b/tests/widgets/governance-widget/test-results/gwr-05-active-citizenship.png new file mode 100644 index 00000000..67becba8 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-05-active-citizenship.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-06-active-alignment-injected.png b/tests/widgets/governance-widget/test-results/gwr-06-active-alignment-injected.png new file mode 100644 index 00000000..f69223e8 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-06-active-alignment-injected.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-07-vote-detail-open.png b/tests/widgets/governance-widget/test-results/gwr-07-vote-detail-open.png new file mode 100644 index 00000000..717fe318 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-07-vote-detail-open.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-08-already-voted.png b/tests/widgets/governance-widget/test-results/gwr-08-already-voted.png new file mode 100644 index 00000000..810418ba Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-08-already-voted.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-09-vote-closed-executed.png b/tests/widgets/governance-widget/test-results/gwr-09-vote-closed-executed.png new file mode 100644 index 00000000..610c0009 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-09-vote-closed-executed.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-10-empty-recipients.png b/tests/widgets/governance-widget/test-results/gwr-10-empty-recipients.png new file mode 100644 index 00000000..d8391341 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-10-empty-recipients.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-11-pool-unavailable-mocked.png b/tests/widgets/governance-widget/test-results/gwr-11-pool-unavailable-mocked.png new file mode 100644 index 00000000..7dca50a9 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-11-pool-unavailable-mocked.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-12-unsupported-chain.png b/tests/widgets/governance-widget/test-results/gwr-12-unsupported-chain.png new file mode 100644 index 00000000..04ca4525 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-12-unsupported-chain.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-13-restake-required.png b/tests/widgets/governance-widget/test-results/gwr-13-restake-required.png new file mode 100644 index 00000000..cf81d95c Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-13-restake-required.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-14-friendly-contract-error.png b/tests/widgets/governance-widget/test-results/gwr-14-friendly-contract-error.png new file mode 100644 index 00000000..7bf9a75c Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-14-friendly-contract-error.png differ diff --git a/tests/widgets/governance-widget/test-results/gwr-15-hoa-greyed-out.png b/tests/widgets/governance-widget/test-results/gwr-15-hoa-greyed-out.png new file mode 100644 index 00000000..b423bc59 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gwr-15-hoa-greyed-out.png differ