Appearance
L2-1: DAO Governance(DAOガバナンス)⭐
1. 問題
完全なDAOガバナンスコントラクトを作成し、提案作成、投票、提案キュー(1件アクティブ + 1件待機中)、3状態投票(賛成/反対/棄権)、トークン移転時のアクティブ提案からの自動投票削除を実装します。これは Token Voting (L1-1) からの直接的なアップグレードです——単純な投票から完全なオンチェーンガバナンスシステムへの進化です。
2. 理由
Token Voting (L1-1) は基本的な「賛成/反対」投票のみですが、実際のDAOガバナンスには以下の重要な機能が必要です:
- 提案キュー管理 — 同時に1つのアクティブ提案のみ(注意散漫と投票疲労攻撃を防止)、保留中の提案は自動的にキューイング
- 遅延状態進行 — キーパーボットやタイマーに依存せず、ユーザー操作(
propose()/vote())時に自動チェックしてキューを進行 - 3状態投票 — 棄権(Abstain)をサポートし、立場を取らずに参加を表明可能
- 投票削除のスコープ分離 — トークン移転時にアクティブ提案の投票のみを削除(過去の提案の投票は変更不可)
- 単純多数決による結果判定 —
votesFor > votesAgainstで可決(棄権は結果に影響せず、定足数統計のみに使用)
これは Compound Governor Alpha の簡略化された先行概念です——提案キュー機構の理解は、後の OZ Governor 学習の基礎となります。
3. 解決策
アーキテクチャ設計
提案キュー:
activeProposalId → 現在投票可能な提案 (0 = アクティブ提案なし)
queuedProposalId → 待機中の提案 (0 = 待機提案なし)
状態進行:
_tryPromoteQueue() は propose() と vote() の入り口で自動呼び出し
├── activeProposalId == 0? → キューを確認 → 昇格
└── アクティブ提案が期限切れ? → 待機提案を昇格 → キューをリセット
コントラクト構造
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;
// 投票追跡
mapping(uint256 => mapping(address => bool)) public hasVoted;
mapping(uint256 => mapping(address => uint8)) public voteChoice;
mapping(uint256 => mapping(address => uint256)) public voteWeight;
}
提案ライフサイクル
propose(title) → Proposal(deadline=now+votingPeriod)
├── アクティブ提案なし → activeProposalId = proposalId
└── アクティブ提案あり → queuedProposalId = proposalId
vote(voteType) → activeProposalId に対してのみ投票
└── 投票重み = token.balanceOf(voter)
_tryPromoteQueue() → 毎回の propose/vote で呼び出し
├── アクティブ期限切れ → activeId = queuedId → queued = 0
└── アクティブ期限内 → 何もしない
getResult(proposalId) → 期限切れの提案のみ
└── votesFor > votesAgainst → true
コアコード
遅延キュー進行:
solidity
function _tryPromoteQueue() internal {
if (activeProposalId != 0) {
Proposal storage active = proposals[activeProposalId];
if (block.timestamp > active.deadline) {
activeProposalId = queuedProposalId;
queuedProposalId = 0;
}
}
}
アクティブ提案のみの投票削除:
solidity
function removeVotes(address from) external onlyTokenContract {
uint256 proposalId = activeProposalId; // ← アクティブ提案のみ
// votesFor/votesAgainst/votesAbstain から差し引く
hasVoted[proposalId][from] = false;
delete voteChoice[proposalId][from];
delete voteWeight[proposalId][from];
}
3状態投票:
solidity
function vote(uint8 voteType) external {
_tryPromoteQueue(); // 入り口で遅延進行
// ...
if (voteType == uint8(VoteType.For)) {
proposal.votesFor += balance;
} else if (voteType == uint8(VoteType.Against)) {
proposal.votesAgainst += balance;
} else {
proposal.votesAbstain += balance; // 棄権:参加するが結果に影響しない
}
}
4. 遭遇した落とし穴
4.1 提案キューのデッドロック
P1(アクティブ)と P2(待機中)の期限が非常に近い場合、P1 期限切れ後に P2 が昇格されますが、P2 も即座に期限切れになる可能性があります——投票ウィンドウが 0 になります。
具体的なシナリオ:
- P1 が t=0 に作成、deadline = t+7d
- P2 が t=1 に作成(待機中)、deadline = t+1+7d
- P1 は t+7d+1 にのみ期限切れ(
_tryPromoteQueueはblock.timestamp > deadlineで発火) - P2 昇格時:
block.timestamp = t+7d+1 > P2.deadline = t+8d→ 期限切れではない
しかし P2 が t=6d+23h に作成された場合(P1 期限の1時間前):
- P1 deadline = t+7d
- P2 deadline = t+6d+23h+7d = t+13d+23h
- P2 は昇格後に十分な投票時間がある
本当のデッドロックシナリオ: P1 作成直後に P2 が作成され、その期限が P1 に非常に近い場合 → P2 昇格後もすぐに期限切れになる可能性があります。解決策は、P2 の作成時間が P1 より少なくとも投票期間の半分後になるようにすることです。
4.2 _tryPromoteQueue の呼び出しタイミング
_tryPromoteQueue は propose() と vote() の入り口でのみ呼び出されます。誰も投票せず誰も提案しない場合、期限切れの提案は永遠に進行されません——キューは永遠にスタックします。
これは意図的なものです(遅延評価パターン):キーパーボットや外部cronは不要で、次のユーザー操作を待つだけです。
4.3 removeVotes の提案スコープ
removeVotes(from) は activeProposalId のみを操作します:
- 過去の提案の投票は影響を受けません(変更不可)
- トークン移転時、送信者のアクティブ提案における投票が削除されます
- ただし、ユーザーがトークンを保有し、投票し、その後売却した場合(同じアドレス)、新しい保有者は再度投票できません(
hasVoted[activeId][buyer]は依然として false であるため)
正しい理解: removeVotes は DecentralizedResistanceToken._update() のために設計されています——from アドレスがトークンを移転する際、トークンコントラクトが governance.removeVotes(from) を呼び出して from のアクティブ提案での投票を削除します。新しい保有者は自動的に投票権を取得しません(トークン残高は既に変更されています)。
4.4 キュー容量制限
maxQueuedProposals エラーは activeProposalId != 0 && queuedProposalId != 0 の場合にスローされます——つまり、同時に最大2つの未処理提案(1アクティブ + 1待機中)しか存在できません。これは提案フラッディング攻撃を防ぐためのリーンな設計です。
5. 落とし穴の理由
5.1 遅延進行における時間のずれ
遅延進行は正確なブロック境界に依存しません。_tryPromoteQueue は次のユーザー操作時にのみ発火します。つまり:
- DRT が一時的に非アクティブな場合(誰も提案/投票しない)、期限切れの提案は
activeProposalIdを占有し続けます - 先頭提案の「実際の進行時間」= 次回の propose/vote の時間
- これは結果の正当性に影響しません(投票は既に終了し、期限は既に経過しています)
5.2 Token Voting (L1-1) との主な違い
| 動作 | L1-1: Token Voting | L2-1: DAO Governance |
|---|---|---|
| 提案数 | 1件 | 最大2件(1アクティブ + 1待機中) |
| removeVotes の範囲 | 唯一の提案 | アクティブ提案のみ |
| 投票オプション | 賛成/反対 (bool) | 賛成/反対/棄権 (uint8) |
| 提案構造 | 暗黙的(コントラクトに直接保存) | 明示的(Proposal 構造体 + proposalCount) |
5.3 棄権票の設計根拠
棄権票は votesAbstain に記録されますが、getResult() には影響しません(votesFor > votesAgainst のみが判断基準)。棄権票の意義:
- 「注目しているが立場はない」ことを表明(参加シグナル)
- より複雑なガバナンスでは定足数計算に使用可能
- 「沈黙」が「同意」と誤解されるのを防ぐ
6. 落とし穴の解決方法
solidity
// 正解:遅延進行は各エントリポイントで実行
function propose(string calldata title) external returns (uint256) {
_tryPromoteQueue(); // ← 重要
// ... 提案を作成
}
function vote(uint8 voteType) external {
_tryPromoteQueue(); // ← 重要
// ... 投票
}
// 正解:removeVotes はアクティブ提案のみを操作
function removeVotes(address from) external onlyTokenContract {
uint256 proposalId = activeProposalId; // ← 常に activeProposalId を操作
// ... 投票を削除
}
// 正解:結果判定は単純多数決のみに依存
function getResult(uint256 proposalId) external view returns (bool) {
require(block.timestamp > proposal.deadline, "Voting not ended");
return proposals[proposalId].votesFor > proposals[proposalId].votesAgainst;
}
7. 技術的ポイント
| ポイント | 説明 |
|---|---|
| 提案キュー | 最大1アクティブ + 1待機中、フラッディング攻撃耐性 |
| 遅延状態遷移 | ユーザー操作時に _tryPromoteQueue が発火 |
| 3状態投票 | 賛成(1) / 反対(0) / 棄権(2) |
| removeVotes の分離 | activeProposalId のみに影響、履歴は変更不可 |
| 単純多数決 | votesFor > votesAgainst — 棄権は結果に影響せず |
| DRT 統合 | トークン _update が governance.removeVotes(from) を呼び出し |
| OZ Governor との違い | タイムロックなし、定足数なし、calldata 実行なし |