DAO(分散型自律組織)

カテゴリ
ガバナンス
チェーン
Ethereum
タグ
SolidityFoundryスマートコントラクトDAO

Solidityで最小限のDAOコントラクトを書き、ローカルで動かしてみる。

ETHを出資してメンバーになり、提案を作成し、投票し、可決されたら自動で実行される、という仕組みを作る。


全体の流れ

sequenceDiagram
    actor A as メンバーA
    actor B as メンバーB
    participant DAO as DAO Contract
    participant Target as 送金先

    Note over A, Target: メンバー登録(出資)
    A->>DAO: join() + 2 ETH
    B->>DAO: join() + 1 ETH

    Note over A, Target: 提案の作成
    A->>DAO: propose(送金先, 1 ETH, "開発費")
    DAO-->>A: ProposalCreated イベント

    Note over A, Target: 投票
    A->>DAO: vote(0, true)  [賛成 / 投票力: 2]
    B->>DAO: vote(0, false) [反対 / 投票力: 1]
    DAO-->>A: Voted イベント
    DAO-->>B: Voted イベント

    Note over A, Target: 投票期間終了後に実行
    A->>DAO: execute(0)
    DAO->>DAO: 賛成(2) > 反対(1) を確認
    DAO->>Target: transfer(1 ETH)
    DAO-->>A: Executed イベント

前提

anvil --mnemonic "test test test test test test test test test test test junk"

この記事では以下のアカウントを使う。

役割アドレス秘密鍵
メンバーA0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266(Account 0)0xac09...ff80
メンバーB0x70997970C51812dc3A010C7d01b50e0d17dc79C8(Account 1)0x59c6...5de4
送金先0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC(Account 2)

コントラクト全体

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

contract SimpleDAO {
    struct Proposal {
        address payable target;
        uint256 amount;
        string description;
        uint256 votesFor;
        uint256 votesAgainst;
        uint256 deadline;
        bool executed;
    }

    uint256 public nextProposalId;
    uint256 public votingPeriod;
    mapping(address => uint256) public shares;
    mapping(uint256 => Proposal) public proposals;
    mapping(uint256 => mapping(address => bool)) public hasVoted;
    uint256 public totalShares;

    event Joined(address member, uint256 amount);
    event ProposalCreated(uint256 indexed id, address target, uint256 amount, string description);
    event Voted(uint256 indexed id, address voter, bool support, uint256 weight);
    event Executed(uint256 indexed id, address target, uint256 amount);

shares が各メンバーの出資額(=投票力)を記録する。hasVoted は二重投票の防止に使う。


コンストラクタ

    constructor(uint256 _votingPeriodDays) {
        votingPeriod = _votingPeriodDays * 1 days;
    }

デプロイ時に投票期間を日数で指定する。


メンバー登録(出資)

    function join() external payable {
        require(msg.value > 0, "Must send ETH");
        shares[msg.sender] += msg.value;
        totalShares += msg.value;
        emit Joined(msg.sender, msg.value);
    }

ETHを送ることでメンバーになる。出資額がそのまま投票力になる。追加出資も可能。


提案の作成

    function propose(address payable target, uint256 amount, string calldata description) external returns (uint256) {
        require(shares[msg.sender] > 0, "Not a member");
        require(amount <= address(this).balance, "Insufficient funds");

        uint256 id = nextProposalId++;
        proposals[id] = Proposal({
            target: target,
            amount: amount,
            description: description,
            votesFor: 0,
            votesAgainst: 0,
            deadline: block.timestamp + votingPeriod,
            executed: false
        });
        emit ProposalCreated(id, target, amount, description);
        return id;
    }

メンバーのみが提案を作成できる。提案にはDAOの残高を超える金額を指定できないようにしている。


投票

    function vote(uint256 id, bool support) external {
        require(shares[msg.sender] > 0, "Not a member");
        Proposal storage p = proposals[id];
        require(block.timestamp < p.deadline, "Voting ended");
        require(!hasVoted[id][msg.sender], "Already voted");

        hasVoted[id][msg.sender] = true;
        uint256 weight = shares[msg.sender];

        if (support) {
            p.votesFor += weight;
        } else {
            p.votesAgainst += weight;
        }
        emit Voted(id, msg.sender, support, weight);
    }

投票力は出資額に比例する。2 ETH出資したメンバーは、1 ETH出資したメンバーの2倍の投票力を持つ。hasVoted で同じ提案への二重投票を防いでいる。


提案の実行

    function execute(uint256 id) external {
        Proposal storage p = proposals[id];
        require(block.timestamp >= p.deadline, "Voting not ended");
        require(!p.executed, "Already executed");
        require(p.votesFor > p.votesAgainst, "Not approved");

        p.executed = true;
        p.target.transfer(p.amount);
        emit Executed(id, p.target, p.amount);
    }
}

3つの条件を確認している。

p.executed = truetransfer の前に設定しているのは、Reentrancy(再入攻撃)対策。状態変更を先にしてから外部送金する、というパターンはChecks-Effects-Interactionsと呼ばれる。


デプロイと実行

1. コントラクトをデプロイ

投票期間を1日に設定してデプロイする。

forge create src/SimpleDAO.sol:SimpleDAO \
  --rpc-url http://localhost:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  --broadcast \
  --constructor-args 1
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3

以降、このアドレスを $DAO として使う。

DAO=0x5FbDB2315678afecb367f032d93F642f64180aa3

2. メンバー登録(出資)

メンバーAが2 ETH、メンバーBが1 ETHを出資する。

cast send $DAO "join()" \
  --value 2ether \
  --rpc-url http://localhost:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
cast send $DAO "join()" \
  --value 1ether \
  --rpc-url http://localhost:8545 \
  --private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

DAOの残高を確認する。

cast balance $DAO --rpc-url http://localhost:8545

3. 提案を作成(メンバーA)

Account 2に1 ETHを送る提案を作る。

cast send $DAO \
  "propose(address,uint256,string)" \
  0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC \
  1000000000000000000 \
  "Development fund" \
  --rpc-url http://localhost:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

4. 投票

メンバーAが賛成(投票力: 2)、メンバーBが反対(投票力: 1)。

cast send $DAO "vote(uint256,bool)" 0 true \
  --rpc-url http://localhost:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
cast send $DAO "vote(uint256,bool)" 0 false \
  --rpc-url http://localhost:8545 \
  --private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

5. 時間を進める

投票期間(1日 = 86400秒)を経過させる。

cast rpc evm_increaseTime 86400 --rpc-url http://localhost:8545
cast rpc evm_mine --rpc-url http://localhost:8545

6. 提案を実行

賛成(2) > 反対(1) なので実行できる。

cast send $DAO "execute(uint256)" 0 \
  --rpc-url http://localhost:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

送金先の残高を確認する。

cast balance 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC --rpc-url http://localhost:8545

イベントログの確認

cast logs --from-block 0 --address $DAO --rpc-url http://localhost:8545

Voted イベントだけをフィルタする場合:

cast logs \
  --from-block 0 \
  --address $DAO \
  "Voted(uint256,address,bool,uint256)" \
  --rpc-url http://localhost:8545

使ったコマンド一覧

コマンド説明
anvilローカルEthereumノードを起動
forge create <contract>コントラクトをデプロイ
cast send <to> <sig> <args>トランザクションを送信(状態変更)
cast call <to> <sig> <args>読み取り専用の呼び出し
cast balance <address>ETH残高を取得
cast logsイベントログを取得
cast rpc evm_increaseTime <sec>anvilの時間を進める
cast rpc evm_mineanvilで1ブロック採掘