ERC721を活用した、全てのロジックとデータをSolidityで管理する方法(前編)

現在のパブリックなEthereum上で複雑なコントラクトを作成・実行しようとするとgas代(手数料)が多くかかってしまうため、壮大なコントラクトを実アプリケーションで使用するのはあまり現実的ではありません。
またgas代が高くつくだけではなく、ちょっと複雑なことをしようとするとすぐにgas limitに引っかかりデプロイすらできないこともあります。

しかしながら最近Loom NetworkであったりEnterprise Ethereumであったりとgas代を気にせずコントラクトを動かせる環境が整ってきており、今後さらにPlasmaや様々オフチェーン・サイドチェーン関連技術の進歩すれば、プライベート/コンソーシアムチェーンとパブリックチェーンがシームレスにつながる世界がやってくると思われます。
そんな世界に備え、とりあえずgasを気にせず全てのロジック、データをsolidityで実装するにはどうすればいいのか考えて実装しましたのでご紹介します。

全てのロジックとデータをSolidityで管理するとは

文字通り、すべてSolidity内に実装します。
必要なデータはすべてコントラクト内のStateに書き込み、必要なロジックは全てfunctionに記述します。
これにより、理論上はコントラクトとフロントエンドさえあればアプリケーションとして稼働するので、DBサーバーもAPIサーバーも不要となります。

問題点

全てをSolidityで実装しようとすると記述量がものすごい増え、一瞬でカオスになります。
そこで少しでもカオスにならないための対策を、4つほどまとめてみました。

  1. OpenZeppelinのERC721の活用
  2. 配列のデータ追加/削除処理の共通化
  3. マスタデータ関連ロジックとその他ロジックの分離
  4. interfaceの活用

対策1. OpenZeppelinのERC721の活用

Solidityでマスタデータを管理しようと思うとそれだけでかなり処理が膨大になるのですが、以下の3つの観点から、ERC721を継承してコントラクトを作成することとしました。
これにより、シンプルかつ便利なマスタデータ管理用コントラクトが作成できるようになります。

  1. データホルダーの管理
  2. データのマイグレーション
  3. 記述量の削減

1. データホルダーの管理

Solidityでデータを管理するとなると、誰が作成したデータなのかをハンドリングする必要が出てきます。
例えばAさんが作成したユーザー情報をBさんが更新できてしまっては困るので、CreateやUpdateはデータのownerのみが実行可能なコントラクトにする必要があります。
そこで、データ1つ1つをNFTとみなし、そのトークンのメタ情報にデータの中身(上記例だとユーザー情報)を定義することで、データを管理する仕組みとしてERC721を活用します。

2. データのマイグレーション

全てのロジック、データをSolidityで管理するとなると、バグが発生する可能性が高くなるでしょう。
そのためUpgradableなコントラクトにしたいところですが、デメリットとしてコントラクトが複雑化したり、そもそもブロックチェーンでやる意味があるのかという話になったりと悩ましいところです。
ERC721を継承していれば、下記ステップでデータ移行が可能なMigratableなコントラクトが実現できます。

  1. 旧コントラクトからデータを抽出する。(コントラクト外のバッチ処理など)
  2. 新コントラクトにて、コントラクト作成がオーナーとなり全データをCreateし直す。
  3. 旧コントラクにてオーナーとなっていたアドレスに対して各データをtransferする。(各データがNFTなので、transferすることで各データのownerが変わる。)

gasのかからないプライベート/コンソーシアムチェーンだからできる手法です。
また、誰でもtransferできてしまうと運用時に困るので、ERC721を直接継承するのではなく、下記のようにコントラクトオーナーのみがtransferできるよう制限をかけたコントラクトを作成し、各マスタデータ管理用のコントラクトはそれを継承するようにした方が良いでしょう。

// MasterDataModule.sol
import 'openzeppelin-solidity/token/ERC721/ERC721Enumerable.sol';

contract MasterDataModule is ERC721Enumerable {
  function transferFrom(
    address from,
    address to,
    uint256 tokenId
  )
    public
    onlyOwner
  {
    super.transferFrom(from, to, tokenId);
  }
}

このfunctionは自分の管理下のデータしかtransferできないので、初回データ移行時以外で勝手にデータの所有者を変更されてしまう心配がないのもポイントです。

3. 記述量の削減

OpenZeppelinのERC721を活用することでマスタデータを管理するロジックの記述量は大幅に削減できるため、バグの発生率もかなり抑えられると思います。
例えばCatを管理するコントラクト(削除なし)であれば、下記の記述だけで、Migratableなマスタデータ管理コントラクトが出来上がります。

// CatMaster.sol
import "./modules/MasterDataModule.sol";

contract CatMaster is MasterDataModule {

    struct Cat { string name; uint256 age;}
    Cat[] public cats;

    function createCat(string name, uint256 age) external {
        Cat memory cat;
        cat.name = name;
        cat.age = age;
        uint256 catId = cats.push(cat) - 1;
        _mint(msg.sender, catId);
    }

    function updateCat(uint256 catId, string name, uint256 age) external {
        require(ownerOf(catId) == msg.sender);
        Cat storage cat = cats[catId];
        cat.name = name;
        cat.age = age;
    }

    function getCat(uint256 catId) external view returns(uint256, string, uint256, address) {
        require(_exists(catId));
        Cat memory cat = cats[catId];
        return (catId, cat.name, cat.age, ownerOf(catId));
    }
}

対策2. 配列のデータ追加/削除処理の共通化

solidityでは、配列内の任意の要素を空にすることはできますが、要素自体を削除することは基本的にできません。
そのため、配列の中の特定の要素を削除するには、下記のように配列の一番最後の値を削除したい場所に代入し、配列を-1して短くするというロジックが必要になってしまいます。
(そのため前述のCat管理コントラクトでは削除のロジックを抜いていました。)

実施に配列内の任意のidを削除しようなると、以下のようなロジックなります。

uint256[] private targetValues;
mapping(uint256 => uint256) private targetValuesIndex;
mapping(uint256 => bool) private registrationStatus;

function delete(uint256 _catId) public {
    require(exists(_keyId, _catId));

    uint256 targetIndex = targetValuesIndex[_catId];
    uint256 lastIndex = targetValues.length - 1;
    uint256 lastTargetId = targetValues[lastIndex];

    registrationStatus[_catId] = false;
    targetValues[targetIndex] = lastTargetId;
    targetValues.length--;

    targetValuesIndex[lastTargetId] = targetIndex;
    targetValuesIndex[_catId] = 0;
}

この処理がコントラクトの中に入り込むとそれだけで他の処理と混ざりカオスになるので、配列管理コントラクトとして独立させ、そのインスタンスを利用することでシンプル化します。

// LinkedIdList.sol
contract LinkedIdList {
    mapping(uint256 => uint256[]) private keyTargetValues;
    mapping(uint256 => mapping(uint256 => uint256)) private keyTargetValuesIndex;
    mapping(uint256 => mapping(uint256 => bool)) private registrationStatus;

    function add(uint256 _keyId, uint256 _targetId) public {
        require(!exists(_keyId, _targetId));

        keyTargetValuesIndex[_keyId][_targetId] = keyTargetValues[_keyId].push(_targetId) - 1;
        registrationStatus[_keyId][_targetId] = true;
    }

    function remove(uint256 _keyId, uint256 _targetId) public {
        require(exists(_keyId, _targetId));

        uint256 targetIndex = keyTargetValuesIndex[_keyId][_targetId];
        uint256 lastIndex = keyTargetValues[_keyId].length - 1;
        uint256 lastTargetId = keyTargetValues[_keyId][lastIndex];

        registrationStatus[_keyId][_targetId] = false;
        keyTargetValues[_keyId][targetIndex] = lastTargetId;
        keyTargetValues[_keyId].length--;

        keyTargetValuesIndex[_keyId][lastTargetId] = targetIndex;
        keyTargetValuesIndex[_keyId][_targetId] = 0;
    }

    function valueOf(uint256 _keyId, uint256 _index) external view returns (uint256) {
        return keyTargetValues[_keyId][_index];
    }

    function valuesOf(uint256 _keyId) external view returns (uint256[]) {
        return keyTargetValues[_keyId];
    }

    function totalOf(uint256 _keyId) external view returns (uint256) {
        return keyTargetValues[_keyId].length;
    }

    function exists(uint256 _keyId, uint256 _targetId) public view returns (bool) {
        return registrationStatus[_keyId][_targetId];
    }
}
// HogeContract.sol
import "./utils/LinkedIdList.sol";

contract HogeContract {
    LinkedIdList private idList;

    constructor() public {
        idList = new LinkedIdList();
    }
}

ただし、この実装方法の欠点としては、配列で管理したいデータの型ごとに上記の配列管理コントラクトが必要となるため、string、uint256、各構造体(struct)等々それぞれの別のコントラクトができてしまうという点があるので、使いどころは考えたほうが良いでしょう。

対策3. マスタデータ関連ロジックとその他ロジックの分離

対策1、2によってデータの管理部分はかなりスッキリしますが、結局データ管理以外のロジックが複雑だとそれだけでコントラクトがカオスになるので、マスタデータ関連ロジックとその他ロジックの分離するのがおすすめです。
例えば前述のCatMasterコントラクトにオーナーのみ実行できるmeowというfunctionを追加する場合、下記のよう別途コントラクトを用意します。
その際に、constructorにデプロイ済みのCatMasterコントラクトのアドレスを渡すことで、データを参照できるようにしています。

// CatAction.sol
import "./CatMaster.sol";

contract CatAction {
    CatMaster public cat;

    constructor(address catMasterAddr) public {
        require(catMasterAddr != address(0));
        cat = CatMaster(catMasterAddr);
    }

    function meow(uint256 catId) public pure returns (string) {
        require(cat.ownerOf(catId) != address(0));
        return "meow!!";
    }
}

対策4. interfaceの活用

最後に、これはおまけみたいなものですが、処理が増えてくるとEventも多くなりなんとなく見通しが悪くなるので、Eventは全てinterfaceに定義しておくのがおすすめです。

前述のCatMasterにてEventを定義するとしたら、下記のようなinterfaceを定義します。

// ICatMaster.sol
interface ICatMaster {
    event CreateCat(address owner, uint256 catId);
    event UpdateCat(address owner, uint256 catId);

    function createCat(string name, uint256 age) external;
    function updateCat(uint256 catId, string name, uint256 age) external;
    function getCat(uint256 catId) external view returns(uint256, string, uint256, address);
}

まとめ

全てのロジックとデータをSolidityで管理すること自体がそもそも今後どの程度意味あるのは分かりませんが、工夫をすることでかなり記述量を減らすことができました。
今回は主にデータ管理の部分に関する内容だったので、次回はロジックに関して紹介できればと思います。


Qiitaでも書いています
ERC721を活用した、全てのロジックとデータをSolidityで管理する方法(前編)

The following two tabs change content below.
Akihiro Tanaka

Akihiro Tanaka

Smart Contract Engineer
Since 2009, I have been a software engineer at Accenture for 9 years, managing, designing, and developing many services, mainly web and mobile apps.
In 2013, I met Bitcoin and started to work on blockchain-related development in 2018, developing an entertainment DApp for underground idols, a blockchain analysis tool, and an STO platform.
Currently, I am working as a Smart Contract Engineer at Secured Finance, developing a DeFi product.

WEB: https://tanakas.org/