Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions examples/storybook/src/fixtures/governanceInteractiveMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { decodeAbiParameters, decodeFunctionData, parseAbi, type Address, type Hex } from 'viem'
import type { EIP1193Provider } from '@goodwidget/core'
import {
encodeMockGovernanceRead,
MOCK_ALIGNMENT,
MOCK_CITIZEN,
MOCK_G_TOKEN,
MOCK_GOOD_ID,
MOCK_HOUSES,
} from './governanceRuntimeMock'

// Minimal write-side ABI fragments, mirroring packages/governance-widget/src/sdks/contracts.ts.
// Duplicated locally (same convention as the read ABI in governanceRuntimeMock.ts) so this
// browser fixture has no dependency on package internals.
const G_TOKEN_WRITE_ABI = parseAbi([
'function transferAndCall(address to, uint256 value, bytes data) returns (bool)',
])
const HOUSES_WRITE_ABI = parseAbi([
'function castVote(address[] recipients, uint256[] allocations)',
'function unstake()',
])
const REGISTRATION_DATA_TYPES = [
{ type: 'uint8' },
{ type: 'string' },
{ type: 'string' },
{ type: 'string' },
{ type: 'string' },
{ type: 'string' },
] as const

const MOCK_GOVERNANCE_RPC_PATH = '/mock-governance-rpc'
const MOCK_SUPERFLUID_URL_FRAGMENT = 'celo-mainnet/protocol-v1'
const MOCK_ACCOUNT: Address = '0x1234123412341234123412341234123412341234'
const MOCK_NOW_SECONDS = 1_784_419_200

type PendingTransactionEffect =
| { kind: 'registration'; house: 0 | 1 }
| { kind: 'vote' }
| { kind: 'unstake' }

interface InteractiveGovernanceSession {
memberStatus: 0 | 1 | 2 | 3 | 4
memberHouse: 0 | 1
hasVoted: boolean
}

function buildMockReceipt(status: 'success' | 'reverted', hash: Hex) {
return {
blockHash: `0x${'b'.repeat(64)}`,
blockNumber: '0x10',
contractAddress: null,
cumulativeGasUsed: '0x5208',
effectiveGasPrice: '0x1',
from: MOCK_ACCOUNT,
gasUsed: '0x5208',
logs: [],
logsBloom: `0x${'0'.repeat(512)}`,
status: status === 'reverted' ? '0x0' : '0x1',
to: MOCK_HOUSES,
transactionHash: hash,
transactionIndex: '0x0',
type: '0x2',
}
}

function buildMockBlock() {
return {
baseFeePerGas: '0x0',
difficulty: '0x0',
extraData: '0x',
gasLimit: '0x1c9c380',
gasUsed: '0x0',
hash: `0x${'b'.repeat(64)}`,
logsBloom: `0x${'0'.repeat(512)}`,
miner: MOCK_HOUSES,
mixHash: `0x${'c'.repeat(64)}`,
nonce: '0x0000000000000000',
number: '0x10',
parentHash: `0x${'d'.repeat(64)}`,
receiptsRoot: `0x${'e'.repeat(64)}`,
sha3Uncles: `0x${'f'.repeat(64)}`,
size: '0x0',
stateRoot: `0x${'1'.repeat(64)}`,
timestamp: `0x${MOCK_NOW_SECONDS.toString(16)}`,
totalDifficulty: '0x0',
transactions: [],
transactionsRoot: `0x${'2'.repeat(64)}`,
uncles: [],
}
}

function buildMockFundingStreams() {
return {
data: {
streams: [
{
sender: { id: MOCK_CITIZEN.toLowerCase() },
currentFlowRate: '0',
streamedUntilUpdatedAt: '300000000000000000000',
updatedAtTimestamp: String(MOCK_NOW_SECONDS),
},
{
sender: { id: MOCK_ALIGNMENT.toLowerCase() },
currentFlowRate: '1',
streamedUntilUpdatedAt: '150000000000000000000',
updatedAtTimestamp: String(MOCK_NOW_SECONDS),
},
],
},
}
}

function requestUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') return input
if (input instanceof URL) return input.href
return input.url
}

export interface InteractiveGovernanceEnvironment {
provider: EIP1193Provider
celoRpcUrl: string
addresses: { housesAddress: Address; goodIdAddress: Address; gTokenAddress: Address }
teardown: () => void
}

/**
* Wires a self-contained, browser-native mocked Celo RPC and Superfluid
* subgraph behind a `window.fetch` override, paired with a matching mock
* EIP-1193 wallet. This lets a human open the story directly in Storybook
* and drive the real `useGovernanceAdapter` runtime end-to-end (onboarding ->
* vote -> unstake) without a live contract or Playwright's `page.route`
* network interception, which only runs under automation.
*/
export function createInteractiveGovernanceEnvironment(): InteractiveGovernanceEnvironment {
const session: InteractiveGovernanceSession = { memberStatus: 0, memberHouse: 0, hasVoted: false }
const pendingEffectsByHash = new Map<Hex, PendingTransactionEffect>()
const listeners: Record<string, Array<(...args: unknown[]) => void>> = {}
const originalFetch = window.fetch.bind(window)
let transactionCounter = 0

const nextTransactionHash = (): Hex => {
transactionCounter += 1
return `0x${transactionCounter.toString(16).padStart(64, '0')}` as Hex
}

const applyReceiptEffect = (effect: PendingTransactionEffect) => {
if (effect.kind === 'registration') {
session.memberStatus = 2
session.memberHouse = effect.house
} else if (effect.kind === 'vote') {
session.hasVoted = true
} else if (effect.kind === 'unstake') {
session.memberStatus = 4
}
}

const provider = {
async request({ method, params }: { method: string; params?: unknown }) {
switch (method) {
case 'eth_requestAccounts':
case 'eth_accounts':
return [MOCK_ACCOUNT]
case 'eth_chainId':
return '0xa4ec'
case 'wallet_switchEthereumChain':
return null
case 'eth_estimateGas':
return '0x5208'
case 'eth_sendTransaction': {
const tx = (params as Array<Record<string, unknown>>)?.[0] ?? {}
const to = String(tx.to ?? '').toLowerCase()
const data = tx.data as Hex
const hash = nextTransactionHash()

if (to === MOCK_HOUSES.toLowerCase()) {
const decoded = decodeFunctionData({ abi: HOUSES_WRITE_ABI, data })
if (decoded.functionName === 'castVote') pendingEffectsByHash.set(hash, { kind: 'vote' })
if (decoded.functionName === 'unstake') pendingEffectsByHash.set(hash, { kind: 'unstake' })
} else if (to === MOCK_G_TOKEN.toLowerCase()) {
const decoded = decodeFunctionData({ abi: G_TOKEN_WRITE_ABI, data })
if (decoded.functionName === 'transferAndCall') {
const registrationData = decoded.args[2]
const [house] = decodeAbiParameters(REGISTRATION_DATA_TYPES, registrationData)
pendingEffectsByHash.set(hash, { kind: 'registration', house: Number(house) === 1 ? 1 : 0 })
}
}
return hash
}
default:
throw new Error(`Interactive governance mock: unsupported wallet method "${method}"`)
}
},
on(event: string, listener: (...args: unknown[]) => void) {
listeners[event] = [...(listeners[event] ?? []), listener]
},
removeListener(event: string, listener: (...args: unknown[]) => void) {
listeners[event] = (listeners[event] ?? []).filter((entry) => entry !== listener)
},
} as EIP1193Provider

window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input)

if (url.includes(MOCK_GOVERNANCE_RPC_PATH)) {
const payload = JSON.parse(String(init?.body ?? '{}')) as {
id: number
method: string
params?: unknown[]
}
const respond = (result: unknown) =>
new Response(JSON.stringify({ jsonrpc: '2.0', id: payload.id, result }), {
headers: { 'content-type': 'application/json' },
})

if (payload.method === 'eth_getTransactionReceipt') {
const hash = payload.params?.[0] as Hex
const effect = pendingEffectsByHash.get(hash)
if (effect) applyReceiptEffect(effect)
return respond(buildMockReceipt('success', hash))
}
if (payload.method === 'eth_getBlockByNumber') return respond(buildMockBlock())
if (payload.method === 'eth_blockNumber') return respond('0x10')
if (payload.method !== 'eth_call') return respond('0x')

const call = (payload.params?.[0] as { to?: Address; data?: Hex } | undefined) ?? {}
if (!call.to || !call.data) return respond('0x')
const result = encodeMockGovernanceRead(call.to, call.data, {
memberStatusByAccount: { [MOCK_ACCOUNT.toLowerCase()]: session.memberStatus },
memberHouseByAccount: { [MOCK_ACCOUNT.toLowerCase()]: session.memberHouse },
hasVotedByVoter: { [MOCK_ACCOUNT.toLowerCase()]: session.hasVoted },
})
return respond(result)
}

if (url.includes(MOCK_SUPERFLUID_URL_FRAGMENT)) {
return new Response(JSON.stringify(buildMockFundingStreams()), {
headers: { 'content-type': 'application/json' },
})
}

return originalFetch(input, init)
}) as typeof window.fetch

return {
provider,
celoRpcUrl: MOCK_GOVERNANCE_RPC_PATH,
addresses: { housesAddress: MOCK_HOUSES, goodIdAddress: MOCK_GOOD_ID, gTokenAddress: MOCK_G_TOKEN },
teardown: () => {
window.fetch = originalFetch
},
}
}
15 changes: 12 additions & 3 deletions examples/storybook/src/fixtures/governanceRuntimeMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ export const MOCK_GOOD_ID = '0x5555555555555555555555555555555555555555' as Addr
export const MOCK_CITIZEN = '0x6666666666666666666666666666666666666666' as Address
export const MOCK_ALIGNMENT = '0x7777777777777777777777777777777777777777' as Address
export const MOCK_POOL = '0x8888888888888888888888888888888888888888' as Address
export const MOCK_G_TOKEN = '0x9999999999999999999999999999999999999999' as Address

export interface MockGovernanceReadOptions {
memberStatus?: 0 | 1 | 2 | 3 | 4
memberStatusByAccount?: Record<string, 0 | 1 | 2 | 3 | 4>
memberHouseByAccount?: Record<string, 0 | 1>
// Keyed by voter address, lowercased. Lets an interactive session reflect a
// just-submitted vote without needing a real per-voteId ledger.
hasVotedByVoter?: Record<string, boolean>
}

export function encodeMockGovernanceRead(
Expand Down Expand Up @@ -148,12 +152,14 @@ export function encodeMockGovernanceRead(
functionName: 'getVoteRecipients',
result: [MOCK_ALIGNMENT],
})
case 'getHasVoted':
case 'getHasVoted': {
const voter = String(decoded.args[1]).toLowerCase()
return encodeFunctionResult({
abi: HOUSES_READ_ABI,
functionName: 'getHasVoted',
result: false,
result: options.hasVotedByVoter?.[voter] ?? false,
})
}
case 'getFinalizedUnits':
return encodeFunctionResult({
abi: HOUSES_READ_ABI,
Expand All @@ -167,6 +173,9 @@ export function encodeMockGovernanceRead(
result: [MOCK_HOUSES, 1n, MOCK_POOL],
})
default:
throw new Error(`Unexpected houses read: ${decoded.functionName}`)
// Every HOUSES_READ_ABI function is handled above, so this branch is unreachable at
// the type level (decoded narrows to `never`) but kept as a runtime guard against a
// future ABI addition that isn't wired into this mock yet.
throw new Error(`Unexpected houses read call data: ${data}`)
}
}
101 changes: 101 additions & 0 deletions examples/storybook/src/stories/governance-widget/GovernanceWidget.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Canvas, Meta, Source } from '@storybook/blocks';
import * as ShowcaseStories from './GovernanceWidgetShowcase.stories';
import * as ThemeOverridesStories from './GovernanceWidgetThemeOverrides.stories';
import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout';

<Meta of={ShowcaseStories} />

<DocsPage
eyebrow="Widget Guide"
title="GovernanceWidget"
lead="This widget wires GoodDAO membership, staking, and House of Alignment voting to the GoodDaoHouses contract, paired with deterministic QA fixtures and a live-mocked runtime flow for manual testing."
>
<DocsSection
title="Manual wallet showcase"
description="Use this story when validating the real onboarding -> stake -> vote -> unstake flow in a browser with a real EIP-1193 wallet extension, against the live GoodDaoHouses contract. Use the Controls panel below the canvas to preview `defaultTheme`."
>
<Canvas of={ShowcaseStories.InjectedWallet} />
</DocsSection>

<DocsSection
title="Theme overrides"
description="Play around with some of the theme overrides using the controls below (you may have to activate Controls in the addons panel, top-right). The code here can be copied and used directly in your own dapp — it always matches what's rendered. See the callout below the code for additional overridable styles not wired to a control."
>
<Canvas of={ThemeOverridesStories.Playground} />
</DocsSection>

<DocsSection
title="How to mount it"
description="Keep the host contract narrow. The widget expects a provider and optional environment/theming inputs."
>
<Source
dark
language="tsx"
code={`import { GovernanceWidget } from '@goodwidget/governance-widget'
import { MiniAppShell } from '@goodwidget/ui'

export function GovernancePanel({ provider }: { provider?: unknown }) {
return (
<MiniAppShell title="GoodDollar">
<GovernanceWidget
provider={provider}
defaultTheme="dark"
/>
</MiniAppShell>
)
}`}
/>
</DocsSection>

<DocsSection
title="Live testable flow with mocked data"
description="A dedicated QA story runs the real useGovernanceAdapter runtime end-to-end (not a hardcoded fixture) against a browser-native mocked Celo RPC and Superfluid subgraph, so onboarding, voting, and unstaking can be driven by hand in Storybook without a live contract."
>
<DocsCard
title="Open the live mocked-data flow"
href="?path=/story/qa-governancewidget-runtime-fixtures--live-mocked-data-flow"
>
Connects a mock wallet and mock RPC/subgraph directly to the real adapter — the only QA
story that exercises the runtime rather than a static state.
</DocsCard>
</DocsSection>

<DocsSection
title="QA coverage"
description="Deterministic dashboard states live in a dedicated QA story file so visual regression, fixture review, and debugging stay reproducible."
>
<DocsCard
title="Open the QA fixture demo"
href="?path=/story/qa-governancewidget-runtime-fixtures--disconnected-dashboard"
>
Disconnected, onboarding, active membership, voting, unstaking, and error states all live in
`QA / GovernanceWidget / Runtime Fixtures`.
</DocsCard>
</DocsSection>

<DocsSection
title="Why this widget is split"
description="The live wallet path (real contract, no mocks) is useful for manual, product-facing validation, while the QA fixtures and the mocked-runtime flow are better suited to automation, screenshots, and repeatable review."
>
<DocsGrid>
<DocsCard title="Showcase stories">
Real wallet, real GoodDaoHouses contract, no mocked reads or writes.
</DocsCard>
<DocsCard title="QA stories">
Static dashboard fixtures for screenshots and automation, plus one live-mocked-data story
for driving the real runtime by hand.
</DocsCard>
</DocsGrid>
</DocsSection>

<DocsSection
title="Workflow guidance"
description="Choose the story type based on whether you are reviewing live contract integration or validating deterministic widget states."
>
<DocsCallout title="Rule of use" tone="info">
Use the showcase story for product-facing wallet checks against the real contract. Use the
QA fixtures for repeatable screenshots and state coverage, and the live mocked-data flow when
you need to manually exercise the real runtime without a live contract.
</DocsCallout>
</DocsSection>
</DocsPage>
Loading