はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、様々な種類の複数のトークンを管理・送付できるERC721トークンの仕組みを提案しているERC3589についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なEIPについてまとめています。

概要

ERC3589は、「アセンブリトークン(Assembly Token)」と呼ばれるERC721トークンの拡張仕様を提案しています。 このトークンは、Ether、ERC20、ERC721、ERC1155トークンなどの複数のアセットを1つにまとめることができる特性を持ちます。

ERC1155では複数トークンのバッチ送付が可能ですが、これのトークンはERC1155コントラクト内で発行されている必要があります。 一方、ERC3589では、ERC721トークンのtokenIdがアセットの署名としても機能し、さまざまな種類の資産を1つに「組み立てる(assemble)」ことが可能になります。 これにより、一括送付やスワップが簡単に実装できるようになります。

動機

NFTアートやデジタルコレクティブルの人気が高まる中、従来の取引手段に不満を抱くコレクターが増えています。 例えば、2人のコレクターが互いのコレクションを交換したい場合、一般的には一方がNFTをマーケットに出品し、もう一方が購入するという手順になりますが、これは非効率かつガスコストが高い方法です。

そのため、信頼できる第三者を介してスワップを行う方法も使われていますが、第三者に両者のNFTを預けるのは極めてリスクが高いです。

この問題を解決するために、複数アセットを1つにまとめてからスワップすることで、1対1のアトミックスワップとして実現できるようにします。 その第一歩が、ERC3589で提案されている「アセンブル」機能の実装です。

仕様

インターフェース

AssemblyNFTInterface.sol
pragma solidity ^0.8.0;

interface AssemblyNFTInterface {

  event AssemblyAsset(address indexed firstHolder,
                    uint256 indexed tokenId,
                    uint256 salt,
                    address[] addresses,
                    uint256[] numbers);

  /**
  * @dev hash function assigns the combination of assets with salt to bytes32 signature that is also the token id.
  * @param `_salt` prevents hash collision, can be chosen by user input or increasing nonce from contract.
  * @param `_addresses` concat assets addresses, e.g. [ERC-20_address1, ERC-20_address2, ERC-721_address_1, ERC-1155_address_1, ERC-1155_address_2]
  * @param `_numbers` describes how many eth, ERC-20 token addresses length, ERC-721 token addresses length, ERC-1155 token addresses length,
  * ERC-20 token amounts, ERC-721 token ids, ERC-1155 token ids and amounts.
  */
  function hash(uint256 _salt, address[] memory _addresses, uint256[] memory _numbers) external pure returns (uint256 tokenId);

  /// @dev to assemble lossless assets
  /// @param `_to` the receiver of the assembly token
  function mint(address _to, address[] memory _addresses, uint256[] memory _numbers) payable external returns(uint256 tokenId);

  /// @dev mint with additional logic that calculates the actual received value for tokens.
  function safeMint(address _to, address[] memory _addresses, uint256[] memory _numbers) payable external returns(uint256 tokenId);

  /// @dev burn this token and releases assembled assets
  /// @param `_to` to which address the assets is released
  function burn(address _to, uint256 _tokenId, uint256 _salt, address[] calldata _addresses, uint256[] calldata _numbers) external;

}

AssemblyAsset

js
event AssemblyAsset(address indexed firstHolder, uint256 indexed tokenId, uint256 salt, address[] addresses, uint256[] numbers);

アセットの組み立てが完了した時に発行されるイベント。 このイベントは、誰がどのトークンIDでどのアセットを組み立てたかを明示的に記録します。 アセットのアドレスと数量情報を配列で保持し、構成内容を記録します。

パラメータ

  • firstHolder
    • 組み立てたアセットの最初の所有者アドレス。
  • tokenId
    • 組み立てたアセットに割り当てられたERC721トークンID。
  • salt
    • ハッシュの衝突を避けるためのソルト値。
  • addresses
    • 各種アセット(ERC20, ERC721, ERC1155など)のアドレス配列。
  • numbers
    • 各アセットの数量や識別子などの数値情報を持つ配列。

hash

js
function hash(uint256 _salt, address[] memory _addresses, uint256[] memory _numbers) external pure returns (uint256 tokenId);

アセット構成とソルトからトークンID(ハッシュ値)を計算する関数。 この関数は、指定されたアセット情報とソルトをもとに、ユニークなトークンIDを生成します。 これにより同一のアセット構成に対して同一のIDが割り当てられ、二重発行や衝突を防ぎます。

引数

  • _salt
    • ハッシュ衝突を避けるためのソルト値。
    • ユーザー入力やコントラクト側で自動インクリメントされる可能性があります。
  • _addresses
    • アセットのコントラクトアドレス群(ERC20, ERC721, ERC1155など)。
  • _numbers
    • 各アセットに関する数量情報(Ether、トークン量、トークンIDなど)。

戻り値

  • tokenId
    • アセット構成に対応するユニークなトークンID(uint256型)。

mint

js
function mint(address _to, address[] memory _addresses, uint256[] memory _numbers) payable external returns(uint256 tokenId);

アセットを組み立ててERC721トークンを発行する関数。 この関数は、transferで正確に送金できるトークンを前提に、指定されたアセットを1つのERC721トークンにまとめます。 呼び出し後、生成されたトークンIDを返します。

引数

  • _to
    • 組み立て後のアセットを受け取るアドレス。
  • _addresses
    • 各アセットのコントラクトアドレス群。
  • _numbers
    • アセットの数量やIDなどの情報を含む配列。

戻り値

  • tokenId
    • 組み立てられたアセットに割り当てられたERC721トークンID。

safeMint

js
function safeMint(address _to, address[] memory _addresses, uint256[] memory _numbers) payable external returns(uint256 tokenId);

数料付きトークンなどを安全に組み立ててトークンを発行する関数。 この関数は、送金時に手数料が差し引かれるようなトークンに対応します。 トークン受領後の手数料を考慮したロジックが含まれており、より安全なアセット組み立てを実現します。

引数

  • _to
    • 組み立て後のアセットを受け取るアドレス。
  • _addresses
    • 各アセットのコントラクトアドレス群。
  • _numbers
    • アセットの数量やIDなどの情報を含む配列。

戻り値

  • tokenId
    • 組み立てられたアセットに対応するERC721トークンID。

burn

js
function burn(address _to, uint256 _tokenId, uint256 _salt, address[] calldata _addresses, uint256[] calldata _numbers) external;

組み立て済みのアセットを解体して指定アドレスへ戻す関数。 この関数は、保有するアセンブリトークン(ERC721)をBurnすることで、その中に含まれていたアセットをもとの状態に戻し、指定されたアドレスに返却します。

引数

  • _to
    • 解体後のアセットを受け取るアドレス。
  • _tokenId
    • 解体対象のERC721トークンID。
  • _salt
    • 組み立て時に使用されたソルト値。
  • _addresses
    • 組み立て時に使用されたアセットのコントラクトアドレス群。
  • _numbers
    • 組み立て時に使用された数量やID情報。

補足

NFTを1つにまとめるニーズは多様です。 例えば、あるコレクターはサッカー選手のNFTをまとめて「チーム」として保有したいと考えるかもしれません。 また、何百ものNFTを保有していてカテゴリ管理が難しい場合や、あるコレクションを「全て欲しい」または「全くいらない」といった要望合にもNFTを一つにまとめて管理したいというニーズが生まれます。

このようなニーズに応えるために、ERC3589ではERC721トークンをラッパーとして採用しています。 なぜなら、ERC721はすでに多くのウォレットでサポートされており、ユーザーの利便性が高いからです。 さらに、Assembly Token自体も再びアセットとして他のアセンブリに組み込むことが可能です。 つまり、入れ子構造に対応しており、柔軟な活用が可能です。

スマートコントラクトにとっても、バラバラのアセット群を管理するより、ひとまとめになったトークン(アセンブリトークン)を扱う方が処理が簡単です。 例えば、バッチでの売買やスワップ、コレクション単位での交換などに適しています。

ERC3589では、アセンブリトークンが保持する資産の種類と数量を正確に記録する AssemblyAsset イベントが用意されています。 これにより、ウォレットはトークンIDを見るだけで、どのアセットが含まれているかを表示できます。

互換性

ERC721と互換性があります。

参考実装

AssemblyNFT.sol
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol";
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
import "./AssemblyNFTInterface.sol";

abstract contract AssemblyNFT is ERC721, ERC721Holder, ERC1155Holder, AssemblyNFTInterface{
  using SafeERC20 for IERC20;

  function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, ERC1155Receiver) returns (bool) {
        return ERC721.supportsInterface(interfaceId) || ERC1155Receiver.supportsInterface(interfaceId);
  }

  uint256 nonce;

  /**
  * layout of _addresses:
  *     erc20 addresses | erc721 addresses | erc1155 addresses
  * layout of _numbers:
  *     eth | erc20.length | erc721.length | erc1155.length | erc20 amounts | erc721 ids | erc1155 ids | erc1155 amounts
   */

  function hash(uint256 _salt, address[] memory _addresses, uint256[] memory _numbers) public pure override returns (uint256 tokenId){
      bytes32 signature = keccak256(abi.encodePacked(_salt));
      for(uint256 i=0; i< _addresses.length; i++){
        signature = keccak256(abi.encodePacked(signature, _addresses[i]));
      }
      for(uint256 j=0; j<_numbers.length; j++){
        signature = keccak256(abi.encodePacked(signature, _numbers[j]));
      }
      assembly {
        tokenId := signature
      }
  }

  function mint(address _to, address[] memory _addresses, uint256[] memory _numbers) payable external override returns(uint256 tokenId){
      require(_to != address(0), "can't mint to address(0)");
      require(msg.value == _numbers[0], "value not match");
      require(_addresses.length == _numbers[1] + _numbers[2] + _numbers[3], "2 array length not match");
      require(_addresses.length == _numbers.length -4 - _numbers[3], "numbers length not match");
      uint256 pointerA; //points to first erc20 address, if there is any
      uint256 pointerB =4; //points to first erc20 amount, if there is any
      for(uint256 i = 0; i< _numbers[1]; i++){
        require(_numbers[pointerB] > 0, "transfer erc20 0 amount");
        IERC20(_addresses[pointerA++]).safeTransferFrom(_msgSender(), address(this), _numbers[pointerB++]);
      }
      for(uint256 j = 0; j< _numbers[2]; j++){
        IERC721(_addresses[pointerA++]).safeTransferFrom(_msgSender(), address(this), _numbers[pointerB++]);
      }
      for(uint256 k =0; k< _numbers[3]; k++){
        IERC1155(_addresses[pointerA++]).safeTransferFrom(_msgSender(), address(this), _numbers[pointerB], _numbers[_numbers[3] + pointerB++], "");
      }
      tokenId = hash(nonce, _addresses, _numbers);
      super._mint(_to, tokenId);
      emit AssemblyAsset(_to, tokenId, nonce, _addresses, _numbers);
      nonce ++;
  }

  function safeMint(address _to, address[] memory _addresses, uint256[] memory _numbers) payable external override returns(uint256 tokenId){
      require(_to != address(0), "can't mint to address(0)");
      require(msg.value == _numbers[0], "value not match");
      require(_addresses.length == _numbers[1] + _numbers[2] + _numbers[3], "2 array length not match");
      require(_addresses.length == _numbers.length -4 - _numbers[3], "numbers length not match");
      uint256 pointerA; //points to first erc20 address, if there is any
      uint256 pointerB =4; //points to first erc20 amount, if there is any
      for(uint256 i = 0; i< _numbers[1]; i++){
        require(_numbers[pointerB] > 0, "transfer erc20 0 amount");
        IERC20 token = IERC20(_addresses[pointerA++]);
        uint256 orgBalance = token.balanceOf(address(this));
        token.safeTransferFrom(_msgSender(), address(this), _numbers[pointerB]);
        _numbers[pointerB++] = token.balanceOf(address(this)) - orgBalance;
      }
      for(uint256 j = 0; j< _numbers[2]; j++){
        IERC721(_addresses[pointerA++]).safeTransferFrom(_msgSender(), address(this), _numbers[pointerB++]);
      }
      for(uint256 k =0; k< _numbers[3]; k++){
        IERC1155(_addresses[pointerA++]).safeTransferFrom(_msgSender(), address(this), _numbers[pointerB], _numbers[_numbers[3] + pointerB++], "");
      }
      tokenId = hash(nonce, _addresses, _numbers);
      super._mint(_to, tokenId);
      emit AssemblyAsset(_to, tokenId, nonce, _addresses, _numbers);
      nonce ++;
  }

  function burn(address _to, uint256 _tokenId, uint256 _salt, address[] calldata _addresses, uint256[] calldata _numbers) override external {
      require(_msgSender() == ownerOf(_tokenId), "not owned");
      require(_tokenId == hash(_salt, _addresses, _numbers));
      super._burn(_tokenId);
      payable(_to).transfer(_numbers[0]);
      uint256 pointerA; //points to first erc20 address, if there is any
      uint256 pointerB =4; //points to first erc20 amount, if there is any
      for(uint256 i = 0; i< _numbers[1]; i++){
        require(_numbers[pointerB] > 0, "transfer erc20 0 amount");
        IERC20(_addresses[pointerA++]).safeTransfer(_to, _numbers[pointerB++]);
      }
      for(uint256 j = 0; j< _numbers[2]; j++){
        IERC721(_addresses[pointerA++]).safeTransferFrom(address(this), _to, _numbers[pointerB++]);
      }
      for(uint256 k =0; k< _numbers[3]; k++){
        IERC1155(_addresses[pointerA++]).safeTransferFrom(address(this), _to, _numbers[pointerB], _numbers[_numbers[3] + pointerB++], "");
      }
  }

}

セキュリティ

mint や safeMint 関数を使用する時にはユーザーは注意が必要です。 一部のトークン実装は一時停止(pausable)機能を持っており、アセンブリトークンに含まれた後で個別のアセットが一時停止されると、burn 関数で元のアセットを解放できなくなる可能性があるためです。

この問題を回避するために、ERC3589を利用するプラットフォームは、サポート対象のトークンを事前にホワイトリストに登録したり、問題があるトークンをブロックリストで管理したりすることが推奨されます。 このような運用により、アセットの凍結によってユーザーの操作が妨げられる事態を防ぐことができます。

引用

Zhenyu Sun (@Ungigdu), Xinqi Yang (@xinqiyang), "ERC-3589: Assemble assets into NFTs [DRAFT]," Ethereum Improvement Proposals, no. 3589, May 2021. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3589.

最後に

今回は「様々な種類の複数のトークンを管理・送付できるERC721トークンの仕組みを提案しているERC3589」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!