こんにちは、フルオンチェーンNFTクリエイターのnawooです。
本記事では、Ethereumチェーンに簡単にファイルを保存できる EthFS: Ethereum File System について解説します。
さて、EthFSでは次のようなことができます。
- 公式サイトから簡単にファイルをアップロード
- SSTORE2を使っているため、ストレージに保存するのに比べて、ガス代を節約できます
- ファイルの種類は、画像でも、テキストファイルでも、JavaScriptでもなんでもOK
- アップロードサイズの制限はなく、300KB近いファイルもアップロードされています
- アップロードされたファイルは誰でも自由に利用可能
- 公式サイトのFile Explorerでファイルの一覧や詳細を確認できます
- コントラクトから簡単にファイルを読み込むことができます
作者は、MUDの開発メンバーでもあるfrolic氏です。
すでにThree.jsやp5.jsなどのライブラリがアップロードされているので、ROSESのようなフルオンチェーンNFTを作成するときに使うことができます。
本記事では、そんなEthFSの使い方や、EthFSの本体であるContentStore
とFileStore
という2つのコントラクトの中身、そして実際にEthFSを使ってフルオンチェーンNFTを作成する方法を解説します。
でははじめに、この記事の構成について説明します。
まずは、EthFSの使用方法について、それぞれ項目ごとに分割して解説します。
続いて、EthFSの本体であるContentStore
とFileStore
という2つのコントラクトについて解説します。
最後に、EthFSを使ったフルオンチェーンNFTの作成方法について、実際の流れを見ながら解説していきます。
本記事が、EthFSの概要やその使い方、フルオンチェーンNFTの構築可能性などについて理解したいと思われている方にとって、少しでもお役に立てれば幸いです。
※本記事は一般的な情報提供を目的としたものであり、法的または投資上のアドバイスとして解釈されることを意図したものではなく、また解釈されるべきではありません。ゆえに、特定のFT/NFTの購入を推奨するものではございませんので、あくまで勉強の一環としてご活用ください。
イーサリアムnaviの活動をサポートしたい方は、「定期購読プラン」をご利用ください。
EthFSの使い方
ファイルのアップロード
公式サイトから簡単にファイルをアップロードできます。
- メインネット版 ethfs.xyz
- テストネット版 goerli.ethfs.xyz
今回は、実際にテストネット版を使ってファイルをアップロードしてみました。
最初に、Connect Walletした後、File Uploaderウィンドウの中央にあるボタンをクリックします。
続いて、アップロードしたいファイルを選択します。今回は画像ファイルの「earth.jpg」を使用します。
注意点として、現在は公式サイトからアップロードする場合、ファイルが必ずBase64エンコードされることになります。
Base64エンコードせずにファイルをアップロードしたい場合は、直接コントラクトを扱う必要があります。
その方法については後で説明します。
画像ファイル「earth.jpg」のサイズは61KBですが、Base64エンコードすると81KBになっていることがわかります。ファイルのライセンス情報を記入し、同意事項にチェックを入れてから、アップロードボタンをクリックします。
するとMetaMaskが起動するので、何度かConfirmをクリックします。今回の例では、81KBのファイルを4つに分割してアップロードし、最後にそれらをまとめてファイルを作成するため、合計5つのトランザクションが発行されます。
後で説明しますが、これはContentStore
コントラクトのaddContent
関数を4回実行し、FileStore
コントラクトのcreateFile
関数を実行するためのトランザクションであることを意味しています。
しばらく待機して、右下に「FileCreated!」と表示されたら完了です。
File Exploreウィンドウに、earth.jpg が追加されました。
File Explorerウィンドウの「earth.jpg」ファイルをダブルクリックすると、画像と詳細情報が表示されます。
バウンティ(bounty)について
公式サイトの右下にFile Bountiesというアイコンがあります。クリックすると、次のような画面になりました。
p5.jsとThree.jsは、メインネットに「バウンティ」という仕組みでアップロードされました。
この仕組みは、大きなファイルを分割し、多くの人がアップロードすることで、ガス代を分散して負担することを意図しています。
私も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にアップロードされています。
詳しくは次のROSESの例を見ていきましょう。
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の本体は、ContentStore
とFileStore
という2つのコントラクトです。
EthFSでは、SSTORE2(データコントラクト)を使っていますが、データコントラクトでは24KBのサイズ制限があるため、ファイルを24KB以下に分割してからアップロードします。
分割した各パーツをアップロードするのがContentStore
、それを1ファイルとしてまとめているのがFileStore
です。
SSTORE2について
SSTORE2については以下の記事で紹介しましたが、簡単におさらいしておきます。
- データコントラクトとして保存するため、ストレージへの保存に比べてガス節約できる
- 最大サイズは24,575バイト
- データの保存は
write
関数を使い、戻り値としてデータコントラクトのアドレス(pointer)が返る - データの読み込みは
read
関数を使う
ファイルのアップロード
SSTORE2で保存できるデータの最大サイズは24,575バイトであり、それ以上のサイズのファイルをアップロードする場合は、ファイルを事前に分割する必要があります。
ファイルの分割はコントラクトではなく、ローカルPCやフロントエンド側で行います。
addContent関数
分割した各ファイルパーツ(chunk)を、ContentStore
コントラクトのaddContent
関数でアップロードします。
addContent
関数では、bytes型の引数(content)を受け取って、keccak256関数でハッシュ値を計算してチェックサムとしています。
チェックサムがすでに登録済みであれば、アップロードせずに終了し、未登録であれば、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
関数で、ファイル情報を作成します。
引数は、ファイル名・ファイルパーツのチェックサムの配列・ファイルのメタデータ(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構造体を再びContentStore
のaddContent
関数でアップロードした後、 ファイル名とFile構造体のチェックサムをmappingに保存します。
createFileの第3引数で渡されるファイルのメタデータ(extraData)は、JSON形式で、メディアタイプやライセンス情報などが含まれます。
例えば、以下のようなものがあります。
// 例: earth.jpgのメタデータ
{
"type": "image/jpeg",
"encoding": "base64",
"license": "CC0"
}
そのため、フロントエンドなどでファイルのメタデータを取得する場合は、イベントログから取得する必要があります。重要度の低いデータは、ガスを節約するためにイベントログに記録することで十分であるという判断がされているのでしょう。
ファイルの読み込み
コントラクトからファイルを読み込むには、getFile
関数とread
関数を使用し、ファイル名を指定する必要があります。
fileStore.getFile("three.min.js.gz").read()
getFile関数
FileStore
コントラクトのgetFile
関数を見てみましょう。
関数の戻り値は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))))
}
}
このコード、どこかで見たことありませんか?実は、以下の記事で紹介したRollerCoasterのgetLibrary
関数で使われた手法です。
EthFSを使ってNFTを作ってみた
この続き: 1,366文字 / 画像4枚
考察とまとめ
本記事では、EthFSの使い方や、EthFSの本体であるContentStoreとFileStoreという2つのコントラクトの中身、そして実際にEthFSを使ってフルオンチェーンNFTを作成する方法について解説しました 。
本記事が、EthFSの概要やその使い方、フルオンチェーンNFTの構築可能性などについて理解したいと思われている方にとって、少しでもお役に立ったのであれば幸いです。
また励みになりますので、参考になったという方はぜひTwitterでのシェア・コメントなどしていただけると嬉しいです。
🧭フルオンチェーンNFTの可能性を広げる「EthFS: Ethereum File System」とは
— イーサリアムnavi🧭 Called “Ethereumnavi” (@ethereumnavi) March 17, 2023
🖼Ethereumチェーンに簡単にファイルを保存できる
🖼公式サイトから簡単にアップロード可能
🖼画像, テキスト, JavaScriptなど全て対応。制限もなく大きなファイルでもOK!
詳しくはこちら👇https://t.co/LzFa7jSIv2
今回、実際に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を使ったプロジェクトはどんどん増えていくのではないでしょうか。 思ってもみなかった利用例も出てくるかもしれません。将来がとても楽しみです。
いずれ標準化されてethfs://〜
のような書き方ができるようになればいいな〜と妄想してしまいます(笑)
最後に、本記事で作成したコントラクトやスクリプトはこちらのGitHubで公開していますので、ぜひ参考にしてください。
また、地球と月の画像(CC0)は以下のサイトからダウンロードさせていただきました:
https://www.pexels.com/photo/earth-wallpaper-41953/
https://www.pexels.com/photo/photo-of-moon-47367/
イーサリアムnaviを運営するSTILL合同会社では、web3/crypto関連のリサーチ代行、アドバイザー業務、その他(ご依頼・ご提案・ご相談など)に関するお問い合わせを受け付けております。
まずはお気軽に、こちらからご連絡ください。
- 法人プランLP:https://ethereumnavi.com/lp/corporate/
- Twitter:@STILL_Corp
- メールアドレス:info@still-llc.com