Appearance
L2-1: DAO Governance
1. Problem
Create a full DAO governance contract that implements proposal creation, voting, a proposal queue (1 active + 1 queued), 3-state voting (For/Against/Abstain), and automatic vote removal from the active proposal upon token transfer. This is a direct upgrade from Token Voting (L1-1) — moving from simple voting to a complete on-chain governance system.
2. Why
Token Voting (L1-1) only has basic "vote for/against" functionality, but real DAO governance requires the following critical features:
- Proposal queue management — Only one active proposal at a time (to prevent attention fragmentation and vote-fatigue attacks); pending proposals auto-queue
- Lazy state progression — No dependency on keeper bots or timers; the queue is automatically checked and advanced during user interaction (
propose()/vote()) - 3-state voting — Supports Abstain, allowing members to express participation without taking a side
- Scoped vote removal — Token transfers only remove votes from the active proposal (historical proposal votes are immutable)
- Simple majority outcome determination —
votesFor > votesAgainstmeans passed (abstentions do not affect the result; they only contribute to quorum statistics)
This serves as a simplified precursor to Compound Governor Alpha — understanding the proposal queue mechanism is foundational for later learning about OZ Governor.
3. Solution
Architecture Design
Proposal Queue:
activeProposalId → Currently votable proposal (0 = no active proposal)
queuedProposalId → Waiting proposal (0 = no queued proposal)
State Progression:
_tryPromoteQueue() auto-invoked at the entry of propose() and vote()
├── activeProposalId == 0? → check queue → promote
└── Active proposal expired? → promote queued proposal → reset queue
Contract Structure
solidity
contract Governance {
IERC20 public immutable token;
uint256 public immutable votingPeriod;
struct Proposal {
string title;
uint256 deadline;
address creator;
uint256 votesFor;
uint256 votesAgainst;
uint256 votesAbstain;
}
uint256 public proposalCount;
mapping(uint256 => Proposal) public proposals;
uint256 public activeProposalId;
uint256 public queuedProposalId;
// Vote tracking
mapping(uint256 => mapping(address => bool)) public hasVoted;
mapping(uint256 => mapping(address => uint8)) public voteChoice;
mapping(uint256 => mapping(address => uint256)) public voteWeight;
}
Proposal Lifecycle
propose(title) → Proposal(deadline=now+votingPeriod)
├── No active proposal → activeProposalId = proposalId
└── Active proposal exists → queuedProposalId = proposalId
vote(voteType) → Vote only on activeProposalId
└── Vote weight = token.balanceOf(voter)
_tryPromoteQueue() → Called on every propose/vote
├── Active expired → activeId = queuedId → queued = 0
└── Active not expired → no-op
getResult(proposalId) → Only for expired proposals
└── votesFor > votesAgainst → true
Core Code
Lazy queue promotion:
solidity
function _tryPromoteQueue() internal {
if (activeProposalId != 0) {
Proposal storage active = proposals[activeProposalId];
if (block.timestamp > active.deadline) {
activeProposalId = queuedProposalId;
queuedProposalId = 0;
}
}
}
Remove votes from active proposal only:
solidity
function removeVotes(address from) external onlyTokenContract {
uint256 proposalId = activeProposalId; // ← Active proposal only
// Deduct from votesFor/votesAgainst/votesAbstain
hasVoted[proposalId][from] = false;
delete voteChoice[proposalId][from];
delete voteWeight[proposalId][from];
}
3-state voting:
solidity
function vote(uint8 voteType) external {
_tryPromoteQueue(); // Lazy promotion at entry
// ...
if (voteType == uint8(VoteType.For)) {
proposal.votesFor += balance;
} else if (voteType == uint8(VoteType.Against)) {
proposal.votesAgainst += balance;
} else {
proposal.votesAbstain += balance; // Abstain: participate but don't affect result
}
}
4. Pitfalls Encountered
4.1 Proposal Queue Deadlock
When P1 (active) and P2 (queued) have deadlines very close to each other, P2 gets promoted after P1 expires, but P2 may also expire immediately — leaving a voting window of 0.
Concrete scenario:
- P1 created at t=0, deadline = t+7d
- P2 created at t=1 (queued), deadline = t+1+7d
- P1 only expires at t+7d+1 (
_tryPromoteQueuetriggers whenblock.timestamp > deadline) - When P2 is promoted:
block.timestamp = t+7d+1 > P2.deadline = t+8d→ not expired
But if P2 is created at t=6d+23h (1h before P1's deadline):
- P1 deadline = t+7d
- P2 deadline = t+6d+23h+7d = t+13d+23h
- P2 has sufficient voting time after promotion
The real deadlock scenario: If P2 is created immediately after P1 and its deadline is very close to P1's → P2 may also be near expiration after promotion. The solution is to ensure P2's creation time is at least half a voting period after P1's.
4.2 _tryPromoteQueue Invocation Timing
_tryPromoteQueue is only called at the entry of propose() and vote(). If no one votes and no one proposes, expired proposals are never advanced — the queue is stuck forever.
This is intentional (lazy evaluation pattern): no keeper bot or external cron is needed; just wait for the next user interaction.
4.3 removeVotes Proposal Scope
removeVotes(from) only operates on activeProposalId:
- Historical proposal votes are unaffected (immutable)
- When tokens are transferred, the sender's votes in the active proposal are removed
- However, if a user holds tokens, votes, then sells tokens (same address), the new holder cannot vote again (because
hasVoted[activeId][buyer]is still false)
Correct understanding: removeVotes is designed for DecentralizedResistanceToken._update() — when the from address transfers tokens, the token contract calls governance.removeVotes(from) to remove from's votes in the active proposal. The new holder does not automatically gain voting rights (their token balance has already changed).
4.4 Queue Capacity Limit
The maxQueuedProposals error is thrown when activeProposalId != 0 && queuedProposalId != 0 — meaning at most 2 unprocessed proposals can exist simultaneously (1 active + 1 queued). This is a lean design to prevent proposal flooding attacks.
5. Why the Pitfalls Exist
5.1 Time Drift in Lazy Promotion
Lazy promotion does not rely on precise block boundaries. _tryPromoteQueue only triggers on the next user interaction, meaning:
- If DRT is temporarily inactive (no one proposes/votes), expired proposals keep occupying
activeProposalId - The "actual promotion time" of a front-running proposal = the time of the next
propose/vote - This does not affect result correctness (voting has already ended, the deadline has already passed)
5.2 Key Differences from Token Voting (L1-1)
| Behavior | L1-1: Token Voting | L2-1: DAO Governance |
|---|---|---|
| Number of proposals | 1 | Up to 2 (1 active + 1 queued) |
| removeVotes scope | The only proposal | Active proposal only |
| Voting options | For/Against (bool) | For/Against/Abstain (uint8) |
| Proposal structure | Implicit (stored directly in contract) | Explicit (Proposal struct + proposalCount) |
5.3 Rationale for Abstain Votes
Abstain votes are recorded in votesAbstain but do not affect getResult() (only votesFor > votesAgainst matters). The purpose of abstain votes:
- Express "I am paying attention but have no position" (a participation signal)
- Can be used for quorum calculation in more complex governance
- Prevents "silence" from being misinterpreted as "consent"
6. How to Solve the Pitfalls
solidity
// Correct: Lazy promotion at every entry point
function propose(string calldata title) external returns (uint256) {
_tryPromoteQueue(); // ← Critical
// ... create proposal
}
function vote(uint8 voteType) external {
_tryPromoteQueue(); // ← Critical
// ... vote
}
// Correct: removeVotes only operates on active proposal
function removeVotes(address from) external onlyTokenContract {
uint256 proposalId = activeProposalId; // ← Always operates on activeProposalId
// ... remove votes
}
// Correct: Result determination relies only on simple majority
function getResult(uint256 proposalId) external view returns (bool) {
require(block.timestamp > proposal.deadline, "Voting not ended");
return proposals[proposalId].votesFor > proposals[proposalId].votesAgainst;
}
7. Technical Takeaways
| Point | Description |
|---|---|
| Proposal queue | Up to 1 active + 1 queued, flood-attack resistant |
| Lazy state transition | _tryPromoteQueue triggered on user interaction |
| 3-state voting | For(1) / Against(0) / Abstain(2) |
| removeVotes isolation | Affects only activeProposalId; history is immutable |
| Simple majority | votesFor > votesAgainst — abstentions do not affect result |
| DRT integration | Token _update calls governance.removeVotes(from) |
| Differences from OZ Governor | No timelock, no quorum, no calldata execution |