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 イベント
前提
- Foundry(forge, cast, anvil)がインストール済みであること
- ローカルで anvil が起動していること
anvil --mnemonic "test test test test test test test test test test test junk"
この記事では以下のアカウントを使う。
| 役割 | アドレス | 秘密鍵 |
|---|---|---|
| メンバーA | 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266(Account 0) | 0xac09...ff80 |
| メンバーB | 0x70997970C51812dc3A010C7d01b50e0d17dc79C8(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 = true を transfer の前に設定しているのは、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_mine | anvilで1ブロック採掘 |