Skip to content

feat(governance): finalize membership lifecycle and runtime QA (#80)#99

Open
edehvictor wants to merge 3 commits into
GoodDollar:copilot/plan-wire-governancewidget-to-gooddaohousesfrom
edehvictor:fix/governance-lifecycle-80
Open

feat(governance): finalize membership lifecycle and runtime QA (#80)#99
edehvictor wants to merge 3 commits into
GoodDollar:copilot/plan-wire-governancewidget-to-gooddaohousesfrom
edehvictor:fix/governance-lifecycle-80

Conversation

@edehvictor

@edehvictor edehvictor commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #80

Related: #75, #76, #77, GoodProtocol #299

Summary

Finalizes the contract-backed GovernanceWidget membership lifecycle and runtime boundaries introduced in #77. Active members now receive a truthful, receipt-aware unstake() flow; no-member, pending Alignment, and revoked states follow the GoodDaoHouses contract; voting/funding edge cases are enforced before submission; and the adapter is split into maintainable widget-local hooks.

The page hierarchy, header, onboarding stepper, membership cards, and transaction surfaces were aligned with the supplied governance UI references while keeping the existing GoodWidget design system and avoiding the out-of-scope detailed voting/dashboard redesign.

Changes

  • Replaced the misleading restake/rejoin path with lock-aware unstake() handling, successful-receipt refresh, and contextual wallet-confirmation, submitted, rejected, reverted, failed, and confirmed states.
  • Preserved normal onboarding for None/unstaked members, GoodID verification for Citizenship, HoA registry gating for Alignment, the pending Alignment state, and a non-actionable revoked state.
  • Reduced useGovernanceAdapter to composition over dedicated membership, voting, and funding hooks without moving contract calls into UI components.
  • Enforced vote-start membership rules, provisional HoA recipient filtering, Citizen GoodID-root voter keys, unique valid addresses, and exactly 10,000 basis points.
  • Kept stopped streams in the cumulative received total while distinguishing whether any stream is currently active.
  • Hardened account and network switching against stale read or receipt state, made wallet actions single-flight, enforced current house minimum stakes before voting, and sourced lock/window time from Celo block timestamps.
  • Confirmed GoodDaoHouses vote IDs are zero-indexed and fixed the first-cycle zero-offset fallback; blocked registration until current membership/stake reads exist; preserved eligibility reasons while allocations change; and prioritized active transactions over stale terminal results.
  • Added deterministic Storybook fixtures, mocked viem RPC/Superfluid integration coverage, lifecycle regression tests, and updated screenshot evidence.

Acceptance criteria

Membership lifecycle

  • Active members use the actual GoodDaoHouses unstake() call and wait for a successful receipt.
  • Unstaking is enabled only after member.updatedAt + termDuration; the locked state explains when it becomes available.
  • Confirmed unstaking refreshes reads and returns the wallet to no-member onboarding.
  • Rejected, reverted, failed, wallet-confirmation, submitted, and confirmed transaction states are represented truthfully.
  • None/unstaked members can join Citizenship; Alignment remains gated by getHoaEligibility(account).isEligible.
  • Pending Alignment remains pending, while revoked membership is concise and non-actionable.
  • No reactivation, committee action, contract change, or unrevoke workaround was added.

Runtime correctness and maintainability

  • Membership/onboarding, voting, and funding concerns live in meaningful widget-local hooks; useGovernanceAdapter remains a small composition boundary.
  • Contract/SDK calls remain inside packages/governance-widget; presentational cards and onboarding components stay contract-agnostic.
  • Voters and provisional HoA recipients must have joined before the vote start; an existing contract snapshot remains authoritative.
  • Ballots contain unique valid addresses and allocations totaling exactly 10,000 bps.
  • Citizen duplicate-vote checks use the GoodID root; eligible active members of either house can vote.
  • Cumulative funding includes stopped streams, while active-stream presentation requires a non-zero current flow rate.
  • Wallet/account or network changes invalidate pending receipt callbacks and previous-account state.
  • On-chain timestamps that cannot be safely represented in milliseconds fall back to unavailable states instead of imprecise dates.

Tests, evidence, and comments

  • Real-adapter integration tests use mocked viem RPC and Superfluid responses.
  • Runtime coverage includes no-member onboarding, HoA gating, registration/unstake receipt outcomes, lock boundaries, voting eligibility, GoodID-root checks, recipient filtering, ballot validation, and funding active/stopped/empty/failed states.
  • Storybook covers disconnected, loading, onboarding, pending, active, no eligible HoA, upcoming/open/already-voted/executed votes, funding unavailable, unsupported chain, unstake lifecycle, revoked, and friendly-error states.
  • Current screenshots under tests/widgets/governance-widget/test-results/ were regenerated for the latest UI states.
  • Comments in touched files are limited to non-obvious protocol decisions.

Verification

Check Result
Governance package lint (eslint packages/governance-widget/src plus changed governance specs) Passed
Governance package ESM/CJS/declaration build (tsup) Passed
Governance adapter logic and runtime Playwright suite 51 passed
Governance dashboard and onboarding Playwright suite 21 passed
Total governance Playwright coverage 72 passed
git diff --check / staged diff check Passed

pnpm lint continues to report the unrelated, pre-existing root failures required to be disclosed by #80:

  • packages/core/src/detect.ts: unused HostEnvironment.
  • packages/embed/src/createMiniAppElement.tsx: unused _ parameter.

The final filtered Turbo invocation was blocked by the local non-interactive pnpm wrapper attempting to purge node_modules (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY). The already-installed toolchain was therefore used directly; the governance package, including generated declarations, built successfully.

Evidence

Scope confirmation

  • No GoodProtocol contract or deployment changes.
  • No shared packages/ui, packages/core, or packages/embed API changes.
  • No new dashboard metrics, fabricated allocation breakdowns, committee UI, or detailed voting-page redesign.

@edehvictor
edehvictor requested review from a team and L03TJ3 July 16, 2026 18:53
@edehvictor edehvictor changed the title Fix/governance lifecycle 80 feat(governance): finalize membership lifecycle and runtime QA (#80) Jul 16, 2026
@edehvictor
edehvictor changed the base branch from main to copilot/plan-wire-governancewidget-to-gooddaohouses July 16, 2026 19:04
Guard registration, unstake, and voting receipts by wallet scope and prevent duplicate submissions. Enforce current house stake thresholds and chain-time locks, expand real-adapter coverage, and stabilize screenshot evidence.
@L03TJ3 L03TJ3 moved this to In Review in GoodBounties Jul 20, 2026
@L03TJ3
L03TJ3 requested a review from pheobeayo July 21, 2026 15:45

@pheobeayo pheobeayo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really solid work overall, the adapter split is a genuine composition boundary (781→278 lines with no forwarding files), the receipt/guard handling is careful, and the stale-state tests around account switching cover the hard cases. Requesting changes on three items only: the vote ID indexing assumption in voteStartTimeFromSchedule (needs confirmation against GoodProtocol #299 since it affects on-chain submission correctness), the zero-stake transferAndCall path in register, and the disabledReason clobbering in setVoteAllocation. The rest are non-blocking suggestions. Happy to re-review quickly once those land.

currentBlockTime: number | null
}

export function voteStartTimeFromSchedule(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This derives the fallback vote start as cycleStartTime + voteId * termDuration, which assumes getCurrentVoteId is 0-indexed relative to the cycle start. Can you confirm against the GoodDaoHouses implementation in GoodProtocol #299 that vote IDs start at 0? If they're 1-based, the pre-snapshot provisional filtering below (L189–L212) computes the wrong window, one full term late and could submit recipients the contract will reject, or wrongly exclude eligible ones.

The unit test at adapter-logic.spec.ts L389 encodes the same assumption rather than verifying it, so it passes either way. If the contract exposes the current vote's start directly (or the indexing is confirmed), a one-line comment here citing the contract source would prevent this question resurfacing. Since voteConfig.startTime takes precedence when it exists, this only bites in the exact window the fallback was written for before the first ballot creates the snapshot which is why I'd like it nailed down before merge.

return
}

const stakeAmountWei = membership?.minimumStakes[selectedHouse] ?? 0n

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the membership reads haven't landed yet (or were invalidated by an account switch), this falls back to 0n and prompts the wallet for a transferAndCall with zero stake, a guaranteed revert. Suggest an early return before transactionGuard.begin():

if (!membership) {
      setState((previous) => ({
        ...previous,
        error: 'Membership data is still loading. Please try again in a moment.',
      }))
      return
    }
    const stakeAmountWei = membership.minimumStakes[selectedHouse]

This keeps the "never send a transaction we know will fail" property the rest of the PR is careful about.

Comment on lines +305 to +310
disabledReason: allocationTotalBps === 10_000 && previous.canVote
? undefined
: allocationTotalBps === 10_000
? previous.disabledReason
: 'Allocation totals must equal exactly 10,000 basis points.',
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stored disabledReason gets clobbered here. Sequence: an ineligible user (or any user pre-eligibility-check) edits an allocation with total ≠ 10,000 → the original reason ("Only active members can vote", "Verify your GoodID…", etc.) is overwritten with the bps message. When the total later reaches exactly 10,000 with canVote still false, the previous.disabledReason you retain is now the bps message; so the UI tells them the total is wrong when it isn't.

Since canVote and the eligibility reasons are already computed in createVotingState, I'd derive disabledReason at read time (eligibility reason first, then the bps check as a final overlay) rather than mutating it in state. That makes the reason a pure function of state and this class of ordering bug impossible.

switchToCelo,
],
)
const transaction = membership.transaction.kind === 'unstake'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kind === 'unstake' wins unconditionally here, even when it's a stale confirmed from a previous lifecycle and voting.transaction is actively in wallet_confirmation. In practice the user is usually back in onboarding after an unstake so it rarely surfaces, but the priority should probably be "most recently active" rather than "unstake always wins." If #4 above lands (resetting to idle after refresh), this simplifies to preferring whichever transaction is non-idle, which reads more honestly.

Comment on lines +195 to +202
const provisionalRecords = await Promise.all(
activeAlignment.map(async (recipient) => ({
recipient,
member: mapMemberRecord(
await publicClient.readContract({
address: housesAddress,
abi: GOODDAO_HOUSES_ABI,
functionName: 'getMember',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two observations, neither blocking:

This is N+1 getMember reads for every alignment member, and because useGovernanceVoting's refresh re-fires whenever the membership poll produces new object identities (~every 30s), it runs continuously while no snapshot exists. Fine at current HoA sizes, but a multicall batch would make it a single RPC round-trip and future-proof it.
The 30s cadence coupling itself is implicit; voting has no interval of its own and effectively polls by riding membership's identity churn through the schedule/member deps. Worth a one-line comment in useGovernanceVoting so nobody "fixes" the missing interval or memoizes the reads and silently kills vote-window updates.

return 'failed'
}

export function statusFromMember(member: GovernanceMemberRecord | null): GovernanceWidgetStatus {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending + citizenship falls through to onboarding_required. Is that state impossible by contract construction (citizenship activates immediately on registration)? If it can occur, a pending citizen would be shown the join flow and could attempt a second registration. If it's impossible, a short comment saying so would be enough, this function is the routing table for the whole widget, so silent fall-throughs deserve a note

@github-project-automation github-project-automation Bot moved this from In Review to In Progress in GoodBounties Jul 21, 2026
@edehvictor

Copy link
Copy Markdown
Collaborator Author

Hi @pheobeayo , thank you for the detailed review. I addressed the three requested changes and the relevant non-blocking observations in signed commit cb85c4e. The review also helped uncover and fix the vote-ID-zero first-window edge case. All 72 governance tests pass with zero retries. Could you please re-review PR #99 when convenient?

@edehvictor
edehvictor requested a review from pheobeayo July 21, 2026 22:16

@pheobeayo pheobeayo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review items addressed; thanks for the fast turnaround. Verified the vote ID derivation and Pending-status claims directly against GoodDaoHouses.sol in GoodProtocol #299; both code comments are accurate, and the explicit voteId-0 handling in voteStartTimeFromSchedule also fixes a latent gap where the first vote window would have skipped provisional recipient filtering. Nice catch turning resolveRegistrationStake and selectGovernanceTransaction into tested pure functions.

Local verification note: the adapter-logic suite passes on my machine (receipts, ballots, unstake boundary, stake resolution, transaction selection all green).

One optional follow-up: batch the provisional getMember reads via multicall if the HoA list grows

@edehvictor

Copy link
Copy Markdown
Collaborator Author

Hey @pheobeayo, Thanks for the re-review and independent verification. I’ll keep the provisional-member multicall optimization as a future scalability follow-up since it is non-blocking for the current HoA size.

@edehvictor

Copy link
Copy Markdown
Collaborator Author

Hi @L03TJ3, PR #99 has completed re-review. @pheobeayo confirmed that all requested changes are addressed and the focused tests pass. Can you kindly do a final review

@pheobeayo

Copy link
Copy Markdown

@L03TJ3 Awaiting your final review on this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

[GoodBounty]: Finalize GovernanceWidget functionality membership lifecycle, runtime QA, and UI/UX structure

3 participants