2025-08-25·14 min read

Governance and timelock security: properties for proposal lifecycle

Governance and Timelock Security: Properties for Proposal Lifecycle

By alex

Governance contracts sit at the top of the trust hierarchy. If the governor breaks, an attacker doesn't just steal funds — they rewrite the rules. Beanstalk lost $182M because a governance exploit let an attacker pass a malicious proposal in a single transaction. That's not a theoretical risk. It happened.

The good news: governance protocols follow well-defined state machines, and state machines are perfect targets for invariant testing. Let's build a property suite that covers the full proposal lifecycle, from creation through execution.

The proposal state machine

OpenZeppelin's Governor defines these states: Pending, Active, Canceled, Defeated, Succeeded, Queued, Expired, Executed. Each transition has rules, and every rule is a property you can test.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Test} from "forge-std/Test.sol";
import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";

contract GovernanceInvariantTest is Test {
    GovernorMock governor;
    GovernanceHandler handler;

    function setUp() public {
        governor = new GovernorMock(token, timelock);
        handler = new GovernanceHandler(governor);
        targetContract(address(handler));
    }

    /// @notice Proposals can only move forward in the state machine, never backward
    function invariant_state_machine_forward_only() public view {
        for (uint256 i = 0; i < handler.proposalCount(); i++) {
            uint256 proposalId = handler.proposalIds(i);
            uint8 currentState = uint8(governor.state(proposalId));
            uint8 previousState = handler.previousState(proposalId);

            // States: 0=Pending,1=Active,2=Canceled,3=Defeated,
            //         4=Succeeded,5=Queued,6=Expired,7=Executed
            // Terminal states: Canceled(2), Defeated(3), Expired(6), Executed(7)
            if (previousState == 2 || previousState == 3
                || previousState == 6 || previousState == 7
            ) {
                assertEq(
                    currentState,
                    previousState,
                    "Terminal state changed"
                );
            }

            // Can't go from Active back to Pending
            if (previousState == 1) {
                assertTrue(
                    currentState != 0,
                    "Regressed from Active to Pending"
                );
            }
        }
    }
}

This catches a category of bugs where state transitions are checked incorrectly. For example, a proposal that gets "un-canceled" or re-enters the Active state after being Defeated.

Voting power conservation

Total voting power cast on a proposal can't exceed total voting power in existence at the snapshot block. This sounds trivial, but it breaks in interesting ways when delegation is involved.

/// @notice Total votes cast on any proposal <= total supply at snapshot
function invariant_voting_power_conservation() public view {
    for (uint256 i = 0; i < handler.proposalCount(); i++) {
        uint256 proposalId = handler.proposalIds(i);
        (
            uint256 againstVotes,
            uint256 forVotes,
            uint256 abstainVotes
        ) = governor.proposalVotes(proposalId);

        uint256 totalVotesCast = againstVotes + forVotes + abstainVotes;
        uint256 snapshotBlock = governor.proposalSnapshot(proposalId);
        uint256 totalSupplyAtSnapshot = token.getPastTotalSupply(
            snapshotBlock
        );

        assertLe(
            totalVotesCast,
            totalSupplyAtSnapshot,
            "Votes cast exceed total supply at snapshot"
        );
    }
}

/// @notice No voter can cast more votes than their weight at snapshot
function invariant_individual_vote_weight() public view {
    for (uint256 i = 0; i < handler.proposalCount(); i++) {
        uint256 proposalId = handler.proposalIds(i);
        uint256 snapshotBlock = governor.proposalSnapshot(proposalId);

        for (uint256 j = 0; j < handler.voterCount(); j++) {
            address voter = handler.voters(j);
            uint256 votesUsed = handler.votesUsedBy(proposalId, voter);
            uint256 votingPower = token.getPastVotes(
                voter,
                snapshotBlock
            );

            assertLe(
                votesUsed,
                votingPower,
                "Voter exceeded their voting power"
            );
        }
    }
}

Quorum integrity

Quorum is the minimum participation threshold for a proposal to pass. Properties here make sure quorum can't be gamed:

/// @notice A proposal can only reach Succeeded if quorum was met
function invariant_quorum_required_for_success() public view {
    for (uint256 i = 0; i < handler.proposalCount(); i++) {
        uint256 proposalId = handler.proposalIds(i);
        uint8 state = uint8(governor.state(proposalId));

        // If Succeeded(4), Queued(5), or Executed(7), quorum must be met
        if (state == 4 || state == 5 || state == 7) {
            (
                uint256 againstVotes,
                uint256 forVotes,
                uint256 abstainVotes
            ) = governor.proposalVotes(proposalId);

            uint256 snapshotBlock = governor.proposalSnapshot(proposalId);
            uint256 requiredQuorum = governor.quorum(snapshotBlock);

            // OZ Governor counts forVotes + abstainVotes toward quorum
            assertGe(
                forVotes + abstainVotes,
                requiredQuorum,
                "Proposal succeeded without quorum"
            );
        }
    }
}

/// @notice Quorum value must be within configured bounds
function invariant_quorum_bounded() public view {
    uint256 currentQuorum = governor.quorum(block.number - 1);
    uint256 totalSupply = token.getPastTotalSupply(block.number - 1);

    // Quorum should be between 1% and 50% of total supply (configurable)
    assertGe(
        currentQuorum,
        totalSupply / 100,
        "Quorum below minimum threshold"
    );
    assertLe(
        currentQuorum,
        totalSupply / 2,
        "Quorum above maximum threshold"
    );
}

Timelock delay enforcement

The timelock is the last line of defense. If the governance vote is compromised, the timelock delay gives the community time to react. It must be airtight.

/// @notice Execution must happen only after timelock delay has passed
function invariant_timelock_delay_enforced() public view {
    for (uint256 i = 0; i < handler.executedProposalCount(); i++) {
        uint256 proposalId = handler.executedProposals(i);
        uint256 queuedAt = handler.queuedTimestamp(proposalId);
        uint256 executedAt = handler.executedTimestamp(proposalId);
        uint256 minDelay = timelock.getMinDelay();

        assertGe(
            executedAt,
            queuedAt + minDelay,
            "Executed before timelock delay elapsed"
        );
    }
}

/// @notice Timelock delay can't be set below minimum safe value
function invariant_delay_minimum() public view {
    assertGe(
        timelock.getMinDelay(),
        1 days, // protocol-specific minimum
        "Timelock delay below safe minimum"
    );
}

/// @notice Queued proposals must expire after grace period
function invariant_proposal_expiry() public view {
    for (uint256 i = 0; i < handler.proposalCount(); i++) {
        uint256 proposalId = handler.proposalIds(i);
        uint8 state = uint8(governor.state(proposalId));

        if (state == 5) { // Queued
            uint256 queuedAt = handler.queuedTimestamp(proposalId);
            uint256 gracePeriod = timelock.GRACE_PERIOD();

            if (block.timestamp > queuedAt + timelock.getMinDelay()
                + gracePeriod
            ) {
                // Should be Expired, not still Queued
                assertTrue(false, "Queued proposal should have expired");
            }
        }
    }
}

Execution atomicity

When a proposal executes, it must either fully succeed or fully revert. Partial execution is a nightmare scenario — imagine a multi-action proposal where the token transfer succeeds but the parameter update fails.

/// @notice Executed proposals must have all targets called
function invariant_execution_atomicity() public view {
    for (uint256 i = 0; i < handler.executedProposalCount(); i++) {
        uint256 proposalId = handler.executedProposals(i);
        (
            address[] memory targets,
            ,
            bytes[] memory calldatas
        ) = handler.getProposalActions(proposalId);

        for (uint256 j = 0; j < targets.length; j++) {
            assertTrue(
                handler.actionWasExecuted(proposalId, j),
                "Proposal partially executed"
            );
        }
    }
}

Cancel and veto safety

Cancellation is tricky. Who can cancel? Under what conditions? And what happens to queued timelock transactions when a proposal gets canceled?

/// @notice Only authorized parties can cancel proposals
function invariant_cancel_authorization() public view {
    for (uint256 i = 0; i < handler.cancelledProposalCount(); i++) {
        uint256 proposalId = handler.cancelledProposals(i);
        address canceller = handler.cancelledBy(proposalId);
        address proposer = handler.proposerOf(proposalId);

        // Only the proposer or guardian should cancel
        assertTrue(
            canceller == proposer
                || canceller == governor.guardian()
                || governor.hasRole(governor.CANCELLER_ROLE(), canceller),
            "Unauthorized cancellation"
        );
    }
}

/// @notice Cancelled proposals must have their timelock operations cancelled
function invariant_cancel_clears_timelock() public view {
    for (uint256 i = 0; i < handler.cancelledProposalCount(); i++) {
        uint256 proposalId = handler.cancelledProposals(i);
        bytes32 timelockId = governor.timelockIds(proposalId);

        if (timelockId != bytes32(0)) {
            assertFalse(
                timelock.isOperationPending(timelockId),
                "Cancelled proposal still pending in timelock"
            );
        }
    }
}

Delegation consistency

Token delegation adds a whole layer of complexity. Your properties need to verify that delegation doesn't create or destroy voting power.

/// @notice Delegation doesn't change total voting power
function invariant_delegation_conserves_power() public view {
    uint256 totalDelegatedPower;
    for (uint256 i = 0; i < handler.holderCount(); i++) {
        address holder = handler.holders(i);
        totalDelegatedPower += token.getVotes(holder);
    }

    // Total delegated power should equal total supply
    // (undelegated tokens have zero voting power in OZ Governor)
    assertLe(
        totalDelegatedPower,
        token.totalSupply(),
        "Delegation created voting power from nothing"
    );
}

/// @notice Self-delegation gives full voting power
function invariant_self_delegation_full_power() public view {
    for (uint256 i = 0; i < handler.holderCount(); i++) {
        address holder = handler.holders(i);
        if (token.delegates(holder) == holder) {
            assertEq(
                token.getVotes(holder),
                token.balanceOf(holder),
                "Self-delegated but voting power != balance"
            );
        }
    }
}

Real governance exploits

Let's look at how these properties would've caught real attacks.

Beanstalk ($182M, April 2022)

The attacker flash-loaned a massive amount of tokens, used them to gain voting power, created a malicious proposal, voted on it, and executed it, all in one transaction. The root cause: the governance contract used current token balances for voting power instead of historical snapshots.

The voting power conservation invariant would catch this. Flash-loaned tokens wouldn't appear in the snapshot block's supply, so the attacker's votes would exceed their historical voting power.

Audius ($6M, July 2022)

A governance proposal was able to modify the proxy's storage layout through an unguarded initialization function. The proposal passed through proper governance channels, but the action it executed was malicious in a way nobody noticed.

The timelock delay enforcement invariant wouldn't have prevented this directly, but having the delay would've given the community time to spot the malicious calldata. More importantly, access control properties on the initializer function would've flagged it during testing.

Tornado Cash governance (May 2023)

An attacker deployed a malicious proposal contract that, upon execution, gave itself enough TORN tokens to control governance permanently. The contract's destroy function could be used to self-destruct and redeploy with different code.

The voting power conservation invariant catches this: after execution, total voting power in the system exceeds the token's total supply at the snapshot block. Something created votes from nothing.

Building the handler

Your governance handler needs careful timing control:

contract GovernanceHandler is CommonBase, StdCheats, StdUtils {
    function createProposal(uint256 actorSeed) external {
        address proposer = _selectProposer(actorSeed);
        // Ensure proposer meets proposal threshold
        if (token.getVotes(proposer) < governor.proposalThreshold()) {
            return;
        }
        vm.prank(proposer);
        uint256 id = governor.propose(targets, values, calldatas, "Test");
        _trackProposal(id, proposer);
    }

    function castVote(
        uint256 actorSeed,
        uint256 proposalSeed,
        uint8 support
    ) external {
        address voter = _selectVoter(actorSeed);
        uint256 proposalId = _selectActiveProposal(proposalSeed);
        if (proposalId == 0) return;

        support = uint8(bound(support, 0, 2)); // Against, For, Abstain
        vm.prank(voter);
        governor.castVote(proposalId, support);
        _trackVote(proposalId, voter, support);
    }

    function advanceBlocks(uint256 blocks) external {
        blocks = bound(blocks, 1, 100000);
        vm.roll(block.number + blocks);
        vm.warp(block.timestamp + blocks * 12);
        _updateProposalStates();
    }

    function queueProposal(uint256 proposalSeed) external {
        uint256 proposalId = _selectSucceededProposal(proposalSeed);
        if (proposalId == 0) return;
        governor.queue(
            targets, values, calldatas, keccak256("Test")
        );
        queuedTimestamp[proposalId] = block.timestamp;
    }

    function executeProposal(uint256 proposalSeed) external {
        uint256 proposalId = _selectQueuedProposal(proposalSeed);
        if (proposalId == 0) return;
        governor.execute(
            targets, values, calldatas, keccak256("Test")
        );
        executedTimestamp[proposalId] = block.timestamp;
    }
}

The advanceBlocks function is essential. Governance proposals have voting periods measured in blocks, so without block advancement, you'll never test transitions from Active to Succeeded/Defeated.

Where to start

For governance security, start with these four properties:

  1. State machine forward-only: no state regression
  2. Voting power conservation: can't create votes from nothing
  3. Quorum enforcement: proposals can't pass without participation
  4. Timelock delay: execution respects the configured delay

Then add delegation consistency, cancellation safety, and execution atomicity. If your protocol has custom extensions (optimistic governance, guardians, vetoers), write specific properties for those.

Governance bugs are low-frequency but catastrophic. A single exploit can permanently compromise a protocol. That's why these properties matter. They're your automated proof that the rules of your governance system can't be broken.

For more on invariant testing patterns, see property design patterns for DeFi lending. And if you're just getting started, how to write your first invariant test walks through the setup step by step.


Need governance security testing for your protocol? Request an Audit or Try Recon Pro to generate properties for your governor contracts.

Related Posts

Related Glossary Terms

Secure your governance contracts