フルオンチェーンNFTの幅を広げる「EthFS: Ethereum File System」の概要や使い方について解説

こんにちは、フルオンチェーンNFTクリエイターのnawooです。

本記事では、Ethereumチェーンに簡単にファイルを保存できる EthFS: Ethereum File System について解説します。

nawoo

さて、EthFSでは次のようなことができます。

  • 公式サイトから簡単にファイルをアップロード
    • SSTORE2を使っているため、ストレージに保存するのに比べて、ガス代を節約できます
    • ファイルの種類は、画像でも、テキストファイルでも、JavaScriptでもなんでもOK
    • アップロードサイズの制限はなく、300KB近いファイルもアップロードされています
  • アップロードされたファイルは誰でも自由に利用可能
    • 公式サイトのFile Explorerでファイルの一覧や詳細を確認できます
    • コントラクトから簡単にファイルを読み込むことができます

作者は、MUDの開発メンバーでもあるfrolic氏です。

出典: ethfs.xyz

すでにThree.jsやp5.jsなどのライブラリがアップロードされているので、ROSESのようなフルオンチェーンNFTを作成するときに使うことができます。

本記事では、そんなEthFSの使い方や、EthFSの本体であるContentStoreFileStoreという2つのコントラクトの中身、そして実際にEthFSを使ってフルオンチェーンNFTを作成する方法を解説します。

でははじめに、この記事の構成について説明します。

STEP
EthFSの使い方

まずは、EthFSの使用方法について、それぞれ項目ごとに分割して解説します。

STEP
コントラクトについて解説

続いて、EthFSの本体であるContentStoreFileStoreという2つのコントラクトについて解説します。

STEP
EthFSを使ってNFTを作ってみた

最後に、EthFSを使ったフルオンチェーンNFTの作成方法について、実際の流れを見ながら解説していきます。

本記事が、EthFSの概要やその使い方、フルオンチェーンNFTの構築可能性などについて理解したいと思われている方にとって、少しでもお役に立てれば幸いです。

※本記事は一般的な情報提供を目的としたものであり、法的または投資上のアドバイスとして解釈されることを意図したものではなく、また解釈されるべきではありません。ゆえに、特定のFT/NFTの購入を推奨するものではございませんので、あくまで勉強の一環としてご活用ください。

イーサリアムnaviの活動をサポートしたい方は、「定期購読プラン」をご利用ください。

目次

EthFSの使い方

ファイルのアップロード

公式サイトから簡単にファイルをアップロードできます。

nawoo

今回は、実際にテストネット版を使ってファイルをアップロードしてみました。

最初に、Connect Walletした後、File Uploaderウィンドウの中央にあるボタンをクリックします。

続いて、アップロードしたいファイルを選択します。今回は画像ファイルの「earth.jpg」を使用します。

注意点として、現在は公式サイトからアップロードする場合、ファイルが必ずBase64エンコードされることになります。

Base64エンコードせずにファイルをアップロードしたい場合は、直接コントラクトを扱う必要があります。

nawoo

その方法については後で説明します。

frolic氏によると、近い将来、アップロード画面にBase64エンコードするかどうかを選択できるチェックボックスが追加される予定だそうです。

画像ファイル「earth.jpg」のサイズは61KBですが、Base64エンコードすると81KBになっていることがわかります。ファイルのライセンス情報を記入し、同意事項にチェックを入れてから、アップロードボタンをクリックします。

するとMetaMaskが起動するので、何度かConfirmをクリックします。今回の例では、81KBのファイルを4つに分割してアップロードし、最後にそれらをまとめてファイルを作成するため、合計5つのトランザクションが発行されます。

nawoo

後で説明しますが、これはContentStoreコントラクトのaddContent関数を4回実行し、FileStoreコントラクトのcreateFile関数を実行するためのトランザクションであることを意味しています。

しばらく待機して、右下に「FileCreated!」と表示されたら完了です。

File Exploreウィンドウに、earth.jpg が追加されました。

File Explorerウィンドウの「earth.jpg」ファイルをダブルクリックすると、画像と詳細情報が表示されます。

バウンティ(bounty)について

公式サイトの右下にFile Bountiesというアイコンがあります。クリックすると、次のような画面になりました。

出典: ethfs.xyz/bounties

p5.jsとThree.jsは、メインネットに「バウンティ」という仕組みでアップロードされました。

この仕組みは、大きなファイルを分割し、多くの人がアップロードすることで、ガス代を分散して負担することを意図しています。

nawoo

私もThree.js(r147)のバウンティに参加し、1つのアップロードをしました。報酬は特にありませんが、貢献したことがトランザクションとして記録されています。

今後もバウンティは継続されると考えられるため、関心のある方は@frolic氏をフォローしておくことをおすすめします。

ファイルの読み込み

アップロードしたファイルを利用する場合は、FileStoreコントラクトを使います。 ファイル名を指定してgetFile(filename).read()とします。

例えば、Three.jsを使ったフルオンチェーンNFTを作成する場合、Three.jsを読み込むコードは次のようになります。

// Three.js(r147)を読み込む例
fileStore.getFile("three-v0.147.0.min.js.gz").read()

このThree.jsは、gzip圧縮されてBase64エンコードされた状態で提供されています。そのためThree.jsを使用するには、それを復元するためのコードが必要です。しかし、そのコードはすでにEthFSにアップロードされています。

nawoo

詳しくは次のROSESの例を見ていきましょう。

ROSESの例

EthFSのリポジトリ(GitHub)には、EthFSを使用してROSESを再現した例が公開されています。

元のROSESのコードと比較してみると、かなり簡単になっていることがわかります。 EthFSから3つのファイルを読み込んで、string.concatで結合しているだけです。

  • three.min.js.gz → gzip圧縮されたThree.js (テストネットにアップロードされたものです)
  • gunzipScripts → gzip展開のためのJSコード
  • rose.js → ROSESのメインコード
contract RoseExample is ERC721 {
    IFileStore public immutable fileStore;

    // ...中略...

    function tokenURI(uint256 tokenId)
        public
        view
        virtual
        override
        returns (string memory)
    {
        return string.concat(
            "data:application/json,%7B%22name%22%3A%22Example%20Rose%22%2C%22animation_url%22%3A%22data%3Atext%2Fhtml%2C%253Cscript%250A%2520%2520type%253D%2522text%252Fjavascript%252Bgzip%2522%250A%2520%2520src%253D%2522data%253Atext%252Fjavascript%253Bbase64%252C",
            fileStore.getFile("three.min.js.gz").read(), // EthFSからthree.jsを読み込む
            "%2522%250A%253E%253C%252Fscript%253E%250A%253Cscript%2520src%253D%2522data%253Atext%252Fjavascript%253Bbase64%252C",
            fileStore.getFile("gunzipScripts.js").read(), // EthFSからgzip展開用のJavaScriptを読み込む
            "%2522%253E%253C%252Fscript%253E%250A%250A%253Cscript%253E%250A%2520%2520var%2520tokenId%2520%253D%2520",
            toString(tokenId),
            "%253B%250A%253C%252Fscript%253E%250A%253Cstyle%253E%250A%2520%2520*%2520%257B%250A%2520%2520%2520%2520margin%253A%25200%253B%250A%2520%2520%2520%2520padding%253A%25200%253B%250A%2520%2520%257D%250A%2520%2520canvas%2520%257B%250A%2520%2520%2520%2520width%253A%2520100%2525%253B%250A%2520%2520%2520%2520height%253A%2520100%2525%253B%250A%2520%2520%257D%250A%253C%252Fstyle%253E%250A%253Cscript%2520src%253D%2522data%253Atext%252Fjavascript%253Bbase64%252C",
            fileStore.getFile("rose.js").read(), // EthFSからROSESのメインコードを読み込む
            "%2522%253E%253C%252Fscript%253E%250A%22%7D"
        );
    }
}

string.concatの部分はURLエンコードされていて読みづらいため、デコードしたものがこちらです。

'data:application/json,{"name":"Example Rose","animation_url":"data:text/html,<script type="text/javascript+gzip" src="data:text/javascript;base64,"',
fileStore.getFile("three.min.js.gz").read(), // EthFSからthree.jsを読み込む
'"></script><script src="data:text/javascript;base64,',
fileStore.getFile("gunzipScripts.js").read(), // EthFSからgzip展開用のJavaScriptを読み込む
'"></script><script>var tokenId = ',
toString(tokenId),
';</script><style>* { margin: 0; padding: 0; } canvas { width: 100%; height: 100%; }</style><script src="data:text/javascript;base64,',
fileStore.getFile("rose.js").read(), // EthFSからROSESのメインコードを読み込む
'"></script>"}'

type="text/javascript+gzip"が指定されているscriptタグは、 gunzipScripts.jsによってgzip展開されます。

コントラクトについて解説

EthFSの本体は、ContentStoreFileStoreという2つのコントラクトです。

EthFSでは、SSTORE2(データコントラクト)を使っていますが、データコントラクトでは24KBのサイズ制限があるため、ファイルを24KB以下に分割してからアップロードします。

分割した各パーツをアップロードするのがContentStore、それを1ファイルとしてまとめているのがFileStoreです。

SSTORE2について

nawoo

SSTORE2については以下の記事で紹介しましたが、簡単におさらいしておきます。

  • データコントラクトとして保存するため、ストレージへの保存に比べてガス節約できる
  • 最大サイズは24,575バイト
  • データの保存はwrite関数を使い、戻り値としてデータコントラクトのアドレス(pointer)が返る
  • データの読み込みはread関数を使う

ファイルのアップロード

nawoo

ファイルをアップロードする際に、2つのコントラクトがどのように使用されているか見てみましょう。

SSTORE2で保存できるデータの最大サイズは24,575バイトであり、それ以上のサイズのファイルをアップロードする場合は、ファイルを事前に分割する必要があります。

ファイルの分割はコントラクトではなく、ローカルPCやフロントエンド側で行います。

addContent関数

分割した各ファイルパーツ(chunk)を、ContentStoreコントラクトのaddContent関数でアップロードします。

addContent関数では、bytes型の引数(content)を受け取って、keccak256関数でハッシュ値を計算してチェックサムとしています。

nawoo

チェックサムがすでに登録済みであれば、アップロードせずに終了し、未登録であれば、SSTORE2でデータコントラクトに保存します。

SSTORE2.writeの戻り値はデータコントラクトのアドレス(pointer)なので、チェックサムとSSTORE2のpointerをmappingに保存します。

mapping(bytes32 => address) public pointers; // checksum => SSTORE2のpointer の mapping

function addContent(bytes memory content) public returns (bytes32 checksum, address pointer) {
    checksum = keccak256(content); // ハッシュ値を求めてチェックサムとする
    if (pointers[checksum] != address(0)) {
        return (checksum, pointers[checksum]); // すでにハッシュ値が登録済みであればアップロードしない
    }
    pointer = SSTORE2.write(content); // SSTORE2でデータを保存する
    pointers[checksum] = pointer; // mappingにチェックサムとSSTORE2のpointerを保存する
    emit NewChecksum(checksum, content.length);
    return (checksum, pointer);
}

全てのファイルのパーツ(chunk)をaddContentで保存し、戻り値のチェックサムを記録しておきます。このチェックサムは、次のステップで使用します。

createFile関数

次に、FileStoreコントラクトのcreateFile関数で、ファイル情報を作成します。

nawoo

引数は、ファイル名・ファイルパーツのチェックサムの配列・ファイルのメタデータ(extraData)になります。

mapping(string => bytes32) public files; // ファイル名 => File構造体のchecksum の mapping

function createFile(
    string memory filename, // ファイル名
    bytes32[] memory checksums, // 各chunkのチェックサム (ContentStoreにアップロードしたもの)
    bytes memory extraData // ファイルのメタデータ(メディアタイプやライセンス)
) public returns (File memory file) {
    if (files[filename] != bytes32(0)) { // ファイル名が登録済みでないか確認
        revert FilenameExists(filename);
    }
    return _createFile(filename, checksums, extraData);
}
  • createFile関数:
    • ファイル名が登録済みでないことを確認して_createFile関数を呼び出す
  •  _createFile関数:
    • ファイルのサイズを計算して、File構造体(struct)を作成する
function _createFile(
    string memory filename,
    bytes32[] memory checksums,
    bytes memory extraData
) private returns (File memory file) {
    Content[] memory contents = new Content[](checksums.length);
    // ファイルサイズを計算
    uint256 size = 0;
    for (uint256 i = 0; i < checksums.length; ++i) {
        size += contentStore.contentLength(checksums[i]);
        contents[i] = Content({checksum: checksums[i], pointer: contentStore.getPointer(checksums[i])});
    }
    if (size == 0) {
        revert EmptyFile();
    }
    // File構造体を作成してContentStoreに登録する
    file = File({size: size, contents: contents});
    (bytes32 checksum, ) = contentStore.addContent(abi.encode(file));
    // ファイル名とFile構造体のチェックサムをmappingに保存
    files[filename] = checksum;
    // FileCreatedイベントを発行
    emit FileCreated(filename, checksum, filename, file.size, extraData);
}

File構造体(struct)は、以下のように定義されています。

struct Content {
    bytes32 checksum; // chunkのチェックサム
    address pointer; // データコントラクトのアドレス
}
struct File {
    uint256 size; // ファイルサイズ
    Content[] contents; // Contentの配列
}

File構造体を再びContentStoreaddContent関数でアップロードした後、 ファイル名とFile構造体のチェックサムをmappingに保存します。

createFileの第3引数で渡されるファイルのメタデータ(extraData)は、JSON形式で、メディアタイプやライセンス情報などが含まれます。

nawoo

例えば、以下のようなものがあります。

// 例: earth.jpgのメタデータ
{
   "type": "image/jpeg",
   "encoding": "base64",
   "license": "CC0"
}

面白いことに、このextraDataは、データコントラクトにもストレージにも保存されていません。 FileCreatedイベント発行のときに使われているだけです。

そのため、フロントエンドなどでファイルのメタデータを取得する場合は、イベントログから取得する必要があります。重要度の低いデータは、ガスを節約するためにイベントログに記録することで十分であるという判断がされているのでしょう。

ファイルの読み込み

コントラクトからファイルを読み込むには、getFile関数とread関数を使用し、ファイル名を指定する必要があります。

fileStore.getFile("three.min.js.gz").read()

getFile関数

FileStoreコントラクトのgetFile関数を見てみましょう。

nawoo

関数の戻り値はFile型です。 ファイル名からチェックサムを取得し、チェックサムからFile構造体を読み込んでいます。

function getFile(string memory filename) public view returns (File memory file) {
    bytes32 checksum = files[filename]; // ファイル名からチェックサムを取得
    if (checksum == bytes32(0)) {
        revert FileNotFound(filename); // ファイル名が未登録ならエラー
    }
    address pointer = contentStore.pointers(checksum); // チェックサムからSSTORE2のpointerを取得
    if (pointer == address(0)) {
        revert FileNotFound(filename); // チェックサムが未登録ならエラー
    }
    return abi.decode(SSTORE2.read(pointer), (File)); // SSTORE2からFile構造体を読み込む
}

read関数

File構造体のread関数は、File.solで定義されています。

SSTORE2を使用して保存したファイルの各パーツを読み込み、結合しています。この時、SSTORE2.read関数を使わずに、Inline Assemblyを使用して直接メモリ領域に書き込んでいます。

function read(File memory file) view returns (string memory contents) {
    // ファイルの各パーツ(chunk)の配列を取得
    Content[] memory chunks = file.contents;
    assembly {
        let len := mload(chunks) // ヘッダに格納されている配列の長さを取得
        let totalSize := 0x20 // ヘッダの32バイトを最初に追加
        contents := mload(0x40) // free memory pointerから読み込み
        let size
        let chunk
        let pointer

        // chunkの数だけループ
        for { let i := 0 } lt(i, len) { i := add(i, 1) } {
            // i番目のchunk(=Content構造体)を取得
            chunk := mload(add(chunks, add(0x20, mul(i, 0x20))))
            // Content構造体の2番目の要素(pointer)を読み込む
            pointer := mload(add(chunk, 0x20))
            // chunkデータのサイズを取得
            size := sub(extcodesize(pointer), 1)
            // データコントラクトからchunkデータをコピー
            extcodecopy(pointer, add(contents, totalSize), 1, size)
            // トータルサイズを更新
            totalSize := add(totalSize, size)
        }
        // 戻り値(contents)のヘッダにトータルサイズを保存
        mstore(contents, sub(totalSize, 0x20)) 
        // free memory pointerを更新
        mstore(0x40, add(contents, and(add(totalSize, 0x1f), not(0x1f))))
    }
}
nawoo

このコード、どこかで見たことありませんか?実は、以下の記事で紹介したRollerCoasterのgetLibrary関数で使われた手法です。


最後に次章では、EthFSを使ったフルオンチェーンNFTの作成方法を中心に、実際の流れを見ながら「定期購読プラン」登録者向けに解説しています。ご興味あればご覧ください。

EthFSを使ってNFTを作ってみた


この続き: 1,366文字 / 画像4枚

この続きは、 定期購読プランメンバー専用です。
Already a member? ここでログイン

考察とまとめ

本記事では、EthFSの使い方や、EthFSの本体であるContentStoreとFileStoreという2つのコントラクトの中身、そして実際にEthFSを使ってフルオンチェーンNFTを作成する方法について解説しました 。

本記事が、EthFSの概要やその使い方、フルオンチェーンNFTの構築可能性などについて理解したいと思われている方にとって、少しでもお役に立ったのであれば幸いです。

また励みになりますので、参考になったという方はぜひTwitterでのシェア・コメントなどしていただけると嬉しいです。

nawoo

今回、実際にEthFSを使ってみて、これはかなり便利だなと感じました。

Three.jsやp5.jsを誰でも簡単に利用できるという点では、dom氏が発言していた「オンチェーン版のnpm」という使い方もできますし、IPFSの代わりにNFTの画像ファイルを保存する場所としても利用できます。

ただ、いくつか気になった点もあるため、以下に列挙します。

  • 公式サイトからのアップロードではBase64エンコードされてしまう
    • 用途によってはBase64エンコードは不要なケースもあると思うので、選択できるようにしてほしいところ
    • 作者のfrolic氏によると、近いうちにBase64エンコードするかどうかのチェックボックスを追加する予定とのこと
  • 誰でも自由にファイル名をつけられるので、早いもの勝ちになってしまう(名前空間の問題)
    • 誰かが1.jpgというファイル名でアップロードすると、他の人が同じファイル名をつけることはできない
    • 誰でも自由にファイルをアップロードできるようになっているため、おそらく近いうちにファイル名のバッティングの問題が発生すると思われる
    • これについては、GitHubのissueにも書かれているので、何らかの対策が取られるのではないかと期待している
      • IPFSのようにハッシュ値でアクセスできるようにするのも一案かもしれません。
  • FileStoreコントラクトにdeleteFile関数が存在する
    • コントラクトオーナーはdeleteFile関数でファイルを削除することができる
      • onlyOwner修飾子がついている
    • そのため、ファイルの永続性という点では少し不安も残る
    • もし将来、EthFSからThree.jsが削除されてしまうと、それを使っているNFTはすべて動かなくなってしまう
    • 一方で、違法なファイルがアップロードされた場合など、コントラクトオーナーによる削除は必要になるかもしれず、難しいところ
    • ただし、ファイルを削除するといっても、FileStoreコントラクトの「ファイル名=>File構造体のチェックサム のmapping」が削除されるだけで、ファイルのデータそのものが削除されるわけではない
      • ファイルのデータやFile構造体はSSTORE2で保存しているため削除できない
    • ファイルを削除されることが心配であれば、例えば、自分専用にFileStoreコントラクトをデプロイするといった対応も可能と思われる

まだ開発途上の部分もありますが、これからEthFSを使ったプロジェクトはどんどん増えていくのではないでしょうか。 思ってもみなかった利用例も出てくるかもしれません。将来がとても楽しみです。

nawoo

いずれ標準化されてethfs://〜のような書き方ができるようになればいいな〜と妄想してしまいます(笑)

最後に、本記事で作成したコントラクトやスクリプトはこちらのGitHubで公開していますので、ぜひ参考にしてください。

また、地球と月の画像(CC0)は以下のサイトからダウンロードさせていただきました:
https://www.pexels.com/photo/earth-wallpaper-41953/
https://www.pexels.com/photo/photo-of-moon-47367/

イーサリアムnaviを運営するSTILL合同会社では、web3/crypto関連のリサーチ代行、アドバイザー業務、その他(ご依頼・ご提案・ご相談など)に関するお問い合わせを受け付けております。

まずはお気軽に、こちらからご連絡ください。

みんなにも読んでほしいですか?
  • URLをコピーしました!
目次