Most governance attacks don't happen during voting — they happen during the execution window. Your timelock configuration is your last line of defense. You've built a token, deployed a Governor, and maybe even run a Snapshot vote. But if your queue() to execute() flow is a gaping, unprotected hole, you're just waiting for someone to drain the treasury. The OpenZeppelin Governor framework, used in 65% of on-chain governance implementations (Code4rena audit data 2025), gives you the tools to plug it—if you wire them together correctly.
This guide is for when you've moved past "what's a DAO?" and are staring at a Governor contract wondering how to make it production-ready. We'll build a complete, secure stack: from token with delegation, through a Governor with a Timelock, all the way to a Gnosis Safe treasury. We'll also simulate a flash loan attack in Foundry so you can see the failure mode you're designing against.
The Stack: From Token Holder to Treasury Execution
Your governance architecture is a pipeline of trust. Each component enforces a specific rule, and a break in the chain breaks everything. Here’s the canonical flow:
- ERC20Votes Token: Holders delegate voting power. This is the source of truth for "who can vote."
- Governor Contract: Manages proposals, voting, and vote tallying. It reads voting power from the token.
- Timelock Contract: Sits as the executor. The Governor proposes, the Timelock queues, and after a delay, executes. This delay is your security blanket.
- Gnosis Safe Treasury: Holds the assets. The Timelock is its sole owner (or a signer on a multi-sig). All fund movements must pass through the Timelock's queue.
The critical insight? The Governor does not hold funds. The Timelock does not make decisions. This separation of powers is what stops a malicious proposal from instantly running amok.
Making Your Token Governance-Ready with ERC20Votes
A plain ERC20 token is useless for governance. Voting power must be checkpointed—snapshotted at a specific block—to prevent voters from borrowing tokens, voting, and returning them in the same block. ERC20Votes adds this.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
contract GovernanceToken is ERC20, ERC20Votes {
constructor() ERC20("GovToken", "GT") ERC20Permit("GovToken") {
_mint(msg.sender, 1000000 * 10 ** decimals());
}
// The following functions are overrides required by Solidity.
function _update(address from, address to, uint256 amount)
internal
override(ERC20, ERC20Votes)
{
super._update(from, to, amount);
}
}
Key points: It inherits from both ERC20 and ERC20Votes, and overrides _update to call both parents. The ERC20Permit import enables gas-less token approvals via EIP-2612, a nice UX boost. Without delegation, tokens have no voting power. Token holders must call delegate(to) (often to themselves) to activate their voting weight.
Real Error & Fix:
- Error:
Governor: proposal not successful - Fix: This isn't just about votes. First, check the quorum requirement is met (default is 4% of total supply). Then, verify the voting period hasn't expired before tallying. A proposal can have majority support but fail on quorum.
Wiring Governor, Votes, and Timelock Control
This is the core integration. You need a Governor that understands checkpointed votes (GovernorVotes) and one that defers execution to a Timelock (GovernorTimelockControl).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
contract DAOGovernor is Governor, GovernorVotes, GovernorTimelockControl {
constructor(IVotes _token, TimelockController _timelock)
Governor("DAOGovernor")
GovernorVotes(_token)
GovernorTimelockControl(_timelock)
{}
// 1. Configuration (We'll set these next)
function votingDelay() public pure override returns (uint256) { return 7200; } // ~1 day in blocks
function votingPeriod() public pure override returns (uint256) { return 50400; } // ~1 week
function quorum(uint256 blockNumber) public pure override returns (uint256) {
return 100000 * 10 ** 18; // Fixed 100k token quorum
}
function proposalThreshold() public pure override returns (uint256) { return 1000 * 10 ** 18; }
// 2. Required Overrides for Timelock Integration
function state(uint256 proposalId)
public
view
override(Governor, GovernorTimelockControl)
returns (ProposalState)
{
return super.state(proposalId);
}
function _execute(
uint256 proposalId,
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
bytes32 descriptionHash
) internal override(Governor, GovernorTimelockControl) {
super._execute(proposalId, targets, values, calldatas, descriptionHash);
}
function _cancel(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
bytes32 descriptionHash
) internal override(Governor, GovernorTimelockControl) returns (uint256) {
return super._cancel(targets, values, calldatas, descriptionHash);
}
function _executor() internal view override(Governor, GovernorTimelockControl) returns (address) {
return super._executor();
}
}
The constructor wires up the token and timelock. The overrides (state, _execute, etc.) are boilerplate required when combining these extensions—copy them precisely.
Tuning the Knobs: Delay, Period, Quorum, and Threshold
These parameters define your DAO's political engine. Get them wrong, and you're either paralyzed or vulnerable.
- Voting Delay (
votingDelay): Blocks between proposal submission and voting start. Gives voters time to assess. 1-3 days is typical. - Voting Period (
votingPeriod): How long voting lasts. Too short, turnout suffers. Too long, you're slow. 3-7 days is common. Remember, average DAO voter turnout is just 8–15% of token holders per proposal (DeepDAO 2025 report). - Quorum (
quorum): Minimum voting power required for a proposal to be valid. A fixed number (as above) is simpler than a percentage, which can change with token supply. This is your most important defense against apathy attacks. - Proposal Threshold (
proposalThreshold): Voting power needed to submit a proposal. Stops spam. 0.1%-1% of supply is common.
The Execution Timeline Reality:
A typical secure cycle isn't fast. It's a minimum of:
Snapshot (2 days) → Voting Period (3-7 days) → Timelock Delay (48h) → Execution.
That's a minimum 4-day on-chain cycle, often over a week. This is why many DAOs use Snapshot (off-chain voting), which has 20M+ votes recorded and is used by 13,000+ DAOs (Snapshot, Q1 2026), for sentiment checks before an on-chain proposal.
The Full Proposal Lifecycle: propose() → vote() → queue() → execute()
Let's walk through a proposal to send 1 ETH from the treasury to a contributor.
- Propose: A token holder with more than the
proposalThresholdcallspropose(targets, values, calldatas, description). Thetargetwill be the Timelock address, and thecalldatawill be the transaction the Timelock should eventually execute. - Vote: After the delay, delegates vote
for,against, orabstain. Votes are weighted by the delegate's token balance at the proposal's snapshot block. - Queue: If the vote succeeds (meets quorum and majority), anyone can call
queue()on the Governor. This does not execute. It submits the action to the Timelock with an ETA ofblock.timestamp + timelock.minDelay(). - Execute: After the timelock delay has passed, anyone can call
execute()to finally run the transaction.
Real Error & Fix:
- Error:
Timelock: transaction is not queued - Fix: The
queue()transaction must be called with the exacteta(current time + min delay). Thetargets,values,calldatas, anddescriptionHashmust match the proposal's details exactly. A single byte off in calldata and thekeccak256hash won't match.
Securing the Treasury: Timelock as Safe Owner
Your treasury is likely a Gnosis Safe, which secures $100B+ in DAO treasury assets (Gnosis Safe, Jan 2026). The final, critical wiring is making the Timelock the owner of the Safe.
- Deploy a Gnosis Safe with a 2-of-3 multi-sig as the initial owner.
- In the Safe UI, go to Settings → Owners → Replace the 2-of-3 multi-sig with the address of your TimelockController contract.
- Now, any transaction that moves funds from the Safe must be a proposal that goes through the full Governor → Timelock lifecycle.
Gas Cost Reality Check:
| Action | Gas Cost (Approx.) | Notes |
|---|---|---|
| Snapshot (off-chain) vote | 0 gas | The scalability king. |
| On-chain Governor vote | ~80,000 gas | ~$4 on mainnet. Why turnout is low. |
| Gnosis Safe 2-of-3 execution | ~65,000 gas | More expensive than an EOA (~21k) but secure. |
| Full Proposal (queue + execute) | ~200,000+ gas | The price of on-chain finality. |
Simulating a Flash Loan Attack (And How Your Timelock Stops It)
Let's prove the value of the timelock with a Foundry test. The attack vector: a malicious actor takes a massive flash loan, acquires a majority of tokens, pushes through a proposal to drain the treasury, and executes it immediately—all before the loan is repaid.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/GovernanceToken.sol";
import "../src/DAOGovernor.sol";
import "@openzeppelin/contracts/governance/TimelockController.sol";
contract GovernanceAttackTest is Test {
GovernanceToken token;
TimelockController timelock;
DAOGovernor governor;
address attacker = address(0xbad);
address safeTreasury = address(0xsafe);
function setUp() public {
// Deploy normal setup
token = new GovernanceToken();
timelock = new TimelockController(0, new address[](0), new address[](0));
governor = new DAOGovernor(token, timelock);
// Mint initial supply to "honest" users
token.mint(address(this), 1000000e18);
token.delegate(address(this));
}
function testFlashLoanAttackWithoutTimelock() public {
// Simulate flash loan: attacker gets 51% of tokens momentarily
vm.startPrank(attacker);
token.mint(attacker, 2000000e18); // The "loan"
token.delegate(attacker);
// 1. Propose to drain treasury to attacker
address[] memory targets = new address[](1);
targets[0] = address(safeTreasury);
uint256[] memory values = new uint256[](1);
values[0] = 100 ether;
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = abi.encodeWithSignature("transfer(address,uint256)", attacker, 100 ether);
uint256 proposalId = governor.propose(targets, values, calldatas, "Steal funds");
// 2. Vote instantly with massive weight
vm.roll(block.number + governor.votingDelay() + 1);
governor.castVote(proposalId, 1); // 1 = for
// 3. Instantly execute after vote ends (NO TIMELOCK)
vm.roll(block.number + governor.votingPeriod() + 1);
// If governor could execute directly on Safe, funds would be stolen HERE.
// governor.execute(...);
// 4. Repay flash loan (burn the tokens)
token.burn(attacker, 2000000e18);
vm.stopPrank();
// The attack would have succeeded. The timelock breaks this.
assertEq(token.balanceOf(attacker), 0); // Attacker has no tokens...
// But without a timelock, they'd have 100 ETH.
}
}
The test shows the attack flow. The critical line is // governor.execute(...). In our architecture, the Governor cannot execute. It can only queue on the Timelock. The attacker would have to wait the full timelock delay (e.g., 48 hours) before execute() works. Their flash loan must be repaid in one transaction, so the attack is impossible. The timelock enforces a mandatory cooling-off period where the community can see a malicious queued transaction and prepare a response (e.g., a whitehat counter-proposal).
Next Steps: From Prototype to Production
You now have a secure, on-chain governance core. But this is just the foundation. To move to production:
- Start with Snapshot: Use it for temperature checks and high-frequency decisions. It's gas-less and fast. Bridge successful Snapshot votes to on-chain execution via tools like Zodiac's Snapshot-Executor or Tally's interface.
- Implement a Fallback: What if the Timelock is compromised? Use a Gnosis Safe with the Timelock as a signer, not the sole owner. Make it a 2-of-3 where the Timelock is one signer, and a 2-of-2 multi-sig of trusted community members are the others. This adds a human veto for clear emergencies.
- Optimize for Voter Turnout: That 8-15% turnout is a problem. Integrate with Boardroom or Karma for voter dashboards and delegation markets. Consider vote delegation protocols to pool influence with experts.
- Upgrade Considerations: Make your Governor upgradeable (via UUPS proxy) from day one. Your parameters will need to change. The upgrade mechanism itself should be behind the Timelock.
- Cross-Chain Governance: As your DAO spans multiple chains, look at Aragon OSx with its cross-chain governance protocol or Compound Bravo's cross-chain proposal system.
The goal isn't to make governance fast—it's to make it secure and legitimate. The timelock is the heartbeat of that security, creating the mandatory pause where the collective can react. Your job is to build the pipes, then let the community fill them.