💬

inline assemblyを駆使して、6.2MBのイラストをフルオンチェーンNFTにしてみた

2023/10/23に公開

はじめに

Solidity にてinline assemblyを適切に使うと、消費ガスを抑えることができ、結果としてガス代が安くすみます。特に、メモリー操作を挟む場合においては、劇的な効果が得られます。

今回は、inline assemblyを駆使して、フルオンチェーンの容量の限界に挑もうと思います。

結果 1

今回は、こちらの素敵なイラスト(Gem さん作)を使わせていただきました。なんて素敵なイラストなんでしょう。

画像

容量は、5.9MB でした。

データ容量

etherscanは、こちらになります。

etherscan で確認したところ return が最後まで帰ってきてたので、オンチェーンとしてはできたということで良いと思います。むちゃくちゃ長い・・・。

etherscan
etherscan

なお、5.95MB でのテストでは out of gas でした。

ちなみにですが、OpenSeaでは、画像が表示されませんでした。非常に悲しみ。

結果 2

memory 拡張コストの計算が間違っていたため、少し無駄にガス代を消費していることがわかりました。
そこで、コントラクト改善しました。具体的には、後半で説明しますが、メモリーに一括呼び出ししてから再利用する方式からストレージから都度呼び出し方式に切り替えました。

容量は、6.2MB でした。

データ容量

etherscanは、こちらになります。

etherscan では、out of gas ですが、OpenSea で表示されています。

etherscan にしろ OpenSea にしろなかなか境界線が掴みきれません。

ソース置き場

Githubからどうぞ!

tokenURIのチェック用のテストのみ書いてます。

環境変数.env.sample.envに変えて、適切に値を入れてください。

ちなみに、誰でもトークン発行できるようにしてあるので、好きに試してもらって問題ありません。

仕組み

  1. 大きな画像データを Base64 エンコードします。(nodejs)
  2. 各データが 24,575 kB になるように分割します。(nodejs)
  3. 各データをストレージコントラクトに格納します。(コントラクト)
  4. ストレージコントラクトのアドレスを繋げたデータbundleAddressDataを作成します。(nodejs)
  5. bundleAddressDataをストレージコントラクトに保存します。(コントラクト)
  6. 得られたストレージコントラクトのアドレスをtokenIDに紐付けて、stackRoomに格納します。(コントラクト)
  7. tokenURI(tokenID)で呼び出され、フルオンチェーンデータが返ってきます。(コントラクト)

それぞれの工程に関する工夫は、後ほど解説しますね。

コントラクト

まずは、コントラクトから。ベースは、openzeppelin の標準コントラクトを継承しています。
え?コントラクトの名前がダサい?まさにその通り!

標準部分

inline assembly以外の部分は、大したこと書いてないので、さらっと行きます。

src/OnchainLargeImage.sol
mapping(uint256 => address) public stackRoom;

要するに、stackRoom[tokenID] = addressって感じですね。

src/OnchainLargeImage.sol
function safeMint(bytes memory bundleAddressData) public {
    uint256 tokenId = _nextTokenId++;
    _setStackRoom(tokenId, bundleAddressData);
    _safeMint(msg.sender, tokenId);
}

mintするときにbundleAddressDataを与えることで、tokenIDに連動させています。
ここの処理が 5・6 に該当します。

src/OnchainLargeImage.sol
event Upload(address indexed pointer, address indexed from);

ポインターのアドレスをストレージに格納してもよかったんですが、あまり価値のあるデータではないので、イベント発行して後から回収しています。

src/OnchainLargeImage.sol
function upload(string memory data) public {
    address pointer = bytes(data).write();
    emit Upload(pointer, msg.sender);
}

画像データを与えることで、新しいストレージコントラクトを発行しています。SSTORE2 と呼ばれるライブラリを使用しています。ここの処理が 3 に該当します。

inline assembly 部分 1

src/OnchainLargeImage.sol
function readSstore2(ptr, sp) -> ep, size {
    // check
    let pointerCodesize := extcodesize(ptr)
    if iszero(pointerCodesize) { pointerCodesize := 1 }

    // Offset all indices by 1 to skip the STOP opcode.
    size := sub(pointerCodesize, 1)

    // Get data and copy it to memory
    extcodecopy(ptr, sp, 1, size)
    ep := add(sp, size)
}

assembly内で関数を定義しています。

  • ptr にはストレージコントラクトのアドレス
  • spは書き込み始めるメモリーポインターの位置
  • epは書き込みが終わるメモリーポインターの位置
  • sizeにはメモリーの長さ

何をしているかというと、ストレージコントラクトの大きさをチェックして、ストレージコントラクトの中身をメモリーへコピーしています。

次から実際の処理に入っていきます。

src/OnchainLargeImage.sol
// memory counter to the start of the free memory.
let mc := mload(0x40)

// start pointer for address data
let sp := mc
let size

// get `bundleAddressData`
mc, size := readSstore2(pointer, mc)

フリーメモリーポインターの位置(0x80)を取得し、スタート位置(0x80)に設定しています。
その後、先ほどの関数を呼び出しています。

ここでは、bundleAddressDataを取得し、スタート位置(0x80)から終わりの位置(0x80+size-1)まで書き込んでいます。

src/OnchainLargeImage.sol
// move free memory pointer
mstore(0x40, mc)

// return data
result := mload(0x40)

// Skip the first slot, which stores the length.
mc := add(mc, 0x20)

再度、フリーメモリーポインターの位置(0x80 -> 0x80+size)を変えています。
その後、result の位置を設定(0x80+size)しています。

現在のメモリーの構造はこんな感じです。

[0x00:0x3f]スクラッチスペース
[0x40:0x5f]フリーメモリーポインター(0x80+size)
[0x60:0x7f]ブランクスペース
[0x80:0x80+size-1]各画像データを保存しているストレージコントラクトのアドレスを束ねたデータ`bundleAddressData`
[0x80+size:0x100+size-1]resultの長さを格納するスペース
[0x100+size〜]resultの値を格納

bundleAddressDataを一旦格納し、後から参照することでループ処理をスマートにしています。

src/OnchainLargeImage.sol
// default information
mstore(mc, 'data:application/json,{"name":"O')
mc := add(mc, 32)

mstore(mc, 'nchainLargeImage","description":')
mc := add(mc, 32)

mstore(mc, '"This is Onchain Large Image.","')
mc := add(mc, 32)

mstore(mc, 'image":"data:image/jpeg;base64,')
mc := add(mc, 31)

オンチェーンに必要な枕詞ですね。

src/OnchainLargeImage.sol
// bundle Data
for {
    let ptr
    let last := add(sp, size)
} 1 {
    // step by step address
    sp := add(sp, 0x14)
} {
    ptr := shr(96, mload(sp))
    mc, size := readSstore2(ptr, mc)
    if lt(last, sp) { break }
}

bundleAddressDataを読み込み、右にシフトさせることでストレージコントラクトのアドレスを取得します。そのアドレスを使って、ストレージコントラクトのデータを読み込みます。

次のループに行く際に、bundleAddressDataの読み込み位置をアドレス長さ分(20bits)ずらして読み込みます。

これを繰り返すことで、次から次にストレージコントラクトのデータを読み込み、繋げていくわけですね!

src/OnchainLargeImage.sol
// Allocate the memory for the string.
mstore(0x40, and(add(mc, 31), not(31)))

// Write the length of the string.
mstore(result, sub(sub(mc, 0x20), result))

最後に、おまじないをかけて、長さを格納すれば出来上がり!非常にシンプルですね。

inline assembly 部分 2

先ほどの実装では、bundleAddressDataを取得し、メモリーに保存して転用していました。

今回の改善では、stackRoomにアドレスデータを mapping にて保存しています。

src/OnchainLargeImageR1.sol
mapping(uint256 => mapping(uint256 => uint256)) public stackRoom;
  • stackRoom[tokenId][0]には、bundleAddressDataの長さを保存
  • stackRoom[tokenId][index+1]には、bundleAddress[index]を保存

しています。具体的な実装を見ていきます。

src/OnchainLargeImageR1.sol
function _setStackRoom(uint256 tokenId, bytes memory bundleAddressData) internal {
    assembly {
        // get `bundleAddressData`
        let len := mload(bundleAddressData)

        // slot for `stackRoom[tokenId]`
        mstore(0x00, tokenId)
        mstore(0x20, stackRoom.slot)
        let slot := keccak256(0x00, 0x40)

        // set `bundleAddressData`.length to stackRoom[tokenId][0]
        mstore(0x00, 0x00)
        mstore(0x20, slot)
        sstore(keccak256(0x00, 0x40), len)

        let mc := add(bundleAddressData, 0x20)

        for {
            let cc := 1
            let last := add(mc, len)
        } 1 {
            // step by step address
            mc := add(mc, 0x14)
            cc := add(cc, 1)
        } {
            // set `bundleAddressData`.data to stackRoom[tokenId][cc]
            mstore(0x00, cc)
            // mstore(0x20, slot)
            sstore(keccak256(0x00, 0x40), shr(96, mload(mc)))

            if lt(last, mc) { break }
        }
    }
}

bytes memory bundleAddressDataのスロットは下記の通りになっており、該当するスロットを読み込んでデータを取得します。

[0x00   ]bundleAddressData の長さ
[0x20...]bundleAddressData のデータ

mload(bundleAddressData)とすれば、bundleAddressDataの長さが取得できます。
データは、sstore(keccak256(0x00, 0x40), shr(96, mload(mc)))で保存しています。

保存するときに shr しているのは、書き込むデータが address 型のためです。
ややこしいんですが、slot 上では右詰め(slot 単位だから)、呼び出し時点では左詰め(address 単位だから)のため微調整しています。

なお、解説書いてて気づきましたが、2 回目以降のmstore(0x20, slot)は不要ですね。

stackRoomからの呼び出しは、下記の通りです。マルチマッピングになっているため、slot を2段階で呼び出す感じになります。

src/OnchainLargeImageR1.sol
mstore(0x00, tokenId)
mstore(0x20, stackRoom.slot)
let slot := keccak256(0x00, 0x40)

mstore(0x00, cc)
mstore(0x20, slot)
ptr := sload(keccak256(0x00, 0x40))

コントラクト発行

まずは、環境変数を読み込みましょう!

source .env

あとは、script/OnchainLargeImage.s.solでオンチェーンに流し込んでいきます。

forge script script/OnchainLargeImage.s.sol:OnchainLargeImageScript --rpc-url $GOERLI_RPC_URL --broadcast --verify -vvvv

コントラクトの発行が終わったら、CONTRACT_ADDRESSを追加しておいてくださいね

事前処理とかオンチェーン操作

事前処理とかオンチェーン操作は、ts-nodeでガチャガチャしています。

chatGPT 君が書いてくれた部分は解説すっ飛ばしますね。気になる部分は、chatGPT 君に聞いてみてくださいね。

データ分割(1・2の処理)

まず、image/inputに画像を保存します。

convert.tsでは、画像を Base64 に変換して、24,575 bytes 毎にデータを分割して保存します。早速次のコマンドで実行してみましょう。

npx ts-node ts-src/convert.ts

すると、image/outputに分割されたデータが出力されます。

icon.jpg0とかicon.jpg1とか・・・。なかなかのネーミングセンスですね。

アップロード(3 の処理)

分割された画像データのアップロードを行います。

upload.tsでは、先ほどのフォルダにあるデータを呼び出し、ブロックチェーンへと書き込んでいきます。
image/upload/tx.jsonに、{fileName: txHash } の形で保存します。

npx ts-node ts-src/upload.ts

トークンの発行

最後にトークンの発行を行います。

今回、ストレージコントラクトのアドレスをコントラクトに書き込まなかったので、イベントを取得する必要があります。

ts-src/mint.ts
const events = await getEvents();
ts-src/tx/getEvents.ts
const events: ethers.EventLog[] = (await contract.queryFilter(
  "Upload"
)) as ethers.EventLog[];

こんな感じでイベントを取得し、必要なデータ形式に加工していきます。

※本来は、decode 処理でやるのが適切だと思いますが、今回は無理やり文字を削ったりして処理しています。

npx ts-node ts-src/mint.ts

image/upload/bundleAddressData.jsonbundleAddressDataが出力されるようになっています。

あとは、Etherscan で確認してみましょう!お疲れ様でした。


いかがだったでしょうか?

コントラクト自体は小一時間程度で作ってしまったので、まだまだ改善の余地があると思われます。10MB 目指して、ぜひ挑戦してみてくださいね!

もし記事があなたのお役に立ったなら、ぜひ「いいね!」ボタンをクリックしてくださいね。

Discussion