🗿

【東京大学ブロックチェーン公開講座第10週】DAOの簡易実装

2024/06/19に公開

本記事は、2024年東京大学ブロックチェーン公開講座の10つ目のレポートです。

【東京大学ブロックチェーン公開講座第1週】ブロックチェーン概論
【東京大学ブロックチェーン公開講座第2週】ビットコイン①
【東京大学ブロックチェーン公開講座第3週】ビットコイン②
【東京大学ブロックチェーン公開講座第4週】ビットコイン③
【東京大学ブロックチェーン公開講座第5週】イーサリアム①
【東京大学ブロックチェーン公開講座第6週】イーサリアム②
【東京大学ブロックチェーン公開講座第7週】イーサリアム③
【東京大学ブロックチェーン公開講座第8週】イーサリアム④
【東京大学ブロックチェーン公開講座第9週】Solidity開発の概要
【東京大学ブロックチェーン公開講座第10週】DAOの簡易実装(本記事)

今回はAIツールを利用しながらスマコンを実装する回だったので、成果物とその過程を記載します。

作るもの

簡易的なDAOとして、以下の要件を満たすスマートコントラクトを実装します。

  • グローバルステートとして数値カウンターをメンバー間で共有することでDAOを表現する
  • DAOのメンバーはカウンターのインクリメントを提案できる
  • 提案に対し、DAOのメンバーは投票できる
  • 投票結果を集計し、多数決で可決・否決を判定する
  • 可決された場合、カウンターの値をインクリメントする

利用ツールは以下。

  • スマートコントラクト開発環境: Foundry
  • フレームワーク: Meta Contact
  • 実装サポート: Meta Contract GPTs
  • 設計ツール: eraser.io

テンプレート作成

$ forge init <project name> -t metacontract/template

設計

eraserに雑なプロンプトを投げて、UMLを書いてもらいます。

ER図

プロンプト
以下のようなデータが必要である。ER図を描け。
グローバルステート
- (提案と投票によって増加する)数値
- 提案
    - 提案者
    - 投票権保有者
    - 提案に対する投票状況があり
    - 提案に対する投票結果がある
    - 提案開催時刻
- 提案1つあたりの開催期間
- Tallyの実行頻度(秒)

アウトプットはこんな感じ。めちゃくちゃ適当なプロンプトでそれっぽい感じのアウトプットがでてきてすごい!ただ、盛大に間違ってるので手で直します。

以下の方針で修正しました。

  • DAOの参加者が投票権を持つはずなので、投票権保有者はProposalではなくGlobal Stateのフィールドとする
  • DAOに対して複数のProposalがあるはずなので、Global Stateにproposalsフィールドを追加
  • durationはDAO共通という前提でGlobal Stateにもつ
  • Ethereumの世界ではウォレットアドレス=アイデンティティなので、Userといったテーブルは不要

状態遷移図

プロンプト
以下の状態に遷移するものとする
- 初期状態(何も提案されていない状態)
- 提案が提出された状態
- 提案に対して投票が行われた状態
- 投票が可決し、実行可能になった状態
ER図のproposal_duration_seconds とtally_frequency_seconds は、状態遷移に
とって重要な情報です。tally_frequency_seconds は proposal_duration_seconds よりもはるかに短いため、提案が終了するまでに複数回のラウンドが行われることになります。

AIがいい感じに不足を補ってくれました(投票されずに投票期限を迎えるケース)。軽く修正を入れ、以下の形としました。

アクティビティ図

プロンプト
以下の関数を有するものとする
- 提案(可決すると数値インクリメントする)
- 投票(yes/noで)
- 集計(多数決)
- 実行(数値インクリメントの状態遷移を実行)
ER図と状態遷移図を参考にしてアクティビティ図を作成してください。


これまたわりといい感じ。何度か試したんですが、インプットが整理されていればかなり精度よいアウトプットを出してくれます。

更新版がこちら。ほぼ修正なしです。

実装

MetaContractのGPTs(非公開)に雑に投げる。順序としてはSchema→Storage→Functionがやりやすそう。

Schema

プロンプト
このER図と状態遷移図とアクティビティ図のMermaid DSLを参考に、meta contractのSchema.sol を生成してください。(上記の図を添付)
初期アウトプット

functionは要らなそうですが、だいたい合ってそうです。

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

library Schema {
    // Global State
    struct GlobalState {
        string id;
        int value;
        Proposal[] proposals;
        string[] members;
        uint proposalDurationSeconds;
        uint tallyFrequencySeconds;
    }

    // Proposals
    struct Proposal {
        string id;
        string proposerAddress;
        string votingStatus;
        string votingResult;
        uint startTime;
        Vote[] votes;
    }

    // Votes
    struct Vote {
        string id;
        string proposalId;
        string voterAddress;
        bool voteValue;
    }
    
    // Function to add a new proposal
    function addProposal(GlobalState storage state, Proposal memory proposal) internal {
        state.proposals.push(proposal);
    }

    // Function to add a vote to a proposal
    function addVote(Proposal storage proposal, Vote memory vote) internal {
        proposal.votes.push(vote);
    }
    
    // Function to add a new member
    function addMember(GlobalState storage state, string memory member) internal {
        state.members.push(member);
    }
}

単純に、利用するデータ構造を定義しているだけですね。ちょっと手直しして、以下の形にしました。
@custom:storage-locationについては、後述するStorageで利用するっぽいのでつけておきました。

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

library Schema {
    // Global State
    /// @custom:storage-location erc7201:firstDAO.GlobalState
    struct GlobalState {
        string id;
        int value;
        Proposal[] proposals;
        address[] members;
        uint proposalDurationSeconds;
        uint tallyFrequencySeconds;
    }

    // Proposals
    struct Proposal {
        string id;
        address proposerAddress;
        string votingStatus;
        string votingResult;
        uint startTime;
        Vote[] votes;
    }

    // Votes
    struct Vote {
        string id;
        string proposalId;
        address voterAddress;
        bool voteValue;
    }
}

Storage

関数からデータにアクセスするときにStorageを利用する。Upgradeabilityを実装したスマコンにおいて、更新前後でストレージ(スロット)の衝突が発生しないように、ランダムな場所にデータを格納して現実的に衝突しないようにする(ERC7201)。

以下のスクリプトでランダムなスロットを取得。

$ sh calc-storage-location.sh firstDAO.GlobalState
0x520053d0dbd829a99ee1a9bb0df20c648e4dd1af4d0bc8d0c8d2eec4599ebf00

これを使って、Storage.solは以下のようにしました。

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

import {Schema} from "./Schema.sol";

library Storage {
    function GlobalState() internal pure returns(Schema.GlobalState storage ref) {
        assembly { ref.slot := 0x520053d0dbd829a99ee1a9bb0df20c648e4dd1af4d0bc8d0c8d2eec4599ebf00 }
    }
}

これでFunctionからStorage.GlobalState().idのようにアクセスできるようになるようです。

Functions

プロンプト
src/functions/MemberAdd.sol をメタコントラクトとして作ってください。
メタコントラクトとしてonlyOwnerをMemberAdd.solに追加してください。
MemberAdd.sol(初期出力)
MemberAdd.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./Schema.sol";

contract MemberAdd {
    address public owner;

    // Define the onlyOwner modifier
    modifier onlyOwner() {
        require(msg.sender == owner, "Not the contract owner");
        _;
    }

    // Event to emit when a new member is added
    event MemberAdded(address member);

    // Initialize the contract and set the owner
    constructor() {
        owner = msg.sender;
    }

    // Function to add a new member
    function addMember(Schema.GlobalState storage state, address member) public onlyOwner {
        state.members.push(member);
        emit MemberAdded(member);
    }
}

ownerというのは、おそらくデフォルトではスマコンをDeployした人になると思います。つまり、関数の実行者に制限をかけるということですね。

onlyOwnerはOpenZeppelinの実装Ownableがありますが、Upgradeabilityがないため(ownerがProxyコントラクトではなく実装コントラクトに保存されてしまう)、コントラクトのなかに定義するそうです。

_;はonlyOwnerを修飾した関数を埋め込む、という処理らしい(全然ぴんとこない)。

手直ししたものがこちら。先ほど定義したStorage.GlobalState()を利用して、memberを追加します。

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

import {Storage} from "../storage/Storage.sol";

contract MemberAdd {
    address public owner;

    // Define the onlyOwner modifier
    modifier onlyOwner() {
        require(msg.sender == owner, "Not the contract owner");
        _;
    }

    // Event to emit when a new member is added
    event MemberAdded(address member);

    // Initialize the contract and set the owner
    constructor() {
        owner = msg.sender;
    }

    // Function to add a new member
    function addMember(address member) public onlyOwner {
        Storage.GlobalState().members.push(member);
        emit MemberAdded(member);
    }
}
プロンプト
- Propose.sol をメタコントラクトとして作ってください。
- Vote.solをメタコントラクトとして作ってください。
- Tally.sol をメタコントラクトとして作ってください。
Propose.sol(初期出力)
Propose.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./Schema.sol";

contract Propose {
    // Event to emit when a new proposal is created
    event ProposalCreated(string id, address proposer);

    // Function to create a new proposal
    function createProposal(
        Schema.GlobalState storage state,
        string memory proposalId,
        address proposerAddress,
        uint startTime
    ) public {
        Schema.Proposal memory newProposal = Schema.Proposal({
            id: proposalId,
            proposerAddress: proposerAddress,
            votingStatus: "Pending",
            votingResult: "None",
            startTime: startTime,
            votes: new Schema.Vote 
        });
        state.proposals.push(newProposal);
        emit ProposalCreated(proposalId, proposerAddress);
    }
}
Vote.sol(初期出力)
Vote.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./Schema.sol";

contract Vote {
    // Event to emit when a vote is cast
    event VoteCast(string proposalId, address voter, bool voteValue);

    // Function to cast a vote
    function castVote(
        Schema.GlobalState storage state,
        string memory proposalId,
        string memory voteId,
        address voterAddress,
        bool voteValue
    ) public {
        for (uint i = 0; i < state.proposals.length; i++) {
            if (keccak256(bytes(state.proposals[i].id)) == keccak256(bytes(proposalId))) {
                Schema.Vote memory newVote = Schema.Vote({
                    id: voteId,
                    proposalId: proposalId,
                    voterAddress: voterAddress,
                    voteValue: voteValue
                });
                state.proposals[i].votes.push(newVote);
                emit VoteCast(proposalId, voterAddress, voteValue);
                break;
            }
        }
    }
}
Tally.sol(初期出力)
Tally.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./Schema.sol";

contract Tally {
    // Event to emit when a proposal is tallied
    event ProposalTallied(string proposalId, string result);

    // Function to tally votes and determine the result of a proposal
    function tallyVotes(Schema.GlobalState storage state, string memory proposalId) public {
        for (uint i = 0; i < state.proposals.length; i++) {
            if (keccak256(bytes(state.proposals[i].id)) == keccak256(bytes(proposalId))) {
                uint yesVotes = 0;
                uint noVotes = 0;
                for (uint j = 0; j < state.proposals[i].votes.length; j++) {
                    if (state.proposals[i].votes[j].voteValue) {
                        yesVotes++;
                    } else {
                        noVotes++;
                    }
                }
                if (yesVotes > noVotes) {
                    state.proposals[i].votingResult = "Accepted";
                } else {
                    state.proposals[i].votingResult = "Rejected";
                }
                state.proposals[i].votingStatus = "Tallied";
                emit ProposalTallied(proposalId, state.proposals[i].votingResult);
                break;
            }
        }
    }
}
プロンプト
Execute.solをメタコントラクトとして作ってください。あるIDを持つProposeが可決され
ていると判断できる時に処理を実行します。(足し算)
Execute.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./Schema.sol";

contract Execute {
    // Event to emit when an execution is completed
    event Executed(string proposalId, int newValue);

    // Function to execute the proposal if it is accepted
    function executeProposal(
        Schema.GlobalState storage state,
        string memory proposalId,
        int addValue
    ) public {
        for (uint i = 0; i < state.proposals.length; i++) {
            if (keccak256(bytes(state.proposals[i].id)) == keccak256(bytes(proposalId)) &&
                keccak256(bytes(state.proposals[i].votingResult)) == keccak256(bytes("Accepted"))) {
                
                state.value += addValue;
                emit Executed(proposalId, state.value);
                break;
            }
        }
    }
}

シンプルにGlobalStateのproposalsをループして探索して、valueを加算しています。Solidityにおいてループ処理を回すのはアンチパターンだった気がしますが、今回はそのあたりは考慮せずに進めてみます。

プロンプト
Increment.solをメタコントラクトとして作ってください。
Increment.solにonlyApproved modifierを実装してください。
Increment.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./Schema.sol";

contract Increment {
    address public owner;
    mapping(address => bool) public approvedAddresses;

    // Define the onlyApproved modifier
    modifier onlyApproved() {
        require(approvedAddresses[msg.sender], "Not an approved address");
        _;
    }

    // Event to emit when an address is approved
    event AddressApproved(address addr);

    // Event to emit when an increment is executed
    event IncrementExecuted(string proposalId, int newValue);

    // Initialize the contract and set the owner
    constructor() {
        owner = msg.sender;
    }

    // Function to approve an address
    function approveAddress(address addr) public {
        require(msg.sender == owner, "Only the owner can approve addresses");
        approvedAddresses[addr] = true;
        emit AddressApproved(addr);
    }

    // Function to execute the increment if the proposal is accepted
    function executeIncrement(
        Schema.GlobalState storage state,
        string memory proposalId,
        int incrementValue
    ) public onlyApproved {
        for (uint i = 0; i < state.proposals.length; i++) {
            if (keccak256(bytes(state.proposals[i].id)) == keccak256(bytes(proposalId)) &&
                keccak256(bytes(state.proposals[i].votingResult)) == keccak256(bytes("Accepted"))) {
                
                state.value += incrementValue;
                emit IncrementExecuted(proposalId, state.value);
                break;
            }
        }
    }
}

mappingというのが出てきました。これはKey-Value型のデータ構造で、TypeScriptにおけるMap, Pythonにおけるdictですね。あるアドレスについて、approveされたか否かを真偽値で保持するようです。

プロンプト
Propose.solが可決時に例えばIncrement.solを実行できるようにコントラクトアドレスを引数に追加してください。
Propose.sol(v2)
Propose.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./Schema.sol";

contract Propose {
    // Event to emit when a new proposal is created
    event ProposalCreated(string id, address proposer, address contractAddress);

    // Function to create a new proposal
    function createProposal(
        Schema.GlobalState storage state,
        string memory proposalId,
        address proposerAddress,
        uint startTime,
        address contractAddress
    ) public {
        Schema.Proposal memory newProposal = Schema.Proposal({
            id: proposalId,
            proposerAddress: proposerAddress,
            votingStatus: "Pending",
            votingResult: "None",
            startTime: startTime,
            votes: new Schema.Vote 
        });
        state.proposals.push(newProposal);
        emit ProposalCreated(proposalId, proposerAddress, contractAddress);
    }
}

追加したcontractAddressを使ってないのでよろしくないですが、あとで修正します。

プロンプト
Execute.solがProposeの引数のコントラクトアドレスを参照するようにしてください。
Propose.sol(v3)
Propose.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./Schema.sol";

contract Propose {
    // Event to emit when a new proposal is created
    event ProposalCreated(string id, address proposer, address contractAddress);

    // Function to create a new proposal
    function createProposal(
        Schema.GlobalState storage state,
        string memory proposalId,
        address proposerAddress,
        uint startTime,
        address contractAddress
    ) public {
        Schema.Proposal memory newProposal = Schema.Proposal({
            id: proposalId,
            proposerAddress: proposerAddress,
            votingStatus: "Pending",
            votingResult: "None",
            startTime: startTime,
            votes: new Schema.Vote 
        });
        state.proposals.push(newProposal);
        emit ProposalCreated(proposalId, proposerAddress, contractAddress);
    }

    // Function to get the contract address associated with a proposal
    function getContractAddress(
        Schema.GlobalState storage state,
        string memory proposalId
    ) public view returns (address) {
        for (uint i = 0; i < state.proposals.length; i++) {
            if (keccak256(bytes(state.proposals[i].id)) == keccak256(bytes(proposalId))) {
                return state.proposals[i].proposerAddress;
            }
        }
        return address(0);
    }
}

これも間違ってるので後で修正します。

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

import "./Schema.sol";
import "./Propose.sol";

contract Execute {
    // Event to emit when an execution is completed
    event Executed(string proposalId, int newValue);

    Propose proposeContract;

    // Initialize the contract with the address of the Propose contract
    constructor(address proposeContractAddress) {
        proposeContract = Propose(proposeContractAddress);
    }

    // Function to execute the proposal if it is accepted
    function executeProposal(
        Schema.GlobalState storage state,
        string memory proposalId,
        int addValue
    ) public {
        for (uint i = 0; i < state.proposals.length; i++) {
            if (keccak256(bytes(state.proposals[i].id)) == keccak256(bytes(proposalId)) &&
                keccak256(bytes(state.proposals[i].votingResult)) == keccak256(bytes("Accepted"))) {
                
                address contractAddress = proposeContract.getContractAddress(state, proposalId);
                if (contractAddress != address(0)) {
                    state.value += addValue;
                    emit Executed(proposalId, state.value);
                    break;
                }
            }
        }
    }
}

雰囲気は出てますが、せっかくProposeから取得したアドレスを使わずに直接GlobalStateを更新してしまっています。要件的には直接GlobalStateを更新してもよさそうですが、以下を踏まえると、ポリモーフィズムがしたい?

  • Propose時に別のコントラクトアドレス(Increment)を指定できるようにした
  • Increment関数を作成した
  • Execute関数ではPropose経由でIncrementを実行する

...ということで、コード修正しつつ色々試したんですが、以下のエラーが解消できませんでした。

Error: 
Compiler run failed:
Error: Unimplemented feature (/solidity/libsolidity/codegen/ArrayUtils.cpp:227):Copying of type struct Schema.Vote memory[] memory to storage not yet supported.
UnimplementedFeatureError: Copying of type struct Schema.Vote memory[] memory to storage not yet supported.

memoryというのは関数内で利用される一時的な値(つまり変数)で、storageは永続的なデータになります(変数を永続化できないなんてことあるの...?)。

以下リンクによると、storageには配列ではなくmappingを持たせろ、ということらしいです。
https://stackoverflow.com/questions/49345903/copying-of-type-struct-memory-memory-to-storage-not-yet-supported

いや、そもそもSchema間違ってるんかーい!!ちゃぶ台返しやんけ。
さすがにもう一度Schemaから設計し直すのは面倒なので、ここで打ち切りました。
https://github.com/mi-373/first-dao/tree/main

テスト

ユニットテストでは、Foundryの自動テストをベースとしてMCTestというライブラリが提供されている。

Hardhat(スマコンの有名なフレームワーク)ではTypeScriptでテストを書く必要があった(型変換が必要)が、MCTestはSolidityで記載する。

Checking Test(自分が知っている仕様の確認)は必要だが、本当に重要なのはExploring Test(今まで知らなかった仕様を発見する)。今まで分からなかった仕様が明らかになり、テストケースとして追加されることに大きな価値がある。

デプロイ

作成したコントラクトをデプロイする流れ(例)

  1. ローカルネットワーク環境でテスト
  2. テストネット環境にデプロイしてテスト
  3. リリース済みや外部サービス連携がある場合はFork環境(メインネットと同じ状態を再現)でテスト
  4. ローンチ前テスト(扱える金額の制限など→Upgradeabilityの必要性)
  5. システムモニタリング

MetaContractにおいては、script/DeployLib.sol, script/Deploy.s.solを実行してデプロイできる。以下のようなAPIが用意されている。

  • mc.init() : 新しいBundleを用意
  • mc.use() : BundleにFunctionを登録する
  • mc.useFacade() : BundleにFacadeを登録する
  • mc.deploy() : Bundle情報をもとにコントラクトをデプロイする

textDAOが参考になる


第10回ここまで

Discussion