-
Notifications
You must be signed in to change notification settings - Fork 9
feat(dashboard): anvil fork test for the full governance lifecycle #2133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
brunod-e
wants to merge
5
commits into
dev
Choose a base branch
from
feat/governance-fork-test
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
94fafd9
feat(dashboard): add an anvil fork test for the full governance lifec…
brunod-e 1d4e08c
feat(dashboard): drive Tornado propose and execute through the dashbo…
brunod-e 6445542
fix(dashboard): send the executeProposal() action in the fork test's …
brunod-e f24f653
fix(dashboard): redact the fork RPC URL in governance fork test logs
brunod-e 253bd57
fix(dashboard): redact anvil startup errors and tolerate trailing-sla…
brunod-e File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@anticapture/dashboard": patch | ||
| --- | ||
|
|
||
| Add an anvil fork test that runs the full proposal lifecycle (create, vote, queue, execute) for whitelabel DAOs. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| # Governance fork test | ||
|
|
||
| Automated end-to-end test of the full governance lifecycle (create proposal, | ||
| vote, queue, execute) against an anvil fork of mainnet, exercising the exact | ||
| code the dashboard uses to send transactions: | ||
|
|
||
| - `submitProposalRequest` (create-proposal feature) to publish the proposal | ||
| - `voteOnProposal` (governance feature) to cast for/against/abstain votes | ||
| - `queueProposal` / `executeProposal` (governance feature) to finish the lifecycle | ||
|
|
||
| Voters and proposers are the DAO's real top delegates, fetched live from the | ||
| Anticapture API and impersonated on the fork, so proposal thresholds and | ||
| quorums are met with genuine delegated voting power. Every step asserts the | ||
| on-chain proposal state and that the tallies match the cast power exactly. | ||
|
|
||
| ## Requirements | ||
|
|
||
| - [Foundry](https://getfoundry.sh) (`anvil` on PATH, or set `ANVIL_PATH`) | ||
| - `BLOCKFUL_API_TOKEN` and optionally `ANTICAPTURE_API_URL` in | ||
| `apps/dashboard/.env` (defaults to the dev gateway) to fetch top delegates | ||
|
|
||
| ## Usage | ||
|
|
||
| ```bash | ||
| pnpm --filter @anticapture/dashboard test:governance uni | ||
| pnpm --filter @anticapture/dashboard test:governance uni comp gtc torn ens | ||
| pnpm --filter @anticapture/dashboard test:governance all | ||
| ``` | ||
|
|
||
| Environment overrides: | ||
|
|
||
| | Variable | Default | Purpose | | ||
| | ------------------ | ------------------------------------- | --------------------------------------------- | | ||
| | `GOV_FORK_RPC_URL` | `https://ethereum-rpc.publicnode.com` | Mainnet RPC to fork | | ||
| | `GOV_FORK_PORT` | `8546` | Port for the anvil fork | | ||
| | `ANVIL_PATH` | resolved from PATH / `~/.foundry` | anvil binary | | ||
| | `GOV_REAL_TIMING` | unset | `1` disables the vote-window shortening below | | ||
|
|
||
| ## Fast timing | ||
|
|
||
| Real voting delays/periods span days (13k-45k blocks), far too slow to mine. | ||
| The harness shrinks them on the throwaway fork, verified against the | ||
| governor's own getters each time: | ||
|
|
||
| - UNI and GTC keep `votingDelay`/`votingPeriod` in plain storage slots, which | ||
| are rewritten before proposing. | ||
| - COMP and ENS get the created proposal's vote window rewritten inside the | ||
| governor's `_proposals` mapping (plain slots and ERC-7201 namespaced storage | ||
| are both discovered automatically). | ||
| - COMP additionally uses `GovernorPreventLateQuorum`; with a short window every | ||
| vote is "late", so the deadline extension is cleared before the final state | ||
| check. | ||
|
|
||
| Set `GOV_REAL_TIMING=1` to keep the real windows (expect hours of mining). | ||
|
|
||
| ## Supported DAOs | ||
|
|
||
| | DAO | Governor | Propose path | Queue/execute | | ||
| | ---- | ----------------------------------------- | ------------------------------------------ | ---------------------------------- | | ||
| | UNI | GovernorBravo | dashboard (`submitProposalRequest`) | yes | | ||
| | COMP | OZ v5 Governor (block clock, late quorum) | dashboard | yes | | ||
| | GTC | OZ Governor (hash ids, named "Bravo") | dashboard | yes | | ||
| | ENS | OZ Governor | dashboard | yes | | ||
| | TORN | Tornado custom (timestamp) | dashboard (`propose(target, description)`) | execute only (no queue on Tornado) | | ||
|
|
||
| TORN proposals delegatecall their target on execution, so the harness gives a | ||
| synthetic proposal contract a single STOP opcode via `setCode` before | ||
| proposing, votes through `voteOnProposal`, and executes through | ||
| `executeProposal` after the execution delay. | ||
|
|
||
| A failing DAO is the harness doing its job: it means this branch's dashboard | ||
| cannot run that DAO's governance on-chain. Known gaps at the time of writing: | ||
| UNI needs the GovernorBravo propose path (`feat/uniswap-whitelabel`), and TORN | ||
| needs the Tornado whitelabel PR (propose/execute paths and the `castVote` | ||
| fallback for voters without delegators). Both pass on their respective | ||
| branches. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| import { spawn, spawnSync, type ChildProcess } from "node:child_process"; | ||
| import { existsSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { | ||
| createPublicClient, | ||
| createTestClient, | ||
| createWalletClient, | ||
| http, | ||
| publicActions, | ||
| walletActions, | ||
| type Address, | ||
| type PublicClient, | ||
| type TestClient, | ||
| type WalletClient, | ||
| } from "viem"; | ||
| import { mainnet } from "viem/chains"; | ||
|
|
||
| export interface ForkHandle { | ||
| rpcUrl: string; | ||
| publicClient: PublicClient; | ||
| testClient: TestClient; | ||
| walletClient: WalletClient; | ||
| stop: () => void; | ||
| } | ||
|
|
||
| const findAnvilBinary = (): string => { | ||
| if (process.env.ANVIL_PATH) return process.env.ANVIL_PATH; | ||
|
|
||
| const probe = spawnSync(process.platform === "win32" ? "where" : "which", [ | ||
| "anvil", | ||
| ]); | ||
| if (probe.status === 0) { | ||
| const found = probe.stdout.toString().split(/\r?\n/)[0]?.trim(); | ||
| if (found) return found; | ||
| } | ||
|
|
||
| const fallback = join( | ||
| homedir(), | ||
| ".foundry", | ||
| "bin", | ||
| process.platform === "win32" ? "anvil.exe" : "anvil", | ||
| ); | ||
| if (existsSync(fallback)) return fallback; | ||
|
|
||
| throw new Error( | ||
| "anvil not found. Install foundry (https://getfoundry.sh) or set ANVIL_PATH.", | ||
| ); | ||
| }; | ||
|
|
||
| const waitForRpc = async (rpcUrl: string, child: ChildProcess) => { | ||
| const deadline = Date.now() + 120_000; | ||
| let exited = false; | ||
| child.on("exit", () => { | ||
| exited = true; | ||
| }); | ||
|
|
||
| while (Date.now() < deadline) { | ||
| if (exited) throw new Error("anvil exited before the RPC came up"); | ||
| try { | ||
| const res = await fetch(rpcUrl, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| id: 1, | ||
| method: "eth_blockNumber", | ||
| params: [], | ||
| }), | ||
| }); | ||
| const body = (await res.json()) as { result?: string }; | ||
| if (body.result) return; | ||
| } catch { | ||
| // node not up yet, retry | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 1_000)); | ||
| } | ||
| throw new Error("anvil did not become ready within 120s"); | ||
| }; | ||
|
|
||
| export const startFork = async ({ | ||
| forkUrl, | ||
| port, | ||
| }: { | ||
| forkUrl: string; | ||
| port: number; | ||
| }): Promise<ForkHandle> => { | ||
| const anvil = findAnvilBinary(); | ||
| const rpcUrl = `http://127.0.0.1:${port}`; | ||
|
|
||
| // Authenticated RPC URLs carry API keys in the path or query string, so | ||
| // never log the full URL; the host is enough to identify the provider. | ||
| let forkHost: string; | ||
| try { | ||
| forkHost = new URL(forkUrl).host; | ||
| } catch { | ||
| forkHost = "<unparseable fork url>"; | ||
| } | ||
| console.log(` starting anvil fork of ${forkHost} on :${port} ...`); | ||
| const child = spawn( | ||
| anvil, | ||
| [ | ||
| "--fork-url", | ||
| forkUrl, | ||
| "--port", | ||
| String(port), | ||
| "--auto-impersonate", | ||
| // Voting periods mean mining tens of thousands of blocks; pruning | ||
| // historical state makes that several times faster and the harness only | ||
| // ever reads latest state. | ||
| "--prune-history", | ||
| "--silent", | ||
| ], | ||
| { stdio: ["ignore", "pipe", "pipe"] }, | ||
| ); | ||
|
|
||
| let stderr = ""; | ||
| child.stderr?.on("data", (chunk: Buffer) => { | ||
| stderr += chunk.toString(); | ||
| }); | ||
| // Anvil connection errors echo the fork URL, so redact stderr before it | ||
| // reaches any log or error message. | ||
| const redactedStderr = () => stderr.trim().split(forkUrl).join(forkHost); | ||
| child.on("exit", (code) => { | ||
| if (code !== null && code !== 0) { | ||
| console.error(` anvil exited with code ${code}: ${redactedStderr()}`); | ||
| } | ||
| }); | ||
|
|
||
| try { | ||
| await waitForRpc(rpcUrl, child); | ||
| if (child.exitCode !== null) { | ||
| throw new Error( | ||
| `anvil exited immediately (port ${port} already in use?): ${redactedStderr()}`, | ||
| ); | ||
| } | ||
| } catch (error) { | ||
| child.kill(); | ||
| throw error instanceof Error && stderr | ||
| ? new Error(`${error.message}\n${redactedStderr()}`) | ||
| : error; | ||
| } | ||
|
|
||
| const transport = http(rpcUrl, { timeout: 600_000 }); | ||
| const publicClient = createPublicClient({ chain: mainnet, transport }); | ||
| const testClient = createTestClient({ | ||
| mode: "anvil", | ||
| chain: mainnet, | ||
| transport, | ||
| }); | ||
| const walletClient = createWalletClient({ chain: mainnet, transport }); | ||
|
|
||
| return { | ||
| rpcUrl, | ||
| publicClient, | ||
| testClient, | ||
| walletClient, | ||
| stop: () => child.kill(), | ||
| }; | ||
| }; | ||
|
|
||
| /** Give an account gas money. Impersonation is handled by --auto-impersonate. */ | ||
| export const fundAccount = async (fork: ForkHandle, address: Address) => { | ||
| await fork.testClient.setBalance({ address, value: 100n * 10n ** 18n }); | ||
| }; | ||
|
|
||
| /** | ||
| * Governor timing is measured in blocks, so voting delays/periods mean mining | ||
| * tens of thousands of empty blocks. Chunked so no single RPC call times out | ||
| * and progress stays visible. | ||
| */ | ||
| export const mineBlocks = async (fork: ForkHandle, blocks: number) => { | ||
| const chunkSize = 5_000; | ||
| let remaining = blocks; | ||
| while (remaining > 0) { | ||
| const chunk = Math.min(chunkSize, remaining); | ||
| await fork.testClient.mine({ blocks: chunk }); | ||
| remaining -= chunk; | ||
| if (blocks > chunkSize) { | ||
| console.log(` mined ${blocks - remaining}/${blocks} blocks`); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| export const advanceTime = async (fork: ForkHandle, seconds: number) => { | ||
| await fork.testClient.increaseTime({ seconds }); | ||
| await fork.testClient.mine({ blocks: 1 }); | ||
| }; | ||
|
|
||
| /** A wallet client that can both read and write, as the dashboard helpers expect. */ | ||
| export const extendedWalletClient = (fork: ForkHandle) => | ||
| fork.walletClient.extend(publicActions).extend(walletActions); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.