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
5 changes: 5 additions & 0 deletions .changeset/governance-fork-test.md
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.
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"build-storybook": "storybook build",
"clean": "rm -rf node_modules .next out build coverage storybook-static *.tsbuildinfo *storybook.log",
"typecheck": "tsc --noEmit",
"test:governance": "tsx scripts/governance-fork-test/index.ts",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
Expand Down Expand Up @@ -111,6 +112,7 @@
"storybook": "^10.2.0",
"tailwindcss": "^4.1.7",
"ts-jest": "^29.2.6",
"tsx": "^4.19.2",
"tw-animate-css": "^1.3.0",
"typescript": "^5",
"vitest": "^3.2.1"
Expand Down
76 changes: 76 additions & 0 deletions apps/dashboard/scripts/governance-fork-test/README.md
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.
192 changes: 192 additions & 0 deletions apps/dashboard/scripts/governance-fork-test/fork.ts
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, {
Comment thread
brunod-e marked this conversation as resolved.
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);
Loading
Loading