不可能を可能にする開発テクニック|724KBのJPG画像をフルオンチェーンNFTにする方法と具体的な手順を解説【Solidity】

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

先日、ニュージーランド在住のフォトグラファーShinichi MaruyamaさんのNFT作品 “Light Sculpture” が公開されました。

「Light Sculpture」は永続性をコンセプトにしたNFT作品であり、「水滴の写真をフルオンチェーンNFTにしたもの』です。

出典: opensea.io/collection/lightsculpture
nawoo

このNFTのコントラクト、実は筆者が担当させていただきました。

そしてなんと、50KBのJPG画像をフルオンチェーンにしています。

画像データはストレージに保存していますが、ストレージへの書き込みはガス代が高いため、1枚の写真をアップロードするのに、ガス価格が5gweiの際でも0.3ETHかかりました。

nawoo

余談ですがMaruyamaさんとは、イーサリアムnaviのdiscordで知り合いました。「開発質問チャンネル」で、フルオンチェーンNFTについて質問されたことがきっかけです。

そして、このLightSculptureを公開した後で、「フルオンチェーンで50KBの画像ファイルを扱えるとは知りませんでした」というご感想や、「何KBの画像までフルオンチェーンにできるのですか?」というご質問なども頂戴しました。

このご質問については筆者自身も興味があったので、『いったいどれくらい大きなサイズまでフルオンチェーンにできるのか?』の限界に挑戦してみようと思い立ち、今回の記事を執筆するに至った次第です。

その結果だけを先に申し上げると、なんと724KBのJPG画像をフルオンチェーンにすることに成功しました。

nawoo

それがこちらのNFT「On-Chain Photo」になります。

出典 testnets.opensea.io/ja/collection/onchainphoto

ということで本記事では、このような724KBのJPG画像をどのようにしてフルオンチェーンNFTにすることができたのかについて、解説していきたいと思います。

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

STEP
サイズの大きな画像をフルオンチェーンNFTにするために

まずは、フルオンチェーンNFTにおけるガス節約の考え方と、そのための具体的な手順について解説いたします。

STEP
オフチェーンでの作業

続いて、オフチェーンで画像ファイルからtokenURIを作成するためのスクリプトについて解説いたします。

STEP
おまけ: URLエンコードをやめてみる

最後に、おまけとして『さらにサイズの大きな画像を扱うためのテクニック』を紹介いたします。

本記事が、サイズの大きな画像をフルオンチェーンNFTにする方法や、NFT開発における先進的な知見を得たいと思われている方にとって、少しでもお役に立てれば幸いです。

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

【AD】Nouns DAO JAPAN

Nouns DAO JAPANは世界で一番Nounsを広げるコミュニティを目指します。Discord参加はこちら

目次

サイズの大きな画像をフルオンチェーンNFTにするために

前提:ガス(gas)を節約しよう

フルオンチェーンNFTの開発は、一言かつ簡潔に申し上げれば「ガスとの戦い」であり、ガス使用量を減らすためにさまざまな工夫を凝らさなければなりません。

今回のようにサイズの大きな画像データを扱うケースでは、ガス使用量に関する課題として大きく以下2点が問題となります。

  1. どうやってデータを保存するか
  2. どうやってtokenURIを作成するか

1. どうやってデータを保存するか

EthereumやPolygonでは、1つのトランザクションで使えるガスの上限は30M(3,000万)です。

サイズの大きな画像データを1トランザクションで送信してストレージ変数に保存しようとしても、ガスの上限を超えてしまうため実行できません。

出典: opensea.io/collection/lightsculpture

そこでLightSculptureでは、「データを分割してトランザクションを分ける」ことにより対処しました。

ただし、その場合でもデータを結合してストレージに保存する際にガスを消費してしまうため、この方法では200KB程度が限度でした。

2. どうやってtokenURIを作成するか

NFTのメタデータを取得するには、tokenURI関数を使います。

tokenURI関数では、保存したデータを読み込んでdataURLを作成するのですが、ここでもガスの制約を受けてしまいます。

tokenURIはview関数なので実際にガス代を払う必要はありませんが、30Mを超える内容は実行できないため、画像が読み込めなくなってしまうのです。

ちなみに、一般的なフルオンチェーンNFTでは、tokenURI関数の中で以下の処理をおこなうケースが多いです。

  1. 文字列を結合してメタデータを作成する
  2. メタデータをBase64エンコードしてdataURLを作成する

大きなデータを扱う場合は、①文字列の結合や②Base64エンコードでのガス使用量が問題になるため、こちらもガスを減らす工夫が必要となります。

以上の2点を踏まえて、ここからは「On-Chain Photo」でガス使用量を減らすためにどういった工夫を凝らしたのかについて、ご紹介していきます。

手順1:メタデータはBase64エンコードしない

nawoo

フルオンチェーンNFTでは、画像データだけでなく「メタデータをdataURL化」する際にも、Base64エンコードしているケースが大半です。

しかし、Base64エンコードすることにより、データのサイズは33%も増加してしまいます。

画像データが100KBであればメタデータは約133KBになり、さらにメタデータをBase64エンコードするとdataURLは177KBになります。

このように、サイズが増えるとそれに応じてガス使用量も増えてしまうのです。

dataURLでは、バイナリデータに関してはBase64エンコードする必要があるのに対して、テキストデータについてはBase64エンコードは必須ではありません。

画像はバイナリデータですが、メタデータはJSON(テキストデータ)なので、Base64エンコードしなくても構いません。

nawoo

例えば、LOOTのdataURLを見るとBase64エンコードされていることが分かりますが、ROSESではBase64エンコードされていないことが分かります。

LootのtokenURI (Base64エンコードされている。data:application/json;base64,〜)

出典: etherscan.io/address/0xff9c1b15b16263c61d017ee9f65c50e4ae0113d7#readContract#F19

ROSESのtokenURI (Base64エンコードされていない。data:application/json,〜)

出典: etherscan.io/address/0x3e743377417cd7ca70dcc9bf08fac55664ed3181#readContract#F10

ただし、Base64エンコードしない場合に、URLエンコードをする必要があり、URLに使用できない記号を%xx形式で書き換えます。
例えば、:%3A/%2Fになります。

また、当然ながらURLエンコードでもサイズは増加してしまいますが、どの程度増えるのかは元データに依ります。

nawoo

「On-Chain Photo」では、メタデータをBase64エンコードした場合は33%増加、URLエンコードの場合は5%の増加だったことから、URLエンコードを採用することに至りました。

手順2:tokenURIの作成はオフチェーンでおこなう

データの書き込み/読み込みの両方に関わる問題。
それは、オンチェーン(コントラクト上)・オフチェーン(ローカル環境) どちらでtokenURIの作成をおこなうかです。

「On-Chain Photo」の場合、tokenURIの作成は以下の手順でおこないます。

  1. 画像データをBase64エンコードする
  2. 画像(image), name, descriptionを含んだメタデータ(JSON)を作成する
  3. メタデータをdataURL化する

ちなみに先ほど説明したように、Base64エンコードするとデータのサイズは33%増加します。
画像データが100KBであれば、メタデータは約133KB、tokenURIは約140KBになります。

ブロックチェーンに保存するデータは小さい方が、支払うガス代は安くなります。
しかし一方で、tokenURI関数でBase64エンコードをおこなう際に、多くのガスを使います。

tokenURI関数では、実際にガス代を支払うわけではありませんが、ガス上限を超えると実行できなくなってしまいます。

nawoo

では、どのような方法を取れば良いのか。以下に2つ挙げてみます。

  1. 方法1. オンチェーンでtokenURIを作成する
    1. 画像データをそのままブロックチェーンに保存する
    2. tokenURI関数で画像データをBase64エンコード&メタデータ作成&dataURL化をおこなう
  2. 方法2. オフチェーンでtokenURIを作成する
    1. あらかじめtokenURIを作成しておき、それをブロックチェーンに保存する
    2. tokenURI関数では保存した内容を返すだけ

方法1では、画像データ(100KB)を保存するだけでよいので、データ保存時のガス代は抑えられます。
しかし、tokenURI関数内で画像データのBase64エンコードをする必要があり、その処理でガス上限を超えてしまう恐れがあります。

方法2では、tokenURIデータ(140KB)を保存しなくてはなりませんが、tokenURI関数ではBase64エンコードする必要がありません。

試した結果、画像データが250KB程度までは方法1で実行可能ですが、それ以上の場合には方法2を使わざるを得ない感じでした。

nawoo

「On-Chain Photo」では可能な限りサイズの大きな画像を使いたいので、方法2(オフチェーン)を採用しました。

手順3:SSTORE2を使用する

出典:github.com/0xsequence/sstore2

ROSESについて調べていた際に、データコントラクトという仕組みと、SSTORE2というライブラリについて知りました。

SSTORE2ではデータコントラクトを使うことで、ストレージへの保存(sstore)と比べて大幅にガス使用量を減らすことができます。

nawoo

ROSESとデータコントラクトについては、前回の記事をご覧ください。

SSTORE2の使い方はとても簡単です。

データを書き込む際はwrite関数を使います。引数はbytes型になり、戻り値はaddressになります。

address addr = SSTORE2.write(data);

データの読み込む際はread関数を使います。引数はaddressを指定し、戻り値はbytes型になります。

bytes memory data = SSTORE2.read(addr);

ちなみに、1度に書き込めるデータの最大サイズは24,575バイトです。

nawoo

そして、「On-Chain Photo」におけるデータ保存のためのコードはこちら。

function appendUri(uint256 tokenId, bytes[] calldata values) public onlyOwner {
    if (tokens[tokenId].frozen) revert DataIsFrozen();
    for (uint256 i = 0; i < values.length; i++) {
        tokens[tokenId].addresses.push(SSTORE2.write(values[i]));
        tokens[tokenId].bytesLength += values[i].length;
    }
}

まず、オフチェーンで作成したtokenURIを分割して、values引数に渡します。
次に、分割したデータごとにSSTORE2.writeを実行して、戻り値のアドレスをaddressesに追加し、バイトサイズをbytesLengthに追加しています。

1回のトランザクションでは24,575バイトx5個(122KB)まで送信できます。
それ以上になると out of gas エラーになってしまいました。122KB以上を送るには、appendUri関数を繰り返して使います。

手順4:bytes.concatをネストする

続いては、tokenURI関数でのデータの読み込みです。

nawoo

SSTORE2のread関数でデータを読み込んで結合します。bytes型の結合には、bytes.concat関数を使いますが、引数が多くなるとstack too deepエラーになってしまいました。試してみると、引数は9つまでしか指定できませんでした。

// これならOK
return bytes.concat(
    SSTORE2.read(addresses[0]),
    SSTORE2.read(addresses[1]),
    SSTORE2.read(addresses[2]),
    SSTORE2.read(addresses[3]),
    SSTORE2.read(addresses[4]),
    SSTORE2.read(addresses[5]),
    SSTORE2.read(addresses[6]),
    SSTORE2.read(addresses[7]),
    SSTORE2.read(addresses[8])
)
// これだと stack too deepエラー
return bytes.concat(
    SSTORE2.read(addresses[0]),
    SSTORE2.read(addresses[1]),
    SSTORE2.read(addresses[2]),
    SSTORE2.read(addresses[3]),
    SSTORE2.read(addresses[4]),
    SSTORE2.read(addresses[5]),
    SSTORE2.read(addresses[6]),
    SSTORE2.read(addresses[7]),
    SSTORE2.read(addresses[8]),
    SSTORE2.read(addresses[9])
)

24,575バイト x 9個なので221,175バイトになりますが、stack too deepは関数をネストさせることで回避できます。

nawoo

試してみると、bytes.concat関数をネストさせることで36個までいけました。

// bytes.concatをネスト
return bytes.concat(
    bytes.concat(
        SSTORE2.read(addresses[0]),
        SSTORE2.read(addresses[1]),
        SSTORE2.read(addresses[2]),
        SSTORE2.read(addresses[3]),
        SSTORE2.read(addresses[4]),
        SSTORE2.read(addresses[5]),
        SSTORE2.read(addresses[6]),
        SSTORE2.read(addresses[7]),
        SSTORE2.read(addresses[8])
    ),
    bytes.concat(
        SSTORE2.read(addresses[9]),
        SSTORE2.read(addresses[10]),
        SSTORE2.read(addresses[11]),
        SSTORE2.read(addresses[12]),
        SSTORE2.read(addresses[13]),
        SSTORE2.read(addresses[14]),
        SSTORE2.read(addresses[15]),
        SSTORE2.read(addresses[16]),
        SSTORE2.read(addresses[17])
    ),
    bytes.concat(
        SSTORE2.read(addresses[18]),
        SSTORE2.read(addresses[19]),
        SSTORE2.read(addresses[20]),
        SSTORE2.read(addresses[21]),
        SSTORE2.read(addresses[22]),
        SSTORE2.read(addresses[23]),
        SSTORE2.read(addresses[24]),
        SSTORE2.read(addresses[25]),
        SSTORE2.read(addresses[26])
    ),
    bytes.concat(
        SSTORE2.read(addresses[27]),
        SSTORE2.read(addresses[28]),
        SSTORE2.read(addresses[29]),
        SSTORE2.read(addresses[30]),
        SSTORE2.read(addresses[31]),
        SSTORE2.read(addresses[32]),
        SSTORE2.read(addresses[33]),
        SSTORE2.read(addresses[34]),
        SSTORE2.read(addresses[35])
    )
);

これにて、24,575バイトx36個となり、最大値は884KBバイトになります。

nawoo

しかし、最大値まで読み込むと out of gas エラーになってしまい、限界値は880KBバイトでした。
(コンパイラの最適化オプションやviaIRオプションによっても変わってきます。)

tokenURIが880KBまで使えるようになったということは、Base64エンコードとURLエンコードする前のデータに換算すると約620KBになります。

つまり、620KBの画像をフルオンチェーン化できるわけです。

手順5:Memory.solを使用する

nawoo

620KBの画像でもフルオンチェーンとしてはかなり大きいと思いますが、もう少し頑張ってみます。

bytes.concat関数をネストさせましたが、この場合、1~9、10〜18、19〜27、28〜36 それぞれを結合させてメモリ変数に一時保存し、それを再度bytes.concat関数で結合する、という手順をとっており、なんだか無駄が多そうです。

Inline Assemblyを使えばもっと効率的にメモリ操作できそうですが、あまり詳しくありません。

いろいろ調べた結果、Memory.solを使うことで、bytesの結合を効果的にできそうだとわかりました。

Memory.solを使うと、データの結合は、このようになります。

// まずuri変数を、最終的なサイズで確保する
bytes memory uri = new bytes(tokens[tokenId].bytesLength);
// uri変数のデータアドレスを取得
(uint256 uriAddr, ) = Memory.fromBytes(uri);
// 順番にコピーしていく
for (uint256 i = 0; i < tokens[tokenId].addresses.length; i++) {
    // SSTORE2.readで読み込んだデータをdata変数に代入
    bytes memory data = SSTORE2.read(tokens[tokenId].addresses[i]);
    // data変数のデータアドレスと長さを取得
    (uint256 dataAddr, uint256 len) = Memory.fromBytes(data);
    // data変数をresult変数にコピーする
    Memory.copy(dataAddr, uriAddr, len);
    uriAddr += len; // uriAddrを移動する
}
nawoo

Memory.solのfromBytes関数とcopy関数の2つを使っています。どういう処理をしているか見てみましょう。

fromBytes関数

fromBytes関数は、bytes型のデータのアドレスと長さを取得します。

function fromBytes(bytes memory bts) internal pure returns (uint256 addr, uint256 len) {
    len = bts.length;
    assembly {
        addr := add(
            bts,
            /*BYTES_HEADER_SIZE*/
            32
        )
    }
}

bytesのような動的タイプでは、先頭32バイトに長さが格納され、その次からデータが格納されていきます。アドレスが+32されているのはそのためです。

copy関数

copy関数は、srcからdestにlenサイズだけコピーします。

EVMでは32バイトを1つの単位(word)として扱います。mloadmstoreも32バイト単位での読み書きになります。 そこで、まず32バイト単位でコピーしていき、余った分はビットマスクを使ってコピーしています。

function copy(
    uint256 src,
    uint256 dest,
    uint256 len
) internal pure {
    // 32バイト単位でコピーできるところまでコピー
    for (; len >= WORD_SIZE; len -= WORD_SIZE) {
        assembly {
            mstore(dest, mload(src))
        }
        dest += WORD_SIZE;
        src += WORD_SIZE;
    }
    if (len == 0) return;
    // 余った分はビットマスクを使ってコピー
    uint256 mask = 256**(WORD_SIZE - len) - 1;
    assembly {
        let srcpart := and(mload(src), not(mask)) // 先頭からlenサイズはsrcからコピー
        let destpart := and(mload(dest), mask)  // それ以後はdestからコピー
        mstore(dest, or(destpart, srcpart))
    }
}

ビットマスクは、ビット演算を使って、必要なデータを作成するテクニックです。 今回のケースでは、先頭lenバイトのみsrcからコピーし、lenバイト以後はdestからコピーした値を取得しています。

nawoo

copy関数を見ると、srcのサイズを32の倍数にしておくと効率が良さそうです。

SSTORE2を使ったデータ上限は24,575バイトですが、32の倍数になるように24,544バイトにすると、ほんの少しですがガス代を節約できました。

結果

bytes.concatではなくMemory.solを使ってデータを結合することで、tokenURIは1,020KBまで読み込めるようになりました。 ついに1MB超えました。

JPG画像データに換算すると、約720KBになります。

nawoo

メモリ操作など、もう少し効率化できるかもしれませんが、それは今後の課題として、今回はここまでにしておきます。

オフチェーンでの作業

tokenURIの作成はオフチェーンでおこなうため、Hardhatのスクリプト(TypeScript)も紹介しておきます。

まず、おこなっている内容は以下のとおりです。

  • 画像ファイルを読み込んで、Base64エンコードする
  • メタデータ(JSON)を作成する
  • URLエンコードして、tokenURIを作成する
  • tokenURIを24544バイトごとに分割する
  • appendTokenURI関数で5個ずつ送信する
// URIを作成
const fileContent = readFileSync(filePath); // JPGファイルを読み込む
const fileContentBase64 = fileContent.toString('base64'); // 画像データをBase64エンコード
const metadata = {
    name: `On-Chain Photo - 724KB`,
    description: 'Fully on-chain NFT of jpg photo file.',
    image: `data:image/jpeg;base64,${fileContentBase64}`,
};
const json = JSON.stringify(metadata); // メタデータ(JSON)を作成
const uri = 'data:application/json,' + encodeURIComponent(json); // URLエンコード
const data = Buffer.from(uri);

// 分割アップロード
const splitSize = 24544; // 最大24575バイト以下で最大の32の倍数
const chunkSize = 5; // 1回のトランザクションで送信する分割データ数
const splitCount = Math.ceil(data.length / splitSize); // 分割数
const splittedData = [...Array(splitCount)].map((_, i) => data.subarray(i * splitSize, (i + 1) * splitSize));
const chunkValues = chunk(splittedData, chunkSize); // lodashのchunkを使っています
for (let i = 0; i < chunkValues.length; i++) {
    // 分割データを5個ずつ appendUri関数に渡す
    const tx = await contract.appendUri(tokenId, chunkValues[i]);
    await tx.wait();
}
出典:testnets.opensea.io/ja/collection/onchainphoto

最初に紹介したこちらの画像は724KBのJPGファイルでしたが、Base64エンコードとURLエンコードすることで、tokenURIは1012KBになりました。

それを24,544バイトx42個に分割し、5個ずつappendUri関数を9回使ってアップロードしました。ガス使用量は合計で228,491,105でした。

nawoo

筆者はテストネット(Goerli)を使いましたが、もしEthereumメインネットなら、ガス価格10gweiだとして、なんとガス代は2.28ETHになります!

おまけ: URLエンコードをやめてみる

「サイズの大きな画像をフルオンチェーンNFTにするために」章で、メタデータをdataURL化する際『Base64エンコードしない場合は、URLエンコードが必要だ』と書きましたが、 URLエンコードなしのdataURLでもOpenSeaでは問題なく表示されるようです。

// URLエンコードありのtokenURI
data:application/json,%7B%22name%22%3A%22On-Chain%20Photo%22%2C%22description%22%3A%...

// URLエンコードなしのtokenURI
data:application/json,{"name":"On-Chain Photo","description":"Fully on-chain NFT of...
nawoo

dataURLを作るときに、URLエンコードはしなくてもいいのでしょうか? またはOpenSeaの親切設計(?)なのでしょうか? 仕様上はURLエンコードが必要だと思うのですが。。。 この点よくわからないので、詳しい方ぜひ教えて下さい。

URLエンコードをすると、tokenURIは約5%サイズが増加します。もしURLエンコードなしでも良いのであれば、その分を削減できるため、計算上は約765KBのJPG画像までいけることになります。

出典:testnets.opensea.io/ja/collection/onchainphoto-dash
nawoo

751KBと762KBで試してみたところ、無事にミントできました。こちらは暫定記録としておきます。

まとめ

【AD】GAME OF THE LOTUS PROJECT
でりおてんちょー

「地方創生×NFT」の取り組みとして非常にユニークで面白いと思います。


今回は、サイズの大きな画像をフルオンチェーンNFTにする方法について解説しました。

本記事が、サイズの大きな画像をフルオンチェーンNFTにする方法や、NFT開発における先進的な知見を得たいと思われている方にとって、少しでもお役に立ったのであれば幸いです。

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

nawoo

まだまだ工夫の余地はあると思いますので、改善できそうな点に気づいた方はぜひ教えて下さい。

700KBともなると、画像だけでなく、動画や3Dモデルにも応用できるかもしれません。

フルオンチェーンNFTの可能性がさらに広がったのではないでしょうか。

イーサリアムnaviを運営するSTILL合同会社では、以下などに関するお問い合わせを受け付けております。

  • 広告掲載
  • PR記事の執筆業務
  • リサーチャー業務
  • アドバイザリー業務
  • その他(ご依頼・ご提案・ご相談など)

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

励みになるので、よかったらSNSなどでシェアしてください!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

フルオンチェーンNFTクリエイター。Lootを見たことがきっかけでNFTやSolidityにハマりました。 今までに作ったフルオンチェーンNFTは以下をご覧ください。

目次