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/fixtures/governanceRuntimeMock.ts b/examples/storybook/src/fixtures/governanceRuntimeMock.ts new file mode 100644 index 00000000..656703e8 --- /dev/null +++ b/examples/storybook/src/fixtures/governanceRuntimeMock.ts @@ -0,0 +1,172 @@ +import { + decodeFunctionData, + encodeFunctionResult, + parseAbi, + type Address, + type Hex, +} from 'viem' + +const HOUSES_READ_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 getActiveMembers(uint8 house) 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)', +]) + +const GOOD_ID_READ_ABI = parseAbi([ + 'function getWhitelistedRoot(address account) view returns (address)', +]) + +export const MOCK_HOUSES = '0x4444444444444444444444444444444444444444' as Address +export const MOCK_GOOD_ID = '0x5555555555555555555555555555555555555555' as Address +export const MOCK_CITIZEN = '0x6666666666666666666666666666666666666666' as Address +export const MOCK_ALIGNMENT = '0x7777777777777777777777777777777777777777' as Address +export const MOCK_POOL = '0x8888888888888888888888888888888888888888' as Address + +export interface MockGovernanceReadOptions { + memberStatus?: 0 | 1 | 2 | 3 | 4 + memberStatusByAccount?: Record + memberHouseByAccount?: Record +} + +export function encodeMockGovernanceRead( + to: Address, + data: Hex, + options: MockGovernanceReadOptions = {}, +): Hex { + if (to.toLowerCase() === MOCK_GOOD_ID.toLowerCase()) { + const decoded = decodeFunctionData({ abi: GOOD_ID_READ_ABI, data }) + if (decoded.functionName !== 'getWhitelistedRoot') { + throw new Error(`Unexpected GoodID read: ${decoded.functionName}`) + } + return encodeFunctionResult({ + abi: GOOD_ID_READ_ABI, + functionName: 'getWhitelistedRoot', + result: MOCK_CITIZEN, + }) + } + + if (to.toLowerCase() !== MOCK_HOUSES.toLowerCase()) { + throw new Error(`Unexpected contract address: ${to}`) + } + + const decoded = decodeFunctionData({ abi: HOUSES_READ_ABI, data }) + switch (decoded.functionName) { + case 'getMember': { + const memberAccount = String(decoded.args[0]).toLowerCase() + const memberStatus = + options.memberStatusByAccount?.[memberAccount] ?? + options.memberStatus ?? + 2 + const hasMembership = memberStatus !== 0 && memberStatus !== 4 + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getMember', + result: { + house: options.memberHouseByAccount?.[memberAccount] ?? 0, + status: memberStatus, + stakedAmount: hasMembership ? 1_000n * 10n ** 18n : 0n, + joinedAt: hasMembership ? 1_761_955_200n : 0n, + updatedAt: hasMembership ? 1_764_547_200n : 0n, + unstakedAt: memberStatus === 4 ? 1_784_044_800n : 0n, + memberIndex: 0n, + name: hasMembership ? 'Mocked Citizen' : '', + socialLinks: hasMembership ? 'https://example.com/citizen' : '', + projectWebpage: '', + missionStatement: '', + distributionStrategy: '', + }, + }) + } + case 'minimumStake': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'minimumStake', + result: 1_000n * 10n ** 18n, + }) + case 'getActiveMembers': { + const house = Number(decoded.args[0]) + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getActiveMembers', + result: house === 0 ? [MOCK_CITIZEN] : [MOCK_ALIGNMENT], + }) + } + case 'cycleStartTime': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'cycleStartTime', + result: 1_764_547_200n, + }) + case 'termDuration': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'termDuration', + result: 19_440_000n, + }) + case 'votingTermLength': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'votingTermLength', + result: 1_209_600n, + }) + case 'isVotingPeriod': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'isVotingPeriod', + result: true, + }) + case 'getCurrentVoteId': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getCurrentVoteId', + result: 1n, + }) + case 'getVoteConfig': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getVoteConfig', + result: { + startTime: 1_783_987_200n, + endTime: 1_785_196_800n, + executedAt: 0n, + executed: false, + }, + }) + case 'getVoteRecipients': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getVoteRecipients', + result: [MOCK_ALIGNMENT], + }) + case 'getHasVoted': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getHasVoted', + result: false, + }) + case 'getFinalizedUnits': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getFinalizedUnits', + result: 0n, + }) + case 'flowSplitterConfig': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'flowSplitterConfig', + result: [MOCK_HOUSES, 1n, MOCK_POOL], + }) + default: + throw new Error(`Unexpected houses read: ${decoded.functionName}`) + } +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx index eb236198..a73695ff 100644 --- a/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx +++ b/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx @@ -62,16 +62,18 @@ function GovernanceStoryFrame({ walletLabel: string children: ReactNode dataTestId: string - width?: any + width?: number }) { return ( - - - {walletLabel} - - - {children} + + + + {walletLabel} + + + {children} + ) } @@ -197,6 +199,7 @@ function CustodialInteractiveFlowStory() { storyProps={{ identityStatus: 'verified', initialStepId: 'welcome', + initialHouse: 'citizenship', walletAddress: '0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08', dataTestId: 'GovernanceOnboardingWidget-interactive-flow', transactionSteps: stepsState, 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..ff1d89fb --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx @@ -0,0 +1,491 @@ +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 +const alignmentRecipients = [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333', +] as const + +function createDashboard( + overrides: Partial = {}, +): GovernanceWidgetAdapterState['dashboard'] { + 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: alignmentRecipients[0], label: 'Local Food Chain', percentage: 42 }, + { id: alignmentRecipients[1], label: 'Web3 Literacy', percentage: 31 }, + { id: alignmentRecipients[2], label: 'Civic Onboarding', percentage: 27 }, + ], + recipients: [...alignmentRecipients], + allocationsBps: { + [alignmentRecipients[0]]: 4200, + [alignmentRecipients[1]]: 3100, + [alignmentRecipients[2]]: 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: GovernanceWidgetAdapterState['member'] = + status === 'active_citizenship' || status === 'active_alignment' || status === 'revoked' + ? { + house: status === 'active_alignment' ? 'alignment' : 'citizenship', + status: status === 'revoked' ? 'revoked' : 'active', + stakedAmount: 250000000000000000000n, + joinedAt: Date.UTC(2026, 0, 10), + updatedAt: Date.UTC(2026, 2, 1), + unstakedAt: 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', + 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, + transaction: { kind: null, status: 'idle', hash: null, error: null }, + unstakeAvailability: { + canUnstake: false, + unlockAt: Date.UTC(2026, 8, 1, 12), + disabledReason: 'Membership remains locked until the current governance term has passed.', + }, + lifecycleNotice: null, + error: null, + ...overrides, + } +} + +function createAdapterFactory(state: GovernanceWidgetAdapterState): GovernanceWidgetAdapterFactory { + return () => ({ + state, + actions: { + connect: async () => {}, + switchToCelo: async () => {}, + refresh: async () => {}, + retry: async () => {}, + selectHouse: () => {}, + register: async () => {}, + unstake: 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 OnboardingHouseSelection: Story = { + render: () => ( + + ), +} + +export const PendingAlignment: Story = { + render: () => , +} + +export const ActiveCitizenship: Story = { + render: () => , +} + +export const UpcomingVote: 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 ActiveMembershipUnstakeReady: Story = { + render: () => ( + + ), +} + +export const UnstakeWalletConfirmation: Story = { + render: () => ( + + ), +} + +export const UnstakeSubmitted: Story = { + render: () => ( + + ), +} + +export const UnstakeRejected: Story = { + render: () => ( + + ), +} + +export const UnstakeReverted: Story = { + render: () => ( + + ), +} + +export const UnstakedReturnsToOnboarding: Story = { + render: () => ( + + ), +} + +export const RevokedMembership: Story = { + render: () => , +} + +export const FriendlyContractError: Story = { + render: () => ( + + ), +} + +export const RealAdapterMockedRuntime: Story = { + render: () => { + const injectedProvider = getInjectedEip1193Provider() + const provider = isInjectedProviderUsable(injectedProvider) + ? injectedProvider + : createCustodialEip1193Provider() + + return ( + + ) + }, +} 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..e1ecc7b3 --- /dev/null +++ b/packages/governance-widget/src/GovernanceWidget.tsx @@ -0,0 +1,528 @@ +import { useMemo } from 'react' +import { Button, ButtonText, Card, Heading, Icon, 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 { + getGovernanceVotingDisabledReason, + type GovernanceWidgetAdapterActions, + type GovernanceWidgetAdapterFactoryInput, + type GovernanceWidgetAdapterResult, + type GovernanceWidgetAdapterState, + type GovernanceWidgetProps, +} from './widgetRuntimeContract' +import { isActiveStatus } from './adapter' +import { formatStakeAmount } from './sdks/contracts' + +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 formatMemberDateTime(timestamp: number | null): string { + if (!timestamp) return 'Not available' + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).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 + + {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 + + Your House of Alignment application is recorded on-chain and is waiting for + committee approval. No further transaction is required while it is pending. + + + Wallet: {state.address ?? 'Not connected'} + + + + ) +} + +function MembershipExitState({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + const transaction = state.transaction.kind === 'unstake' ? state.transaction : null + const isPending = + transaction?.status === 'wallet_confirmation' || + transaction?.status === 'submitted' || + transaction?.status === 'confirmed' + const canSubmit = state.unstakeAvailability.canUnstake && !isPending + + return ( + + + Membership stake + + Active governance stakes remain locked for one full term. Once the lock expires, + unstaking returns your G$ and removes your active membership. + + + Available from + + {formatMemberDateTime(state.unstakeAvailability.unlockAt)} + + + {!state.unstakeAvailability.canUnstake ? ( + + {state.unstakeAvailability.disabledReason} + + ) : null} + {transaction?.status === 'wallet_confirmation' ? ( + Confirm the unstake transaction in your wallet. + ) : null} + {transaction?.status === 'submitted' ? ( + + Transaction submitted. Waiting for a successful Celo receipt… + + ) : null} + {transaction?.status === 'rejected' || + transaction?.status === 'reverted' || + transaction?.status === 'failed' ? ( + + {transaction.error ?? 'The unstake transaction did not complete.'} + + ) : null} + + + + ) +} + +function RevokedState({ state }: { state: GovernanceWidgetAdapterState }) { + return ( + + + Membership revoked + + This governance membership was revoked and cannot be reactivated from the widget. + Contact the GoodDAO governance team if you believe this status is incorrect. + + + Wallet: {state.address ?? 'Not connected'} + + + + ) +} + +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 disabledReason = getGovernanceVotingDisabledReason(vote) + const voteTransactionPending = + state.transaction.kind === 'vote' && + ( + state.transaction.status === 'wallet_confirmation' || + state.transaction.status === 'submitted' || + state.transaction.status === 'confirmed' + ) + const canSubmit = + vote.canVote && + vote.allocationTotalBps === 10000 && + !vote.hasVoted && + vote.isVotingOpen && + !voteTransactionPending + const isReadOnly = vote.hasVoted || vote.executed || voteTransactionPending + + return ( + + + + {vote.title} + + + + Allocate basis points across the recipients captured when this vote opened. + Your allocation must total exactly 10,000 basis points. + + + {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 && !voteTransactionPending ? ( + {disabledReason ?? 'Voting is unavailable.'} + ) : null} + {state.transaction.kind === 'vote' && state.transaction.status === 'wallet_confirmation' ? ( + Confirm the vote in your wallet. + ) : null} + {state.transaction.kind === 'vote' && state.transaction.status === 'submitted' ? ( + Vote submitted. Waiting for confirmation… + ) : null} + {state.transaction.kind === 'vote' && state.transaction.status === 'confirmed' ? ( + Vote confirmed on Celo. + ) : null} + {state.transaction.kind === 'vote' && state.transaction.error ? ( + {state.transaction.error} + ) : null} + + + + ) +} + +function GovernanceWidgetView({ + adapter, + testId, +}: { + adapter: GovernanceWidgetAdapterResult + testId?: string +}) { + 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.error && state.status !== 'friendly_error' && state.transaction.status === 'idle' ? ( + + + Governance action unavailable + {state.error} + + + ) : null} + {state.status === 'vote_detail' ? : null} + {state.status === 'onboarding_required' ? ( + + {state.lifecycleNotice ? ( + + {state.lifecycleNotice} + + ) : null} + { + void actions.startIdentityVerification() + }} + onProfileSubmit={(profileDraft) => { + void actions.register(profileDraft) + }} + /> + + ) : null} + {state.status === 'pending_alignment' ? : null} + {state.status === 'revoked' ? : null} + {shouldShowDashboard ? : null} + + {isActiveStatus(state.status) ? : null} + + ) +} + +function DefaultGovernanceWidgetContent({ + adapterInput, + testId, +}: { + adapterInput: GovernanceWidgetAdapterFactoryInput + testId?: string +}) { + const adapter = useGovernanceAdapter(adapterInput) + return +} + +function InjectedGovernanceWidgetContent({ + adapterFactory, + adapterInput, + testId, +}: { + adapterFactory: NonNullable + adapterInput: GovernanceWidgetAdapterFactoryInput + testId?: string +}) { + const adapter = adapterFactory(adapterInput) + return +} + +export function GovernanceWidget({ + provider, + themeOverrides, + config, + defaultTheme = 'light', + adapterFactory, + testId, + environment, + celoRpcUrl, + addresses, +}: GovernanceWidgetProps) { + const adapterInput = useMemo( + () => ({ environment, celoRpcUrl, addresses }), + [addresses, celoRpcUrl, environment], + ) + + return ( + + {adapterFactory ? ( + + ) : ( + + )} + + ) +} diff --git a/packages/governance-widget/src/adapter.ts b/packages/governance-widget/src/adapter.ts new file mode 100644 index 00000000..0e89e241 --- /dev/null +++ b/packages/governance-widget/src/adapter.ts @@ -0,0 +1,291 @@ +import { useCallback, useMemo } from 'react' +import { useWallet } from '@goodwidget/core' +import { getAddress } from 'viem' +import type { GovernanceDashboardState } from './widgetRuntimeContract' +import type { + GovernanceWidgetAdapterActions, + GovernanceWidgetAdapterFactoryInput, + GovernanceWidgetAdapterResult, + GovernanceWidgetAdapterState, + GovernanceWidgetStatus, + GovernanceTransactionState, + GovernanceVotingState, +} from './widgetRuntimeContract' +import { + CELO_CHAIN_ID, + createGovernancePublicClient, + requestCeloMainnetSwitch, + resolveGovernanceAddresses, +} from './sdks/contracts' +import type { GovernanceStakeRequirements } from './sdks/contractReads' +import { + createTransactionSteps, + friendlyGovernanceError, + isActiveStatus, + useGovernanceMembership, +} from './hooks/useGovernanceMembership' +import { + createEmptyVotingState, + useGovernanceVoting, +} from './hooks/useGovernanceVoting' +import { + createFundingLoadingState, + useGovernanceFunding, +} from './hooks/useGovernanceFunding' + +const IMPACT_METRICS: GovernanceDashboardState['impact'] = { + title: 'Distributed', + metrics: [ + { label: 'UBI Pool', amount: { value: '—', token: 'G$' } }, + { label: 'Impact Pool', amount: { value: '—', token: 'G$' } }, + ], + description: + 'Empowering people worldwide through transparent, decentralized funding for public goods.', + ctaLabel: 'View Impact Report Q3', +} + +const EMPTY_STAKES: GovernanceStakeRequirements = { + citizenship: 0n, + alignment: 0n, +} + +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 ?? createEmptyVotingState(), + fundingDistribution: params.funding ?? createFundingLoadingState(), + } +} + +function createInitialState( + status: GovernanceWidgetStatus = 'disconnected', +): GovernanceWidgetAdapterState { + return { + status, + address: null, + chainId: null, + identityStatus: 'unverified', + identityVerificationUrl: null, + member: null, + dashboard: createDashboardState(), + selectedHouse: 'citizenship', + onboardingStepId: undefined, + profileDraft: {}, + stakeAmountLabel: '0 G$', + minimumStakeAmounts: EMPTY_STAKES, + transactionSteps: createTransactionSteps('idle'), + registrationHash: null, + transaction: { kind: null, status: 'idle', hash: null, error: null }, + unstakeAvailability: { + canUnstake: false, + unlockAt: null, + disabledReason: 'Only active members can unstake.', + }, + lifecycleNotice: null, + error: null, + } +} + +function isPendingTransaction(transaction: GovernanceTransactionState): boolean { + return transaction.status === 'wallet_confirmation' || transaction.status === 'submitted' +} + +export function selectGovernanceTransaction( + membershipTransaction: GovernanceTransactionState, + votingTransaction: GovernanceTransactionState, + isVoteDetailOpen = false, +): GovernanceTransactionState { + if (isPendingTransaction(votingTransaction)) return votingTransaction + if (isPendingTransaction(membershipTransaction)) return membershipTransaction + if (isVoteDetailOpen && votingTransaction.status !== 'idle') return votingTransaction + return membershipTransaction.status !== 'idle' ? membershipTransaction : votingTransaction +} + +export function useGovernanceAdapter({ + environment = 'production', + celoRpcUrl, + addresses: addressOverrides, +}: GovernanceWidgetAdapterFactoryInput = {}): GovernanceWidgetAdapterResult { + const { address, chainId, provider, connect } = useWallet() + const publicClient = useMemo( + () => createGovernancePublicClient(celoRpcUrl), + [celoRpcUrl], + ) + const addresses = useMemo( + () => resolveGovernanceAddresses(addressOverrides), + [addressOverrides], + ) + const account = useMemo( + () => address ? getAddress(address.toLowerCase()) : null, + [address], + ) + const resolvedChainId = chainId ?? null + const runtimeEnabled = Boolean( + account && resolvedChainId === CELO_CHAIN_ID && addresses.houses, + ) + + const membership = useGovernanceMembership({ + account, + chainId: resolvedChainId, + provider, + publicClient, + addresses, + environment, + }) + const voting = useGovernanceVoting({ + enabled: runtimeEnabled && Boolean(membership.schedule), + account, + provider, + publicClient, + addresses, + member: membership.member, + identityRoot: membership.identityRoot, + activeAlignment: membership.activeAlignment, + schedule: membership.schedule, + minimumStakes: membership.minimumStakes, + }) + const funding = useGovernanceFunding({ + enabled: runtimeEnabled, + publicClient, + housesAddress: addresses.houses, + tokenAddress: addresses.gToken, + }) + + const refresh = useCallback(async () => { + await Promise.all([ + membership.refresh(), + voting.refresh(), + funding.refresh(), + ]) + }, [funding, membership, voting]) + + const switchToCelo = useCallback(async () => { + await requestCeloMainnetSwitch(provider) + }, [provider]) + + let status: GovernanceWidgetStatus + let runtimeError: string | null = null + if (!account) { + status = 'disconnected' + } else if (resolvedChainId !== CELO_CHAIN_ID) { + status = 'unsupported_chain' + } else if (!addresses.houses) { + status = 'friendly_error' + runtimeError = 'Governance contract address is not configured yet.' + } else if (membership.isLoading && !membership.membership) { + status = 'loading' + } else if (membership.loadError) { + status = 'friendly_error' + runtimeError = membership.loadError + } else { + status = isActiveStatus(membership.status) && voting.isDetailOpen + ? 'vote_detail' + : membership.status + } + + const transaction = selectGovernanceTransaction( + membership.transaction, + voting.transaction, + voting.isDetailOpen, + ) + + const state = useMemo(() => ({ + status, + address: account, + chainId: resolvedChainId, + identityStatus: membership.identityStatus, + identityVerificationUrl: membership.identityVerificationUrl, + member: membership.member, + dashboard: createDashboardState({ + activeMemberCount: + membership.activeCitizens.length + membership.activeAlignment.length, + voting: voting.voting, + funding: funding.funding, + }), + selectedHouse: membership.selectedHouse, + onboardingStepId: membership.onboardingStepId, + profileDraft: membership.profileDraft, + stakeAmountLabel: membership.stakeAmountLabel, + minimumStakeAmounts: membership.minimumStakes, + transactionSteps: membership.transactionSteps, + registrationHash: membership.transaction.kind === 'registration' + ? membership.transaction.hash + : null, + transaction, + unstakeAvailability: membership.unstakeAvailability, + lifecycleNotice: membership.lifecycleNotice, + error: runtimeError ?? transaction.error ?? membership.error ?? voting.error, + }), [ + account, + funding.funding, + membership.activeAlignment.length, + membership.activeCitizens.length, + membership.identityStatus, + membership.identityVerificationUrl, + membership.lifecycleNotice, + membership.error, + membership.member, + membership.minimumStakes, + membership.onboardingStepId, + membership.profileDraft, + membership.selectedHouse, + membership.stakeAmountLabel, + membership.transaction, + membership.transactionSteps, + membership.unstakeAvailability, + resolvedChainId, + runtimeError, + status, + transaction, + voting.error, + voting.voting, + ]) + + const actions = useMemo(() => ({ + connect, + switchToCelo, + refresh, + retry: refresh, + selectHouse: membership.selectHouse, + register: membership.register, + unstake: membership.unstake, + openVote: voting.openVote, + closeVote: voting.closeVote, + setVoteAllocation: voting.setVoteAllocation, + submitVote: voting.submitVote, + startIdentityVerification: membership.startIdentityVerification, + }), [ + connect, + membership.register, + membership.selectHouse, + membership.startIdentityVerification, + membership.unstake, + refresh, + switchToCelo, + voting.closeVote, + voting.openVote, + voting.setVoteAllocation, + voting.submitVote, + ]) + + return { state, actions } +} + +export { + createDashboardState, + createInitialState, + friendlyGovernanceError, + isActiveStatus, +} diff --git a/packages/governance-widget/src/hooks/useGovernanceFunding.ts b/packages/governance-widget/src/hooks/useGovernanceFunding.ts new file mode 100644 index 00000000..758f679c --- /dev/null +++ b/packages/governance-widget/src/hooks/useGovernanceFunding.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react' +import type { Address, PublicClient } from 'viem' +import type { GovernanceDashboardState } from '../widgetRuntimeContract' +import { ZERO_ADDRESS } from '../sdks/contracts' +import { readFlowSplitterConfig } from '../sdks/contractReads' +import { fetchFundingReceivedSoFar } from '../sdks/funding' + +type FundingState = GovernanceDashboardState['fundingDistribution'] + +export function createFundingLoadingState(): FundingState { + return { + title: 'Funding received so far', + centerLabel: 'Loading funding', + totalAmount: { + value: '0', + token: 'G$', + isStreaming: false, + streamLabel: 'Loading Superfluid streams', + }, + projects: [], + isStreaming: false, + stateLabel: 'Refreshing cumulative funding…', + emptyStateLabel: 'Loading funding streams…', + } +} + +export function createFundingUnavailableState(): FundingState { + return { + title: 'Funding received so far', + centerLabel: 'Funding unavailable', + totalAmount: { + value: '0', + token: 'G$', + isStreaming: false, + streamLabel: 'Superfluid stream data unavailable', + }, + projects: [], + isStreaming: false, + stateLabel: 'Funding data is temporarily unavailable.', + emptyStateLabel: 'Membership and voting remain available while funding data refreshes.', + } +} + +export function useGovernanceFunding(params: { + enabled: boolean + publicClient: PublicClient + housesAddress?: Address + tokenAddress: Address +}) { + const { enabled, publicClient, housesAddress, tokenAddress } = params + const [funding, setFunding] = useState(() => createFundingLoadingState()) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + if (!enabled || !housesAddress) return + try { + const flowConfig = await readFlowSplitterConfig({ publicClient, housesAddress }) + if (flowConfig.poolAddress.toLowerCase() === ZERO_ADDRESS) { + setFunding({ + title: 'Funding received so far', + centerLabel: 'No receiver configured', + totalAmount: { + value: '0', + token: 'G$', + isStreaming: false, + streamLabel: 'No FlowSplitter pool receiver yet', + }, + projects: [], + isStreaming: false, + emptyStateLabel: 'No funding receiver has been configured yet.', + }) + setError(null) + return + } + + const total = await fetchFundingReceivedSoFar({ + receiver: flowConfig.poolAddress, + token: tokenAddress, + }) + const hasActiveStreams = total.activeStreamCount > 0 + const hasHistoricalStreams = total.streamCount > 0 + setFunding({ + title: 'Funding received so far', + centerLabel: hasActiveStreams + ? 'Active Superfluid total' + : hasHistoricalStreams + ? 'Cumulative received' + : 'No streams yet', + totalAmount: { + value: total.formattedAmount, + token: 'G$', + isStreaming: hasActiveStreams, + streamLabel: hasActiveStreams + ? `${total.activeStreamCount} active stream${total.activeStreamCount === 1 ? '' : 's'}` + : hasHistoricalStreams + ? `${total.streamCount} stopped historical stream${total.streamCount === 1 ? '' : 's'}` + : 'No inbound streams found', + }, + projects: [], + isStreaming: hasActiveStreams, + stateLabel: hasHistoricalStreams && !hasActiveStreams + ? 'Historical streams are stopped; their received totals remain included.' + : undefined, + emptyStateLabel: hasHistoricalStreams + ? 'Distribution breakdown is unavailable until outgoing stream data exists.' + : 'No funding streams have been received yet.', + }) + setError(null) + } catch (err: unknown) { + setFunding(createFundingUnavailableState()) + setError(err instanceof Error ? err.message : 'Funding refresh failed') + } + }, [enabled, housesAddress, publicClient, tokenAddress]) + + useEffect(() => { + if (!enabled) { + setFunding(createFundingLoadingState()) + setError(null) + return + } + void refresh() + const interval = globalThis.setInterval(() => void refresh(), 30_000) + return () => globalThis.clearInterval(interval) + }, [enabled, refresh]) + + return { funding, error, refresh } +} diff --git a/packages/governance-widget/src/hooks/useGovernanceMembership.ts b/packages/governance-widget/src/hooks/useGovernanceMembership.ts new file mode 100644 index 00000000..a9a73dc2 --- /dev/null +++ b/packages/governance-widget/src/hooks/useGovernanceMembership.ts @@ -0,0 +1,542 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Linking } from 'react-native' +import type { EIP1193Provider } from '@goodwidget/core' +import type { StepperStepItem } from '@goodwidget/ui' +import type { Address, Hex, PublicClient } from 'viem' +import type { GovernanceHouse, GovernanceOnboardingStepId, GovernanceProfileDraft } from '../types' +import type { + GovernanceTransactionState, + GovernanceTransactionStatus, + GovernanceUnstakeAvailability, + GovernanceWidgetStatus, +} from '../widgetRuntimeContract' +import { + CELO_CHAIN_ID, + createGovernanceWalletClient, + formatStakeAmount, + safeMillisecondsFromSeconds, + type GovernanceContractAddresses, + type GovernanceMemberRecord, +} from '../sdks/contracts' +import { + readGovernanceMembership, + readGovernanceSchedule, + type GovernanceMembershipReads, + type GovernanceSchedule, + type GovernanceStakeRequirements, +} from '../sdks/contractReads' +import { + registerWithTransferAndCall, + unstakeGovernanceMembership, + type GovernanceTransactionStage, +} from '../sdks/transactions' +import { + createGovernanceIdentitySdk, + createGovernanceIdentityVerificationLink, + type GovernanceIdentityEnvironment, +} from '../sdks/identity' +import { useGovernanceTransactionGuard } from './useGovernanceTransactionGuard' + +const EMPTY_STAKES: GovernanceStakeRequirements = { + citizenship: 0n, + alignment: 0n, +} + +const EMPTY_ADDRESSES: Address[] = [] + +const IDLE_TRANSACTION: GovernanceTransactionState = { + kind: null, + status: 'idle', + hash: null, + error: null, +} + +export function createTransactionSteps( + stage: GovernanceTransactionStatus, + failedMessage?: string, +): StepperStepItem[] { + const rejected = stage === 'rejected' + const reverted = stage === 'reverted' || stage === 'failed' + + return [ + { + id: 'prepare', + title: 'Prepare wallet balance', + description: 'Keep the required G$ amount available before the membership transaction starts.', + status: stage === 'idle' ? 'active' : 'completed', + }, + { + id: 'approve', + title: 'Confirm in wallet', + description: rejected ? failedMessage : 'Approve the membership transaction from your wallet.', + status: rejected ? 'failed' : stage === 'wallet_confirmation' ? 'active' : stage === 'idle' ? 'pending' : 'completed', + }, + { + id: 'stake', + title: 'Transaction submitted', + description: reverted ? failedMessage : 'Wait for the Celo transaction receipt before continuing.', + status: reverted ? 'failed' : stage === 'submitted' ? 'active' : stage === 'confirmed' ? 'completed' : 'pending', + }, + { + id: 'finalize', + title: 'Confirmed on-chain', + status: stage === 'confirmed' ? 'completed' : 'pending', + }, + ] +} + +export 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('Term not passed')) { + return 'Your membership is still locked for the current governance term.' + } + 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.' +} + +export function transactionStatusFromError(err: unknown): Extract { + if (err instanceof Error && (err.message.includes('User rejected') || err.message.includes('4001'))) { + return 'rejected' + } + if (err instanceof Error && (err.message.includes('revert') || err.message.includes('reverted'))) { + return 'reverted' + } + return 'failed' +} + +export function statusFromMember(member: GovernanceMemberRecord | null): GovernanceWidgetStatus { + if (!member || member.status === 'none' || member.status === 'unstaked') return 'onboarding_required' + // GoodDaoHouses activates Citizen registrations immediately; Pending is Alignment-only. + 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') return 'revoked' + return 'onboarding_required' +} + +export function isActiveStatus(status: GovernanceWidgetStatus): boolean { + return status === 'active_alignment' || status === 'active_citizenship' +} + +export function getUnstakeAvailability( + member: GovernanceMemberRecord | null, + termDurationSeconds: bigint, + currentBlockTime: number | null, +): GovernanceUnstakeAvailability { + if (!member || member.status !== 'active') { + return { canUnstake: false, unlockAt: null, disabledReason: 'Only active members can unstake.' } + } + if (!member.updatedAt || termDurationSeconds <= 0n) { + return { + canUnstake: false, + unlockAt: null, + disabledReason: 'The membership lock period is unavailable. Refresh before trying again.', + } + } + if (currentBlockTime === null) { + return { + canUnstake: false, + unlockAt: null, + disabledReason: 'The current Celo block time is unavailable. Refresh before trying again.', + } + } + + const termDurationMs = safeMillisecondsFromSeconds(termDurationSeconds) + if (termDurationMs === null) { + return { + canUnstake: false, + unlockAt: null, + disabledReason: 'The membership lock period is unavailable. Refresh before trying again.', + } + } + + const unlockAt = member.updatedAt + termDurationMs + if (!Number.isSafeInteger(unlockAt)) { + return { + canUnstake: false, + unlockAt: null, + disabledReason: 'The membership lock period is unavailable. Refresh before trying again.', + } + } + return currentBlockTime >= unlockAt + ? { canUnstake: true, unlockAt } + : { + canUnstake: false, + unlockAt, + disabledReason: 'Membership remains locked until the current governance term has passed.', + } +} + +interface MembershipHookState { + loadedAccount: Address | null + membership: GovernanceMembershipReads | null + schedule: GovernanceSchedule | null + selectedHouse: GovernanceHouse + onboardingStepId?: GovernanceOnboardingStepId + profileDraft: GovernanceProfileDraft + transactionSteps: StepperStepItem[] + transaction: GovernanceTransactionState + identityVerificationUrl: string | null + lifecycleNotice: string | null + isLoading: boolean + loadError: string | null + error: string | null +} + +export function resolveRegistrationStake( + membership: GovernanceMembershipReads | null, + selectedHouse: GovernanceHouse, +): { stakeAmountWei: bigint; error: null } | { stakeAmountWei: null; error: string } { + if (!membership) { + return { + stakeAmountWei: null, + error: 'Membership data is still loading. Please try again in a moment.', + } + } + return { stakeAmountWei: membership.minimumStakes[selectedHouse], error: null } +} + +function createInitialMembershipState(): MembershipHookState { + return { + loadedAccount: null, + membership: null, + schedule: null, + selectedHouse: 'citizenship', + onboardingStepId: undefined, + profileDraft: {}, + transactionSteps: createTransactionSteps('idle'), + transaction: IDLE_TRANSACTION, + identityVerificationUrl: null, + lifecycleNotice: null, + isLoading: false, + loadError: null, + error: null, + } +} + +function transactionFromStage( + kind: GovernanceTransactionState['kind'], + stage: GovernanceTransactionStage, + hash?: Hex, +): GovernanceTransactionState { + return { kind, status: stage, hash: hash ?? null, error: null } +} + +export function useGovernanceMembership(params: { + account: Address | null + chainId: number | null + provider: EIP1193Provider | null + publicClient: PublicClient + addresses: GovernanceContractAddresses + environment: GovernanceIdentityEnvironment +}) { + const { account, chainId, provider, publicClient, addresses, environment } = params + const [state, setState] = useState(() => createInitialMembershipState()) + const refreshRequestId = useRef(0) + const transactionGuard = useGovernanceTransactionGuard([ + account?.toLowerCase() ?? 'no-account', + chainId ?? 'no-chain', + addresses.houses?.toLowerCase() ?? 'no-contract', + ].join(':')) + const enabled = Boolean(account && chainId === CELO_CHAIN_ID && addresses.houses) + const hasCurrentAccountState = Boolean( + account && state.loadedAccount?.toLowerCase() === account.toLowerCase(), + ) + const membership = hasCurrentAccountState ? state.membership : null + const schedule = hasCurrentAccountState ? state.schedule : null + + const refresh = useCallback(async () => { + if (!account || !addresses.houses || chainId !== CELO_CHAIN_ID) return + const requestId = ++refreshRequestId.current + setState((previous) => ({ ...previous, isLoading: true, loadError: null })) + + try { + const [membership, schedule] = await Promise.all([ + readGovernanceMembership({ + publicClient, + housesAddress: addresses.houses, + goodIdAddress: addresses.goodId, + account, + }), + readGovernanceSchedule({ publicClient, housesAddress: addresses.houses }), + ]) + setState((previous) => requestId === refreshRequestId.current + ? { + ...previous, + loadedAccount: account, + membership, + schedule, + selectedHouse: + membership.member.status === 'none' || membership.member.status === 'unstaked' + ? previous.selectedHouse + : membership.member.house, + isLoading: false, + loadError: null, + } + : previous) + } catch (err: unknown) { + setState((previous) => requestId === refreshRequestId.current + ? { + ...previous, + loadedAccount: account, + isLoading: false, + loadError: friendlyGovernanceError(err), + } + : previous) + } + }, [account, addresses.goodId, addresses.houses, chainId, publicClient]) + + useEffect(() => { + refreshRequestId.current += 1 + if (!enabled) { + setState(createInitialMembershipState()) + return + } + setState(createInitialMembershipState()) + void refresh() + }, [enabled, refresh]) + + useEffect(() => { + if (!enabled) return undefined + const interval = globalThis.setInterval(() => void refresh(), 30_000) + return () => globalThis.clearInterval(interval) + }, [enabled, refresh]) + + const selectHouse = useCallback((house: GovernanceHouse) => { + setState((previous) => ({ ...previous, selectedHouse: house })) + }, []) + + const register = useCallback(async (profileDraft: GovernanceProfileDraft) => { + if (!account || !addresses.houses) return + const selectedHouse = state.selectedHouse + const registrationStake = resolveRegistrationStake(membership, selectedHouse) + if (registrationStake.stakeAmountWei === null) { + setState((previous) => ({ + ...previous, + error: registrationStake.error, + })) + return + } + const transactionToken = transactionGuard.begin() + if (!transactionToken) return + + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) { + const error = 'The connected wallet provider is unavailable.' + if (transactionGuard.isCurrent(transactionToken)) { + setState((previous) => ({ + ...previous, + onboardingStepId: 'stake', + profileDraft, + transactionSteps: createTransactionSteps('failed', error), + transaction: { kind: 'registration', status: 'failed', hash: null, error }, + error, + })) + } + transactionGuard.finish(transactionToken) + return + } + + const stakeAmountWei = registrationStake.stakeAmountWei + setState((previous) => ({ + ...previous, + onboardingStepId: 'stake', + profileDraft, + transactionSteps: createTransactionSteps('wallet_confirmation'), + transaction: transactionFromStage('registration', 'wallet_confirmation'), + lifecycleNotice: null, + error: null, + })) + + try { + const hash = await registerWithTransferAndCall({ + publicClient, + walletClient, + account, + addresses: { ...addresses, houses: addresses.houses }, + selectedHouse, + profileDraft, + stakeAmountWei, + onStage: (stage, stageHash) => { + if (!transactionGuard.isCurrent(transactionToken)) return + setState((previous) => ({ + ...previous, + transaction: transactionFromStage('registration', stage, stageHash), + transactionSteps: createTransactionSteps(stage), + })) + }, + }) + if (!transactionGuard.isCurrent(transactionToken)) return + setState((previous) => ({ + ...previous, + transaction: { kind: 'registration', status: 'confirmed', hash, error: null }, + transactionSteps: createTransactionSteps('confirmed'), + onboardingStepId: 'success', + })) + await refresh() + } catch (err: unknown) { + if (!transactionGuard.isCurrent(transactionToken)) return + const status = transactionStatusFromError(err) + const error = friendlyGovernanceError(err) + setState((previous) => ({ + ...previous, + transaction: { kind: 'registration', status, hash: previous.transaction.hash, error }, + transactionSteps: createTransactionSteps(status, error), + error, + })) + } finally { + transactionGuard.finish(transactionToken) + } + }, [account, addresses, membership, provider, publicClient, refresh, state.selectedHouse, transactionGuard]) + + const unstake = useCallback(async () => { + if (!account || !addresses.houses) return + const availability = getUnstakeAvailability( + membership?.member ?? null, + schedule?.termDurationSeconds ?? 0n, + schedule?.currentBlockTime ?? null, + ) + if (!availability.canUnstake) return + const transactionToken = transactionGuard.begin() + if (!transactionToken) return + + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) { + const error = 'The connected wallet provider is unavailable.' + if (transactionGuard.isCurrent(transactionToken)) { + setState((previous) => ({ + ...previous, + transaction: { kind: 'unstake', status: 'failed', hash: null, error }, + error, + })) + } + transactionGuard.finish(transactionToken) + return + } + + setState((previous) => ({ + ...previous, + transaction: transactionFromStage('unstake', 'wallet_confirmation'), + lifecycleNotice: null, + error: null, + })) + + try { + const hash = await unstakeGovernanceMembership({ + publicClient, + walletClient, + account, + housesAddress: addresses.houses, + onStage: (stage, stageHash) => { + if (!transactionGuard.isCurrent(transactionToken)) return + setState((previous) => ({ + ...previous, + transaction: transactionFromStage('unstake', stage, stageHash), + })) + }, + }) + if (!transactionGuard.isCurrent(transactionToken)) return + setState((previous) => ({ + ...previous, + transaction: { kind: 'unstake', status: 'confirmed', hash, error: null }, + lifecycleNotice: 'Membership unstaked successfully. You can now join a governance house again.', + })) + await refresh() + } catch (err: unknown) { + if (!transactionGuard.isCurrent(transactionToken)) return + const status = transactionStatusFromError(err) + const error = friendlyGovernanceError(err) + setState((previous) => ({ + ...previous, + transaction: { kind: 'unstake', status, hash: previous.transaction.hash, error }, + error, + })) + } finally { + transactionGuard.finish(transactionToken) + } + }, [ + account, + addresses.houses, + membership?.member, + provider, + publicClient, + refresh, + schedule?.currentBlockTime, + schedule?.termDurationSeconds, + transactionGuard, + ]) + + const startIdentityVerification = useCallback(async () => { + if (!account) return + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) { + setState((previous) => ({ ...previous, error: 'The connected wallet provider is unavailable.' })) + return + } + + try { + setState((previous) => ({ ...previous, error: null })) + const identitySdk = createGovernanceIdentitySdk({ publicClient, walletClient, environment }) + const returnUrl = (await Linking.getInitialURL()) ?? undefined + const identityVerificationUrl = await createGovernanceIdentityVerificationLink({ + identitySdk, + returnUrl, + chainId: chainId ?? undefined, + }) + setState((previous) => ({ ...previous, identityVerificationUrl })) + await Linking.openURL(identityVerificationUrl) + } catch (err: unknown) { + setState((previous) => ({ ...previous, error: friendlyGovernanceError(err) })) + } + }, [account, chainId, environment, provider, publicClient]) + + const minimumStakes = membership?.minimumStakes ?? EMPTY_STAKES + const member = membership?.member ?? null + const status = statusFromMember(member) + const unstakeAvailability = useMemo( + () => getUnstakeAvailability( + member, + schedule?.termDurationSeconds ?? 0n, + schedule?.currentBlockTime ?? null, + ), + [member, schedule?.currentBlockTime, schedule?.termDurationSeconds], + ) + + return { + ...state, + membership, + schedule, + isLoading: enabled && !hasCurrentAccountState ? true : state.isLoading, + status, + member, + minimumStakes, + stakeAmountLabel: formatStakeAmount(minimumStakes[state.selectedHouse]), + identityRoot: membership?.identityRoot ?? null, + identityStatus: membership?.identityRoot && membership.identityRoot !== '0x0000000000000000000000000000000000000000' + ? 'verified' as const + : 'unverified' as const, + activeCitizens: membership?.activeCitizens ?? EMPTY_ADDRESSES, + activeAlignment: membership?.activeAlignment ?? EMPTY_ADDRESSES, + unstakeAvailability, + refresh, + selectHouse, + register, + unstake, + startIdentityVerification, + } +} diff --git a/packages/governance-widget/src/hooks/useGovernanceTransactionGuard.ts b/packages/governance-widget/src/hooks/useGovernanceTransactionGuard.ts new file mode 100644 index 00000000..3d8e8156 --- /dev/null +++ b/packages/governance-widget/src/hooks/useGovernanceTransactionGuard.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' + +export interface GovernanceTransactionToken { + id: number + scope: string +} + +/** + * Keeps one wallet transaction active per account/chain/contract scope. + * A scope change invalidates callbacks from the previous wallet before they + * can publish receipt state into the newly connected account. + */ +export function useGovernanceTransactionGuard(scope: string) { + const scopeRef = useRef(scope) + const nextIdRef = useRef(0) + const activeIdRef = useRef(null) + scopeRef.current = scope + + useEffect(() => { + nextIdRef.current += 1 + activeIdRef.current = null + }, [scope]) + + const begin = useCallback((): GovernanceTransactionToken | null => { + if (activeIdRef.current !== null) return null + + const id = ++nextIdRef.current + activeIdRef.current = id + return { id, scope: scopeRef.current } + }, []) + + const isCurrent = useCallback((token: GovernanceTransactionToken): boolean => ( + token.id === activeIdRef.current && token.scope === scopeRef.current + ), []) + + const finish = useCallback((token: GovernanceTransactionToken): void => { + if (isCurrent(token)) activeIdRef.current = null + }, [isCurrent]) + + return useMemo( + () => ({ begin, isCurrent, finish }), + [begin, finish, isCurrent], + ) +} diff --git a/packages/governance-widget/src/hooks/useGovernanceVoting.ts b/packages/governance-widget/src/hooks/useGovernanceVoting.ts new file mode 100644 index 00000000..fdebfc63 --- /dev/null +++ b/packages/governance-widget/src/hooks/useGovernanceVoting.ts @@ -0,0 +1,382 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { EIP1193Provider } from '@goodwidget/core' +import { getAddress, isAddress, type Address, type PublicClient } from 'viem' +import type { RankedVotingOption } from '../types' +import { + getGovernanceVotingDisabledReason, + type GovernanceTransactionState, + type GovernanceVotingState, +} from '../widgetRuntimeContract' +import { + ZERO_ADDRESS, + createGovernanceWalletClient, + safeMillisecondsFromSeconds, + type GovernanceContractAddresses, + type GovernanceMemberRecord, +} from '../sdks/contracts' +import { + readGovernanceVote, + type GovernanceSchedule, + type GovernanceStakeRequirements, +} from '../sdks/contractReads' +import { castGovernanceVote } from '../sdks/transactions' +import { friendlyGovernanceError, transactionStatusFromError } from './useGovernanceMembership' +import { useGovernanceTransactionGuard } from './useGovernanceTransactionGuard' + +const IDLE_VOTE_TRANSACTION: GovernanceTransactionState = { + kind: null, + status: 'idle', + hash: null, + error: null, +} + +function shortAddress(address: Address): string { + return `${address.slice(0, 6)}…${address.slice(-4)}` +} + +function nextVotingWindowLabel(schedule: GovernanceSchedule): string { + if (!schedule.cycleStartTime || schedule.termDurationSeconds === 0n || schedule.currentBlockTime === null) { + return 'Contract schedule unavailable' + } + const termMs = safeMillisecondsFromSeconds(schedule.termDurationSeconds) + if (termMs === null) return 'Contract schedule unavailable' + const nowMs = schedule.currentBlockTime + const nextStart = nowMs < schedule.cycleStartTime + ? schedule.cycleStartTime + : schedule.cycleStartTime + (Math.floor((nowMs - schedule.cycleStartTime) / termMs) + 1) * termMs + if (!Number.isSafeInteger(nextStart)) return 'Contract schedule unavailable' + return `Next window starts ${new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(nextStart))}` +} + +export function resolveGovernanceVoterKey( + member: GovernanceMemberRecord | null, + identityRoot: Address | null, + account: Address, +): Address { + return member?.house === 'citizenship' && identityRoot && identityRoot.toLowerCase() !== ZERO_ADDRESS + ? identityRoot + : account +} + +export function createVotingState(params: { + member: GovernanceMemberRecord | null + identityRoot: Address | null + voteId: bigint + isVotingOpen: boolean + voteStartTime: number | null + voteConfig: { startTime: number | null; endTime: number | null; executedAt: number | null; executed: boolean } + recipients: Address[] + hasVoted: boolean + finalizedUnits: Record + schedule: GovernanceSchedule + minimumStake: bigint +}): 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 hasRequiredStake = Boolean( + params.member && params.member.stakedAmount >= params.minimumStake, + ) + const hasCitizenIdentity = params.member?.house !== 'citizenship' || Boolean( + params.identityRoot && params.identityRoot.toLowerCase() !== ZERO_ADDRESS, + ) + const joinedBeforeVote = Boolean( + params.member?.joinedAt && + params.voteStartTime && + params.member.joinedAt <= params.voteStartTime, + ) + const canVote = Boolean( + isActiveMember && + hasRequiredStake && + hasCitizenIdentity && + joinedBeforeVote && + params.isVotingOpen && + !params.hasVoted && + recipients.length > 0, + ) + + let title = 'Alignment vote' + let summaryLabel = 'Allocate 10,000 bps to eligible 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 (recipients.length === 0) { + disabledReason = 'No House of Alignment members were eligible when this vote opened.' + } else if (params.hasVoted) { + disabledReason = 'You already voted in this allocation cycle.' + } else if (!isActiveMember) { + disabledReason = 'Only active members can vote.' + } else if (!hasRequiredStake) { + disabledReason = 'Your membership stake is below the current minimum required for this house.' + } else if (!joinedBeforeVote) { + disabledReason = 'Members who joined after this vote opened cannot participate in this cycle.' + } 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, + } +} + +export function validateGovernanceBallot( + recipients: string[], + allocationsBps: Record, +): { recipients: Address[]; allocations: bigint[] } { + if (recipients.length === 0) throw new Error('No recipients') + if (!recipients.every((recipient) => isAddress(recipient))) throw new Error('Invalid recipient') + + const normalized = recipients.map((recipient) => getAddress(recipient)) + if (new Set(normalized.map((recipient) => recipient.toLowerCase())).size !== normalized.length) { + throw new Error('Duplicate recipient') + } + const allocations = normalized.map((recipient, index) => { + const originalRecipient = recipients[index] + const amount = allocationsBps[recipient] + ?? allocationsBps[originalRecipient] + ?? allocationsBps[recipient.toLowerCase()] + ?? 0 + if (!Number.isSafeInteger(amount)) throw new Error('Invalid allocation') + return BigInt(amount) + }) + const total = allocations.reduce((sum, allocation) => sum + allocation, 0n) + if (allocations.some((allocation) => allocation < 0n || allocation > 10_000n)) { + throw new Error('Invalid allocation') + } + if (total !== 10_000n) throw new Error('Alloc != 10000') + return { recipients: normalized, allocations } +} + +export function createEmptyVotingState(): GovernanceVotingState { + return { + 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.', + } +} + +export function useGovernanceVoting(params: { + enabled: boolean + account: Address | null + provider: EIP1193Provider | null + publicClient: PublicClient + addresses: GovernanceContractAddresses + member: GovernanceMemberRecord | null + identityRoot: Address | null + activeAlignment: Address[] + schedule: GovernanceSchedule | null + minimumStakes: GovernanceStakeRequirements +}) { + const { + enabled, + account, + provider, + publicClient, + addresses, + member, + identityRoot, + activeAlignment, + schedule, + minimumStakes, + } = params + const [voting, setVoting] = useState(() => createEmptyVotingState()) + const [transaction, setTransaction] = useState(IDLE_VOTE_TRANSACTION) + const [isDetailOpen, setIsDetailOpen] = useState(false) + const [error, setError] = useState(null) + const refreshRequestId = useRef(0) + const transactionGuard = useGovernanceTransactionGuard([ + account?.toLowerCase() ?? 'no-account', + addresses.houses?.toLowerCase() ?? 'no-contract', + enabled ? 'enabled' : 'disabled', + ].join(':')) + + const refresh = useCallback(async () => { + if (!enabled || !account || !addresses.houses || !schedule) return + const requestId = ++refreshRequestId.current + try { + const voterKey = resolveGovernanceVoterKey(member, identityRoot, account) + const vote = await readGovernanceVote({ + publicClient, + housesAddress: addresses.houses, + voterKey, + activeAlignment, + schedule, + }) + if (requestId === refreshRequestId.current) { + setVoting(createVotingState({ + member, + identityRoot, + voteId: vote.voteId, + isVotingOpen: vote.isVotingPeriod, + voteStartTime: vote.voteStartTime, + voteConfig: vote.voteConfig, + recipients: vote.recipients, + hasVoted: vote.hasVoted, + finalizedUnits: vote.finalizedUnits, + schedule, + minimumStake: member ? minimumStakes[member.house] : 0n, + })) + setError(null) + } + } catch (err: unknown) { + if (requestId === refreshRequestId.current) setError(friendlyGovernanceError(err)) + } + }, [ + account, + activeAlignment, + addresses.houses, + enabled, + identityRoot, + member, + minimumStakes, + publicClient, + schedule, + ]) + + useEffect(() => { + refreshRequestId.current += 1 + setVoting(createEmptyVotingState()) + setTransaction(IDLE_VOTE_TRANSACTION) + setIsDetailOpen(false) + setError(null) + }, [account, addresses.houses, enabled]) + + useEffect(() => { + // Membership's 30-second refresh updates these dependencies, + // keeping voting on the same cadence. + if (enabled) void refresh() + }, [enabled, refresh]) + + const setVoteAllocation = useCallback((recipientId: string, basisPoints: number) => { + setVoting((previous) => { + if (!(recipientId in previous.allocationsBps)) return previous + const normalizedBasisPoints = Number.isFinite(basisPoints) + ? Math.trunc(basisPoints) + : 0 + const allocationsBps = { + ...previous.allocationsBps, + [recipientId]: Math.max(0, Math.min(10_000, normalizedBasisPoints)), + } + const allocationTotalBps = Object.values(allocationsBps).reduce((total, amount) => total + amount, 0) + return { + ...previous, + allocationsBps, + allocationTotalBps, + } + }) + }, []) + + const submitVote = useCallback(async () => { + if (!account || !addresses.houses) return + const disabledReason = getGovernanceVotingDisabledReason(voting) + if (disabledReason || !voting.canVote) { + const unavailableError = disabledReason ?? 'Voting is currently unavailable.' + setError(unavailableError) + return + } + const transactionToken = transactionGuard.begin() + if (!transactionToken) return + + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) { + const providerError = 'The connected wallet provider is unavailable.' + if (transactionGuard.isCurrent(transactionToken)) { + setTransaction({ kind: 'vote', status: 'failed', hash: null, error: providerError }) + setError(providerError) + } + transactionGuard.finish(transactionToken) + return + } + + try { + const ballot = validateGovernanceBallot(voting.recipients, voting.allocationsBps) + setTransaction({ kind: 'vote', status: 'wallet_confirmation', hash: null, error: null }) + const hash = await castGovernanceVote({ + publicClient, + walletClient, + account, + housesAddress: addresses.houses, + recipients: ballot.recipients, + allocationsBps: ballot.allocations, + onStage: (stage, stageHash) => { + if (!transactionGuard.isCurrent(transactionToken)) return + setTransaction({ + kind: 'vote', + status: stage, + hash: stageHash ?? null, + error: null, + }) + }, + }) + if (!transactionGuard.isCurrent(transactionToken)) return + setTransaction({ kind: 'vote', status: 'confirmed', hash, error: null }) + await refresh() + } catch (err: unknown) { + if (!transactionGuard.isCurrent(transactionToken)) return + const status = transactionStatusFromError(err) + const friendlyError = friendlyGovernanceError(err) + setTransaction((previous) => ({ ...previous, kind: 'vote', status, error: friendlyError })) + setError(friendlyError) + } finally { + transactionGuard.finish(transactionToken) + } + }, [account, addresses.houses, provider, publicClient, refresh, transactionGuard, voting]) + + return useMemo(() => ({ + voting, + transaction, + isDetailOpen, + error, + refresh, + openVote: () => setIsDetailOpen(true), + closeVote: () => setIsDetailOpen(false), + setVoteAllocation, + submitVote, + }), [error, isDetailOpen, refresh, setVoteAllocation, submitVote, transaction, voting]) +} diff --git a/packages/governance-widget/src/index.ts b/packages/governance-widget/src/index.ts index 141a7b1e..49d029b1 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,31 @@ export type { } from './types' export { DEFAULT_TRANSACTION_STEPS } from './onboarding/constants' +export { useGovernanceAdapter } from './adapter' +export type { + GovernanceDashboardState, + GovernanceTransactionKind, + GovernanceTransactionState, + GovernanceTransactionStatus, + GovernanceUnstakeAvailability, + 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, + 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..25e3cbef 100644 --- a/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx +++ b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx @@ -23,29 +23,35 @@ import type { StepperStepItem } from '@goodwidget/ui' interface GovernanceOnboardingFlowProps { identityStatus: GovernanceIdentityStatus walletAddress?: string - disabledHouseOptions: GovernanceHouse[] initialFieldErrors: GovernanceProfileFieldErrors - stakeAmountLabel: string + stakeAmountLabels: Record transactionSteps: StepperStepItem[] finalActions: GovernanceOnboardingAction[] + onHouseChange?: (house: GovernanceHouse) => void + onIdentityVerificationPress?: () => void + onProfileSubmit?: (profileDraft: GovernanceWizardData['profileDraft'], house: GovernanceHouse) => void onFinalActionPress?: (actionId: string) => void dataTestId?: string } +function areTransactionStepsComplete(steps: StepperStepItem[]): boolean { + return steps.length > 0 && steps.every((step) => step.status === 'completed') +} + export function GovernanceOnboardingFlow({ identityStatus, walletAddress, - disabledHouseOptions, initialFieldErrors, - stakeAmountLabel, + stakeAmountLabels, transactionSteps = DEFAULT_TRANSACTION_STEPS, finalActions = DEFAULT_FINAL_ACTIONS, + onHouseChange, + onIdentityVerificationPress, + onProfileSubmit, onFinalActionPress, dataTestId, }: GovernanceOnboardingFlowProps) { const { currentStep, steps, data, setData, next } = usePageWizard() - // The success step is a terminal view and should not appear in the progress - // indicator — Stitch design shows exactly 4 steps: Verify, Path, Profile, Transact. const stepperDisplaySteps = steps.filter((s) => s.id !== 'success') const [fieldErrors, setFieldErrors] = useState(initialFieldErrors) @@ -53,6 +59,7 @@ export function GovernanceOnboardingFlow({ const selectedHouse = wizardData.selectedHouse const profileDraft = wizardData.profileDraft ?? {} const resolvedHouse: GovernanceHouse = selectedHouse ?? 'citizenship' + const selectedStakeAmountLabel = stakeAmountLabels[resolvedHouse] const isIdentityVerified = identityStatus === 'verified' const profileIsComplete = isProfileDraftComplete(resolvedHouse, profileDraft) @@ -68,7 +75,6 @@ export function GovernanceOnboardingFlow({ } }) - // Clear the error as the user types so they get immediate positive feedback setFieldErrors((previousErrors) => { const nextErrors = { ...previousErrors } delete nextErrors[fieldKey] @@ -76,8 +82,6 @@ export function GovernanceOnboardingFlow({ }) } - // Validate a single field when the user leaves it (blur) so they see - // inline feedback before hitting the submit button. const handleFieldBlur = (fieldKey: GovernanceProfileFieldKey, fieldValue: string) => { const error = validateField(fieldKey, fieldValue) setFieldErrors((prev) => { @@ -95,12 +99,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,28 +127,27 @@ export function GovernanceOnboardingFlow({ walletAddress={walletAddress} isIdentityVerified={isIdentityVerified} onProceedPress={next} + onVerifyPress={onIdentityVerificationPress} /> ) - // Footer is null — "Proceed to Membership" is inside OnboardingIdentityCard shellFooter = null break case 'house': shellTitle = 'Choose your house' shellDescription = - 'Where will your impact be felt? Choose the path that best fits your contribution.' + 'Select the governance body you wish to join.' shellContent = ( ) shellFooter = ( - - ) @@ -157,37 +162,34 @@ export function GovernanceOnboardingFlow({ selectedHouse={resolvedHouse} profileDraft={profileDraft} fieldErrors={fieldErrors} - stakeAmountLabel={stakeAmountLabel} + stakeAmountLabel={selectedStakeAmountLabel} onProfileFieldChange={updateProfileField} onProfileFieldBlur={handleFieldBlur} ctaDisabled={!profileIsComplete} - // CTA button lives inside the card — no shell footer button needed onContinuePress={handleProfileContinue} /> ) - // Footer is null — "Create Profile and Stake" is inside ProfileStepContent card shellFooter = null break case 'stake': { - // Disable the CTA until every on-chain transaction step has completed. - // L03TJ3 feedback: "I can continue to success while the progress is not finalized?" - const allStepsCompleted = - transactionSteps.length > 0 && - transactionSteps.every((step) => step.status === 'completed') - shellTitle = 'Creating profile & staking' + const allStepsCompleted = areTransactionStepsComplete(transactionSteps) + shellTitle = 'Securing your membership' shellDescription = - 'Please wait while your transaction is confirmed on-chain. You can review each step below.' + 'Transactions are being processed on-chain. Please do not close this window.' shellContent = ( - + ) - shellFooter = ( - - - ) + ) : null break } @@ -196,7 +198,7 @@ export function GovernanceOnboardingFlow({ shellContent = ( ) diff --git a/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx b/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx index 83e36c6c..20d9746d 100644 --- a/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx +++ b/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx @@ -1,18 +1,12 @@ import { Stack } from 'tamagui' -import { Badge, BadgeText, Heading, Icon, PillText, Text, XStack, createComponent } from '@goodwidget/ui' +import { Heading, Icon, PillText, XStack, createComponent } from '@goodwidget/ui' import { HOUSE_COPY } from './copy' import type { GovernanceHouse } from '../types' -/** Maps each house to its Figma-specified icon name. */ const HOUSE_ICON: Record = { citizenship: 'user', alignment: 'compass', } - - -/** - * Internal house-selection button. Uses createComponent to register for theme overrides. - */ const HouseOptionButton = createComponent(Stack, { name: 'GovernanceHouseOptionButton', tag: 'button', @@ -41,13 +35,6 @@ const HouseOptionButton = createComponent(Stack, { backgroundColor: '$backgroundHover', }, }, - disabled: { - true: { - opacity: 0.5, - cursor: 'not-allowed', - pointerEvents: 'none', - }, - }, } as const, }) @@ -100,7 +87,6 @@ const HousePill = createComponent(Stack, { interface HouseSelectionCardProps { house: GovernanceHouse isSelected: boolean - isDisabled: boolean stakeAmountLabel: string onPress: () => void } @@ -108,7 +94,6 @@ interface HouseSelectionCardProps { export function HouseSelectionCard({ house, isSelected, - isDisabled, stakeAmountLabel, onPress, }: HouseSelectionCardProps) { @@ -117,24 +102,19 @@ export function HouseSelectionCard({ return ( - {/* ── Header: icon + title + radio (matches Figma layout) ── */} - {houseCopy.title} + {houseCopy.title} - {/* ── Summary text ─────────────────────────────────────────── */} - {houseCopy.summary} - {houseCopy.label} @@ -142,14 +122,7 @@ export function HouseSelectionCard({ {`${stakeAmountLabel} stake`} - {isSelected ? ( - - Selected - - ) : null} - - {/* "Continue with this house" row removed — not in Figma design */} ) } diff --git a/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx b/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx index ebb2467e..45ef3893 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' @@ -122,19 +124,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/copy.ts b/packages/governance-widget/src/onboarding/copy.ts index bdee8c22..64988ccf 100644 --- a/packages/governance-widget/src/onboarding/copy.ts +++ b/packages/governance-widget/src/onboarding/copy.ts @@ -13,14 +13,14 @@ export const HOUSE_COPY: Record = { title: 'House of Citizenship', summary: 'Represent verified community members and highlight your public governance identity.', helper: 'Collect the profile details that describe the member behind the wallet.', - label: 'Membership house', + label: 'Identity', defaultStakeAmount: '100 G$', }, alignment: { title: 'House of Alignment', summary: 'Coordinate aligned projects and explain how your mission creates value for the network.', helper: 'Collect project-facing metadata that can later map to the onchain registration shape.', - label: 'Project house', + label: 'Protocol security', defaultStakeAmount: '250 G$', }, } diff --git a/packages/governance-widget/src/onboarding/steps/HouseStepContent.tsx b/packages/governance-widget/src/onboarding/steps/HouseStepContent.tsx index 0571c074..583d2fad 100644 --- a/packages/governance-widget/src/onboarding/steps/HouseStepContent.tsx +++ b/packages/governance-widget/src/onboarding/steps/HouseStepContent.tsx @@ -4,35 +4,31 @@ import type { GovernanceHouse } from '../../types' interface HouseStepContentProps { selectedHouse?: GovernanceHouse - disabledHouseOptions: GovernanceHouse[] - stakeAmountLabel: string + stakeAmountLabels: Record onHouseSelect: (nextHouse: GovernanceHouse) => void } export function HouseStepContent({ selectedHouse, - disabledHouseOptions, - stakeAmountLabel, + stakeAmountLabels, onHouseSelect, }: HouseStepContentProps) { return ( - onHouseSelect('citizenship')} - /> onHouseSelect('alignment')} /> + onHouseSelect('citizenship')} + /> diff --git a/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx b/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx index fe74fca3..ef3df1ee 100644 --- a/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx +++ b/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx @@ -183,7 +183,6 @@ export function ProfileStepContent({ )} - {/* ── CTA button (Figma: inside card at bottom) ───────────────── */}