From a forum thread to a transaction the protocol signs itself
A governance forum carries a line like *GP-12: fund the security audit — send 100,000 GOV from the treasury to the audit team.* In an ordinary company a finance lead reads it, approves, and signs the transfer. In a DAO nobody holds that pen. The treasury's keys belong to a smart contract, and the only thing that can make it move money is a vote that the contract itself tallies and then obeys. The previous guide established that a DAO is, underneath, a treasury plus rules written as code. This guide opens the most important rule of all — exactly how those token holders decide, mechanically, on-chain.
Nearly every serious DAO runs the same three-box machine, standardised by Compound's Governor Bravo and OpenZeppelin's Governor contracts. Box one is a governance token that measures how much each address's vote weighs. Box two is the Governor contract — a state machine that walks a proposal through a fixed lifecycle and counts the votes. Box three is a separate timelock that actually owns the treasury and refuses to execute anything until a mandatory delay has elapsed. Keep those three boxes distinct and every on-chain vote you ever read about becomes legible.
Voting power is the tokens you held in the past — and who you lent them to
Here is the first surprise: in token-weighted voting, holding the token is not the same as having votes. OpenZeppelin's standard `ERC20Votes` only starts counting your weight after you delegate it — even if you only delegate to yourself. The reason is cost: the chain cannot afford to re-tally everybody's balance on every transfer, so it tracks a running checkpoint of voting power only for addresses someone has delegated to.
// OpenZeppelin v5 — a token whose balances can become votes.
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() ERC20("Gov", "GOV") ERC20Permit("Gov") {
_mint(msg.sender, 1_000_000e18);
}
// ...required v5 _update / nonces overrides omitted for brevity...
}
// A balance counts as ZERO votes until it is delegated -- even to yourself:
// token.delegate(myself); // activate my own weight
// token.delegate(trustedDelegate); // lend my weight, keep custody
// token.delegateBySig(...); // do it gaslessly, with a signature
// Every transfer writes a checkpoint, so the chain can answer later:
// token.getPastVotes(account, pastBlock) // my votes at a past block
// token.getPastTotalSupply(pastBlock) // total votes back thenNow the load-bearing detail. When a proposal opens, the Governor fixes a snapshot block, and from then on it weighs each voter with `getPastVotes(voter, snapshotBlock)` — their power as of that past block, not their balance today. This single design choice is the first line of defence against rented votes. A flash loan can hand an attacker millions of tokens for the length of one transaction, but it cannot rewrite the historical checkpoints, so those borrowed tokens carry zero voting power at a snapshot that is already in the past. (The full catalogue of governance attacks, and where this defence can still fail, is the next guide.)
The proposal lifecycle is a state machine
A proposal is not a sentence of English intent — it is the exact bundle of calls the Governor will make if and only if the vote passes. You submit it as three parallel arrays plus a human-readable description: the contracts to call (`targets`), the ETH to attach to each (`values`), and the exact encoded calldata for each (`calldatas`). The Governor hashes all of it into a deterministic `proposalId`, so the same actions always yield the same id and the friendly description can never be quietly swapped for different code.
// GP-12: "send 100,000 GOV from the treasury to the audit team" address[] memory targets = new address[](1); uint256[] memory values = new uint256[](1); bytes[] memory calldatas = new bytes[](1); targets[0] = address(govToken); // the call is made TO the token contract, values[0] = 0; // with no ETH attached, calldatas[0] = abi.encodeCall(IERC20.transfer, (grantee, 100_000e18)); string memory description = "GP-12: fund the security audit grant"; uint256 id = governor.propose(targets, values, calldatas, description); // The id is a deterministic hash of the EXACT actions plus the description: // keccak256(abi.encode(targets, values, calldatas, keccak256(bytes(description)))) // The funds live in the timelock, so the transfer is ultimately made BY the timelock.
- Pending. The proposal exists but voting is not open yet. The Governor waits `votingDelay` blocks, and the snapshot of everyone's voting power is taken at the instant voting begins — the delay gives holders time to delegate or move tokens before weights freeze.
- Active. Voting is open for `votingPeriod` blocks. Each voter casts For, Against, or Abstain, weighted by `getPastVotes` at the snapshot. Only an address holding at least `proposalThreshold` votes was even allowed to open the proposal in the first place — that threshold is the anti-spam gate.
- Defeated or Succeeded. When voting closes, the Governor checks two conditions — quorum and majority (next section). Fail either and the proposal is Defeated and simply dies; pass both and it is Succeeded.
- Queued. A Succeeded proposal is queued into the timelock, which starts the mandatory delay. Nothing has executed yet — the decision is made, but the action is still pending.
- Executed (or Expired). After the timelock delay, anyone may call `execute` and the bundled calls finally fire. If no one executes within the timelock's grace period, the queued proposal Expires and must be re-proposed.
These knobs are concrete settings, not abstractions. Compound's live Governor Bravo, for instance, uses a `votingDelay` of 13,140 blocks (~2 days), a `votingPeriod` of 19,710 blocks (~3 days), a `proposalThreshold` of 25,000 COMP, and a quorum of 400,000 COMP. Read those numbers as a deliberate tempo: roughly a week from idea to execution, slow enough that delegates and ordinary users can actually notice and react before anything irreversible happens.
Quorum and thresholds: two gates, not one
Three different numbers gate a proposal, and beginners constantly conflate them. The proposal threshold is the weight you need just to open a proposal — an anti-spam fee, not a vote count. The other two are checked when voting closes. The first is the quorum: enough total weight must turn out, so that a tiny, unrepresentative clique cannot pass things while everyone is asleep. With OpenZeppelin's `GovernorVotesQuorumFraction`, quorum is a percentage of the total voting supply measured at the snapshot — say 4%. The second is the majority test itself.
// GovernorCountingSimple encodes each ballot as: // support = 0 (Against) | 1 (For) | 2 (Abstain) // When voting closes, TWO independent gates are checked: quorumReached = (forVotes + abstainVotes) >= quorum(snapshotBlock); voteSucceeded = (forVotes > againstVotes); passes = quorumReached && voteSucceeded; // BOTH must hold // The asymmetry that trips people up: // Abstain -> counts toward QUORUM, but NOT toward For-beats-Against // For -> counts toward both // Against -> counts toward NEITHER quorum nor success
Make it concrete. Suppose total supply is 1,000,000 GOV and quorum is 4% — so 40,000 weight must participate. A vote closes with For 55,000, Against 30,000, Abstain 5,000. Quorum check: For + Abstain = 60,000 ≥ 40,000 ✓. Majority check: 55,000 > 30,000 ✓. The proposal Succeeds. Now change the turnout: For 22,000, Against 3,000, Abstain 5,000. The majority is overwhelming (22,000 ≫ 3,000), yet For + Abstain = 27,000 < 40,000, so quorum fails and the proposal is Defeated. A popular idea can still die simply because too few people showed up.
The timelock: a mandatory pause before anything happens
Why delay a proposal that already won? Because "code is law" cuts both ways: a passed proposal can move the entire treasury or upgrade the protocol's own contracts, and there is no appeals court afterward. So the standard stack does something subtle — the Governor does not own the money or the admin rights. A separate `TimelockController` does. The Governor merely holds the proposer role on that timelock; it can schedule actions but cannot fire them, and the timelock refuses to execute anything until a minimum delay has elapsed.
// The Timelock -- not the Governor -- holds the treasury and admin keys.
TimelockController timelock = new TimelockController(
2 days, // minDelay: nothing executes until 2 days after queuing
proposers, // [address(governor)] -> only the Governor may schedule
executors, // [address(0)] -> after the delay, ANYONE may execute
admin // wires the roles; ideally renounced once set up
);
// Lifecycle of a winning proposal:
// governor.queue(...) -> timelock.schedule(...) // start the 2-day clock
// ...wait minDelay... // the public safety window
// governor.execute(...) -> timelock.execute(...) // the calls finally fire// A complete, standard Governor, assembled from OpenZeppelin v5 mixins.
contract MyGovernor is
Governor, GovernorSettings, GovernorCountingSimple,
GovernorVotes, GovernorVotesQuorumFraction, GovernorTimelockControl
{
constructor(IVotes token, TimelockController timelock)
Governor("MyGovernor")
GovernorSettings(
7200, // votingDelay: ~1 day (blocks until the snapshot)
50400, // votingPeriod: ~1 week (blocks the poll stays open)
100e18 // proposalThreshold: votes needed to OPEN a proposal
)
GovernorVotes(token) // weight = token.getPastVotes(...)
GovernorVotesQuorumFraction(4) // quorum = 4% of past total supply
GovernorTimelockControl(timelock) // execution routes THROUGH the timelock
{}
// The mixins supply votingDelay(), votingPeriod(), quorum(timepoint),
// proposalThreshold(), state(), propose(), castVote(), queue(), execute().
// (Several v5 override stubs are elided here for brevity.)
}That waiting window is the safety valve. During the delay — Compound's timelock is 2 days — anyone who dislikes the outcome can act: a user can withdraw funds and exit before the change lands, and a guardian (often a multisig holding the timelock's canceller role) can veto a malicious-but-passed proposal before it executes. The delay turns a binding vote from an instant, irreversible event into one with a public cooling-off period.
On-chain or off-chain: Snapshot, gas, and the gasless vote
Everything so far is binding on-chain governance: the vote and the action share one transaction trail, and code carries the result out. That honesty is also its cost — every cast vote is a gas-paying transaction, which prices small holders out and makes a hotly-contested vote expensive. So most DAOs reach for a cheaper layer during the deliberation stage.
The dominant tool is Snapshot, an off-chain platform where a vote is just a signed message stored off-chain, weighted by a token balance read from a past block. It is free and gasless, so anyone can participate — but a Snapshot vote executes nothing. It is a poll. To turn the result into action, a trusted multisig team still has to go and make the transaction by hand. Many teams run exactly this hybrid: signal cheaply on Snapshot, then put only the final, binding step on-chain.
Binding governance has narrowed the gap, though. OpenZeppelin's Governor supports `castVoteBySig`, which lets a holder sign their vote off-chain and have a relayer submit it, so the voter pays no gas even though the tally stays fully on-chain and self-executing. The honest summary: off-chain Snapshot trades enforceability for cost and inclusivity; on-chain Governor trades cost for credibly-neutral, self-executing outcomes — and signature-based voting recovers some of that inclusivity without giving up enforcement.
When one-token-one-vote isn't enough: the alternatives
For all its machinery, token-weighted voting has one stubborn flaw it cannot engineer away: plutocracy. One token, one vote means weight equals wealth, so a whale's preferences can simply outvote a thousand smaller, more numerous holders — and voter apathy, with only a few percent of supply ever voting, makes that wealth cheap to wield. Delegation eases participation but concentrates power further. Several alternative ballots try to do better; here is the honest gist of two, with the full mechanism-design treatment saved for guide four of this rung.
Quadratic voting makes intensity cost something. Each voter gets a budget of voice credits, and casting v votes on one issue costs v squared — so influence grows only with the square root of resources, letting a passionate minority outweigh an indifferent majority precisely when it genuinely cares more. The catch is brutal: the whole result assumes one budget per person. If credits are a tradeable token, a whale simply splits a stake across many wallets — a sybil attack — and the square root collapses back toward plain wealth-voting. Quadratic voting exports its security to a proof-of-personhood layer that crypto does not yet have a clean answer for.
Conviction voting attacks the time axis instead. There is no snapshot and no fixed window; your support accrues conviction the longer you keep tokens staked behind a proposal, and the proposal passes once its accumulated conviction crosses a threshold. This naturally resists flash loans — capital held for a single block builds almost no conviction, because conviction needs time, the one thing a flash loan cannot rent — and it expresses durable preference rather than a one-block spike. The honest costs: it is slow, its decay and threshold parameters are fiddly to tune, and it is still ultimately token-weighted, so it dampens a whale's speed, not its size.