Skip to content
On this page

L2-1: DAO Governance(DAOガバナンス)⭐

1. 問題

完全なDAOガバナンスコントラクトを作成し、提案作成、投票、提案キュー(1件アクティブ + 1件待機中)、3状態投票(賛成/反対/棄権)、トークン移転時のアクティブ提案からの自動投票削除を実装します。これは Token Voting (L1-1) からの直接的なアップグレードです——単純な投票から完全なオンチェーンガバナンスシステムへの進化です。

2. 理由

Token Voting (L1-1) は基本的な「賛成/反対」投票のみですが、実際のDAOガバナンスには以下の重要な機能が必要です:

  1. 提案キュー管理 — 同時に1つのアクティブ提案のみ(注意散漫と投票疲労攻撃を防止)、保留中の提案は自動的にキューイング
  2. 遅延状態進行 — キーパーボットやタイマーに依存せず、ユーザー操作(propose() / vote())時に自動チェックしてキューを進行
  3. 3状態投票 — 棄権(Abstain)をサポートし、立場を取らずに参加を表明可能
  4. 投票削除のスコープ分離 — トークン移転時にアクティブ提案の投票のみを削除(過去の提案の投票は変更不可)
  5. 単純多数決による結果判定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 にのみ期限切れ(_tryPromoteQueueblock.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 の呼び出しタイミング

_tryPromoteQueuepropose()vote() の入り口でのみ呼び出されます。誰も投票せず誰も提案しない場合、期限切れの提案は永遠に進行されません——キューは永遠にスタックします。

これは意図的なものです(遅延評価パターン):キーパーボットや外部cronは不要で、次のユーザー操作を待つだけです。

4.3 removeVotes の提案スコープ

removeVotes(from)activeProposalId のみを操作します:

  • 過去の提案の投票は影響を受けません(変更不可)
  • トークン移転時、送信者のアクティブ提案における投票が削除されます
  • ただし、ユーザーがトークンを保有し、投票し、その後売却した場合(同じアドレス)、新しい保有者は再度投票できません(hasVoted[activeId][buyer] は依然として false であるため)

正しい理解: removeVotesDecentralizedResistanceToken._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 VotingL2-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 統合トークン _updategovernance.removeVotes(from) を呼び出し
OZ Governor との違いタイムロックなし、定足数なし、calldata 実行なし

Built with AiAda