🎱

買い占めるほどデカくなる?NFT「merge.」の技術的な仕組みを解説する

2022/01/16に公開

前置き

  • この記事は特定のNFTや仮想通貨の購買を促進する記事ではありません。

はじめに

2021年12月、デジタルアーティスト「Pak」氏が作ったNFTコレクション「merge.」が48時間で約104億売上達成、完売したというニュースがありました。この「merge.」というNFTの技術的な仕組みが面白かったので今回はその仕組みを紐解きます。

merge. とは?

引用元: PakのNFT「Merge」、合計約104億円で完売

デジタルアーティストのPak氏のNFTプロジェクト
48時間で約104億円売上、完売。2日間で28984人が参加
Mergeでは「mass(マス)」と呼ばれるNFTが販売され、複数の「マス」を購入したコレクターは「マス」同士を「マージ(結合)」できる。多くの「マス」を購入した分だけNFTのサイズを拡大できる仕組み
これにより「マス」の「総量」は一定のまま「総数」は減少していく
コレクターが2次市場でマスを購入するたびに、新しく購入したマスと既存のマスはひとつのNFTとして「マージ」される
https://niftygateway.com/collections/pakmerge

NFTのサイズを拡大?
二次流通でマスを購入するたびにひとつのNFTとしてマージされるって何?

色々気になりますが、実際のmerge. コントラクトのソースコードは公開されているので、早速見てみます。

https://etherscan.io/address/0xc3f8a0f5841abff777d3eefa5047e8d413a1c9ab#code

読み解く

気になる点

はじめに、気になる点をざっと洗い出してみます

  • どのタイミングで「マス」が「マージ」される?
  • 「マス」の初期値は?
  • 「マス」の「総量」は一定のまま「総数」が減少していく とは?
  • どうやって描画されている?

1つずつ見ていきます

どのタイミングで「マス」が「マージ」される?

マス同士をマージするとは?NFT同士って合成できるの?どういうこと?
ERC721はNFTを他人に転送するときは transfer メソッドが呼ばれます。受信時に呼ばれる関数は(デフォルトでは)ありません。
ここから、 transferメソッド内にマージ処理が書かれていると予想できます。

// Merge.sol
function _transfer(address owner, address from, address to, uint256 tokenId) internal notFrozen {
        require(owner == from, "ERC721: transfer of token that is not own");
        require(to != address(0), "ERC721: transfer to the zero address");
        require(!_blacklistAddress[to], "Merge: transfer attempt to blacklist address");
	
	// ... 省略
	
                } else {
                    uint256 sentTokenId = tokenId;

                    // ここがmerge処理
                    uint256 deadTokenId = _merge(currentTokenId, sentTokenId);
                    emit Transfer(to, address(0), deadTokenId);

                    uint256 aliveTokenId = currentTokenId;
                    if (currentTokenId == deadTokenId) {
                        aliveTokenId = sentTokenId;
                    }

                    delete _owners[deadTokenId];

    // ... 

merge処理自体は _merge メソッドに切り分けわれているようです。

// Merge.sol
    function _merge(uint256 tokenIdRcvr, uint256 tokenIdSndr) internal returns (uint256 tokenIdDead) {
        require(tokenIdRcvr != tokenIdSndr, "Merge: Illegal argument identical tokenId.");

        // 既に持っているマスの値
        uint256 massRcvr = decodeMass(_values[tokenIdRcvr]);
	// 購入した or 送られてきたマスの値
        uint256 massSndr = decodeMass(_values[tokenIdSndr]);
        
	// どちらが大きいマスか比較する -------->>>
        uint256 massSmall = massRcvr;
        uint256 massLarge = massSndr;

        uint256 tokenIdSmall = tokenIdRcvr;
        uint256 tokenIdLarge = tokenIdSndr;

        if (massRcvr >= massSndr) {

            massSmall = massSndr;
            massLarge = massRcvr;

            tokenIdSmall = tokenIdSndr;
            tokenIdLarge = tokenIdRcvr;
        }
	// << ------------------------------

        // 大きい方のマスに小さい方のマスの値を加算する
	// ⭐ _valuesに全てのマスの大きさが格納されていることがわかる
        _values[tokenIdLarge] += massSmall;

        uint256 combinedMass = massLarge + massSmall;

        if(combinedMass > _alphaMass) {
            _alphaId = tokenIdLarge;
            _alphaMass = combinedMass;
            emit AlphaMassUpdate(_alphaId, combinedMass);
        }
        
        _mergeCount[tokenIdLarge]++;
        
	// 小さい方のマスの値データ削除
        delete _values[tokenIdSmall];

        _countToken -= 1;

        emit MassUpdate(tokenIdSmall, tokenIdLarge, combinedMass);

        return tokenIdSmall;
    }

大事な部分はコメントで書いています。
ポイントは_values[tokenIdLarge] += massSmall; ここですね。大きい方のマスの値に小さい方のマスの値を加算しています。
その後、小さい方のマスはburnされています。

「マス」の初期値は?

マス自体に値を持っているということが先程のコードを見ることでわかりました。
その初期値は1だとは思いますが念の為コードを見てみましょう。

まずはNFT生成処理である Merge.sol の mint メソッドを読みます

// Merge.sol

// ...
function mint(uint256[] calldata values_) external onlyValidSender {
	require(!_mintingFinalized, "Merge: Minting is finalized.");        

	uint256 index = _nextMintId;                
	uint256 alphaId = _alphaId;
	uint256 alphaMass = _alphaMass;
	address omnibus = _omnibus;

	uint256 massAdded = 0;
	uint256 newlyMintedCount = 0;
	uint256 valueIx = 0;

	while (valueIx < values_.length) {            

	    if (isSentinelMass(values_[valueIx])) {
		// SKIP FLAG SET - DON'T MINT
	    } else {
		newlyMintedCount++;

		// _values に値を入れている
		// 値はこのメソッドの引数である values_ からとっている
		_values[index] = values_[valueIx];            
		_owners[index] = omnibus;

		(/* uint256 class */, uint256 mass) = decodeClassAndMass(values_[valueIx]);

		if (alphaMass < mass){
		    alphaMass = mass;
		    alphaId = index;
		}

		massAdded += mass;

		emit Transfer(address(0), omnibus, index);
	    }

	    // update counters for loop
	    valueIx++;
	    index++;
	}

// ...

実際の値はハードコーディングされているわけではなかったのでコードから読み解くことができませんでした。

コントラクトにはマスの値を見ることができる getValueOf というメソッドが実装されています。
適当な merge(1) となっている一度もマージされていないマスのtokenIDを元に getValueOf メソッドを呼び出して値を見たところ 100000001 と表示されていたので初期値は 100000001 と設定されていたことがわかりました。

https://opensea.io/assets/0xc3f8a0f5841abff777d3eefa5047e8d413a1c9ab/17814

なぜ 100000001 ? 100000001+100000001をしているの?

100000001+100000001 という計算をしているわけではありません。 _merge メソッドの中でこういう処理があったと思います

// Merge.sol
// 既に持っているマスの値
uint256 massRcvr = decodeMass(_values[tokenIdRcvr]);
// 購入した or 送られてきたマスの値
uint256 massSndr = decodeMass(_values[tokenIdSndr]);

decodeMassメソッドでは以下のような処理が行われています

// Merge.sol

// ...

uint256 constant private CLASS_MULTIPLIER = 100 * 1000 * 1000; // 100 million

// ...

function decodeMass(uint256 value) public pure returns (uint256 mass) {
	mass = value % CLASS_MULTIPLIER; // integer modulo is ‘checked’ in Solidity 0.8.x
	ensureValidMass(mass);
}
  • mass = value % CLASS_MULTIPLIER
  • 100 * 1000 * 1000 -> 100000000

つまり、 100000001100000000 で割ったあまり (この場合は1) をマスの実際の値としています
これはマスの値の オーバーフロー・アンダーフロー対策 です。詳しくは googleSafeMath あたりが参考になると思います

このあたりの数字処理を見る限り、計算するときだけ 1+1のようなわかりやすい数字で安全に計算し値としては 100000001 100000002 といった数値で保持していることがわかりました。

「マス」の「総量」は一定のまま「総数」が減少していく とは?

総量と総数、似たようで少し意味が違います。
総量はNFTの最大発行枚数のことで、総数はNFTの最大発行枚数 - burnされた数 と捉えるほうがわかりやすいかもしれません。

最大発行枚数はどのように決まっているのか?それがどこで保証されているのか?を見ていきます。 mint 処理を見てみましょう

// Merge.sol


bool public _mintingFinalized;

// ...

function mint(uint256[] calldata values_) external onlyValidSender {
	// _mintingFinalizedがtrueの場合、mintができない
	require(!_mintingFinalized, "Merge: Minting is finalized.");        
	/// ...
}
     
// ...

// _mintingFinalizedをtrueにする。 falseにするメソッドは無い
function finalize() external onlyPak {
	thaw();        
	_mintingFinalized = true;        
}
    
// ...

_mintingFinalized のフラグの状態を見てmintできるかできないかを制御していて、最大発行枚数をコントロールしているわけではありませんでした。
_mintingFinalized は trueにすることはできるがfalseにすることはできない(メソッドが存在しないため)ので、一度 finalize() メソッドを呼んだ時点で最大発行枚数が決まることになります。

このことから、「総量」が一定のまま ということがわかります。

また、マスの総数はマージされた時点で2つのうち値が小さいほうのマスがburnされるため、総数が減少していくことになります。

描画処理はどうやってる?

マスをマージしていくと段々大きくなっていく仕組みのようです。これはどうなっているのか、openseaに表示されている画像のデータを見てみます。

svgファイルのようです。NFTの画像メタデータには実は png, jpeg, mp4, gif 以外にも、HTMLファイルやSVGファイルを格納することもでき、URLではなくbase64エンコーディングした生データも格納できます。(生データの場合はガス代が高くなる。また、表示できるかどうかはマケプレ次第)

URLはopenseaのものですがこれはopenseaにsvgファイルが保存されているわけではなく、あくまでopenseaが保持しているキャッシュファイルです。

実体はどこにあるのか?

それは tokenURI() に書かれています

// Merge.sol
function tokenURI(uint256 tokenId) public virtual view override returns (string memory) {
	require(_exists(tokenId), "ERC721: nonexistent token");

	// tokenMetadataを呼び出している
	return _metadataGenerator.tokenMetadata(
	    tokenId, 
	    decodeClass(_values[tokenId]), 
	    decodeMass(_values[tokenId]), 
	    decodeMass(_values[_alphaId]), 
	    tokenId == _alphaId,
	    getMergeCount(tokenId));
}
    
// MergeMetadata.sol

function tokenMetadata(uint256 tokenId, uint256 rarity, uint256 tokenMass, uint256 alphaMass, bool isAlpha, uint256 mergeCount) external view override returns (string memory) {        
	string memory base64Json = Base64.encode(bytes(string(abi.encodePacked(_getJson(tokenId, rarity, tokenMass, alphaMass, isAlpha, mergeCount)))));

	// metadata jsonを作成しbase64エンコードしたものを返却している
	return string(abi.encodePacked('data:application/json;base64,', base64Json));
}

svgデータはコントラクト内で生成され、それを元にしたmetadata.jsonを作り、base64エンコーディングしたものをtokenURIとして返しているようです。
これはいわゆるフルオンチェーンNFTと呼ばれている部分ですがここの詳しい解説は長くなってしまうので省略します。 参考

まとめ

  • 買い占めるほどデカくなるNFT merge. の技術的な仕様を紐解いた
  • 気になる点4つを解説した
    • 「マス」の初期値は?
    • どのタイミングで「マス」が「マージ」される?
    • 「マス」の「総量」は一定のまま「総数」は減少していく とは?
    • 描画処理は?

最後に

コントラクト内にはもっと細かい制御処理が書かれていますが、今回は省かせていただきました。
さらに興味のある方は以下からコントラクトのコードを読んでみてください。

https://etherscan.io/address/0xc3f8a0f5841abff777d3eefa5047e8d413a1c9ab#code

etc

Solidityについてワイワイ学ぶコミュニティ「solidity-jp」を作りました!
いまから学んでみたい/学習中だけどの日本語の情報が少ない/古くて時間がかかっているという方、一緒に学びましょう〜!!

https://solidity-jp.dev/

また、TwitterにてSolidityについて技術的な部分を発信しています。良ければフォローお願いします!

https://twitter.com/k0uhashi

Discussion