📖

Chapter 14: ERC20、ERC721を実装する | Solidity Programming Essentialsを読む

2022/08/07に公開

イーサリアムの知識を整理するために2022年6月発売のSolidity Programming Essentials 2nd Editionを読み進める試みです。この記事ではChapter14「Writing Token Contracts」を読み進めます。いよいよ残るところ1Chapterになりました。

Solidity Programming Essentials: A guide to building smart contracts and tokens using the widely used Solidity language, 2nd Edition (English Edition)

読書ログは以下のスクラップで逐次更新していきます。
https://zenn.dev/mah/scraps/ea8c79961ae8c8


この章で扱われるトピックは以下の通りです。

  • Creating a new ERC20 token
  • Creating a new ERC721 token
  • Using ERC165
  • Using ERC223

ERC20トークン

ERCとはEthereum foundationが公開している規格のことで、特定のシナリオやユースケースに関するルールや標準を列挙した一連の文章です。

ERCは複数あり、それぞれ番号を使って参照されます。例えばERC20はイーサリアム上でファンジブルトークン(代替可能なトークン:例えば1ETHは誰が持っていても価値が同じであるため、代替可能である)を定義するためのルールと標準を列挙した文章です。同様にERC721はノンファンジブルトークン(代替不可能なトークン:いわゆるNFT、一点物の財産のこと)を定義するためのルールと標準を列挙した文章です。

他にも様々なERCがありますが、本章では一番ポピュラーなERC20とERC721を扱います。

しかし、そもそもなぜトークンに標準規格が必要なのでしょうか?

標準規格がないとそれぞれのトークンがバラバラの仕様を持つことによってEtherや他のトークンとの相互運用性が損なわれ、結果としてイーサリアムのエコシステム内における暗号通貨の使い勝手と有効性を低下させることになります。仕様がバラバラなシステムと無数に連携しなければならないと考えると頭が痛くなりますよね。

標準規格があるおかげで、ERC20に則ってコントラクトを実装するだけでイーサリアムのエコシステム内で相互運用可能な新しいトークンを作り出すことができます。

ERC20標準

ERC20が求めるコントラクトの仕様は以下の通りです。

  • トークンは、あるアカウントから別のアカウントに送信または転送することができます。アカウントは外部アカウントでもコントラクトアカウントでも良い。
  • コントラクトに照会して、任意のアカウントのトークン残高を取得できます。通貨と同じように、コントラクトは誰でも自分のアドレスに対して保有するトークンの量を照会する機能を提供する必要があります。
  • すべてのアカウントで流通しているトークンの総量も問い合わせ可能です。
  • トークンは、あるアカウントから別のアカウントに使用されることを許可することができます。あるアカウントに代わって使用すること、とも言い換えられます。承認されたアカウントは、承認者によって承認された以上のトークンを使用することができないようにする必要があります。

ERC20で実装する関数とイベント

標準ではこれらの関数を実装することを求めています。各関数の概要についてはソースコード内のコメントに記しました。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract WorldToken {
  /**
   * トークンの名前を返す。
   */
  function name() public view returns (string) {}

  /**
   * トークンのシンボルを返す。ETHのように3文字程度のもの定義するのが一般的。
   */
  function symbol() public view returns (string) {}

  /**
   * 表現に使用する小数点以下の桁数を返す。
   * 例えば 2 に設定すると、505 トークンは 5.05 と表示されることになる。
   */
  function decimals() public view returns (uint8) {}

  /**
   * 利用可能なトークンの総供給量を返す。
   */
  function totalSupply() public view returns (uint256) {}

  /**
   * 引数で指定されたアドレスの残高を返す。
   */
  function balanceOf(address _owner) public view returns (uint256 balance) {}

  /**
   * 引数として受け取ったアドレスに、指定量のトークンを転送する。
   * 送信元は暗黙に msg.sender になる。
   */
  function transfer(address _to, uint256 _value)
    public
    returns (bool success)
  {}

  /**
   * 引数として受け取ったアドレスから指定のアドレスに、指定量のトークンを転送する。
   * この関数は他のアカウントから代理で実行することを承認されたアカウントによって呼び出される。
   */
  function transferFrom(
    address _from,
    address _to,
    uint256 _value
  ) public returns (bool success) {}

  /**
   * 指定のアドレスに指定量のトークンを転送する権限を与える。
   */
  function approve(address _spender, uint256 _value)
    public
    returns (bool success)
  {}

  /**
   * あるアドレスがあるアドレスに対して許可したトークンの量を返す。
   */
  function allowance(address _owner, address _spender)
    public
    view
    returns (uint256 remaining)
  {}

  /**
   * tranfer, transferFrom実行時に発火させるイベント。
   */
  event Transfer(address indexed _from, address indexed _to, uint256 _value);

  /**
   * approve実行時に発火させるイベント。
   */
  event Approval(
    address indexed _owner,
    address indexed _spender,
    uint256 _value
  );
}

ERC20トークンの実装

さて、早速このERC20トークンを実装していきましょう。トークンの名前やシンボルはコンストラクタで与える必要があります。また、トークンの初期供給量はコンストラクタの引数として与えられるようにしています。

constructor(uint256 totalSupply) {
  _totalSupply = totalSupply;
  owner = payable(msg.sender);
  balances[msg.sender] = _totalSupply;
  name = "WorldToken";
  symbol = "WOT";
}

これらの値はステート変数で管理する必要があります。各アドレスの残高、承認されたトークン量などをまとめると以下の通りです。

string public name;
string public symbol;
mapping(address => uint256) balances;
mapping(address => mapping (address => uint256)) allowed;
uint256 _totalSupply;
address payable owner;

トークンの全供給量はステート変数に保持することにしたので、totalSupply関数は以下のように書けます。

function totalSupply() public view returns (uint256) {
  return _totalSupply;
}

各アドレスの残高はbalancesマッピングを参照すれば取得できます。

function balanceOf(address _owner) public view returns (uint256 balance) {
  return balances[_owner];
}

transfer関数は呼び出しアカウントの残高を減らし、転送先アカウントの残高を増やします。実行後にTransferイベントを発火させます。transfer関数では送信元アドレスに十分な残高があることをチェックする必要があります。

function transfer(address _to, uint256 _value) public returns (bool success) {
  require(_value <= balances[msg.sender]);
  balances[msg.sender] = balances[msg.sender] - _value;
  balances[_to] = balances[_to] + _value;
  emit Transfer(msg.sender, _to, _value);
  return true;
}

次に実装するapproveallowancetransferFrom関数は互いに密接に関係しています。別のアカウントに対して代わりにトークンを転送することを許可するフローは以下の通りです。

  1. 元のトークン所有者アカウントが別のアカウントを承認し、そのアカウントが自分の代わりに支出することを、支出可能額と一緒に承認します。トークン所有者だけがこの操作を行うことができます。承認されたトークンはallowanceと呼ばれます。
  2. トークン所有者のアドレスと使用者のアドレスを入力すれば、誰でも承認のマッピングを参照して、使用者が利用できる残高を調べることができます。支出者は1回のトランザクションでallowanceの全額を使う必要はありません。トークン所有者に代わって、少しずつ残高を移動させることができます。
  3. トークン所有者のアカウントに代わって第三者アカウントが行うトークンの取引はtransferFrom関数を使用して行われます。

それではapprove関数の実装を見てみましょう。allowedマッピングのキーに所有者と使用者のアドレスを設定し、その値として支出可能額を持たせます。実行後はApprovalイベントを発火させます。

function approve(address _spender, uint256 _value)
  public
  returns (bool success)
{
  allowed[msg.sender][_spender] = allowed[msg.sender][_spender] + _value;
  emit Approval(msg.sender, _spender, _value);
  return true;
}

allowance関数ではシンプルに現在の支出可能額を参照することができます。

function allowance(address _owner, address _spender)
  public
  view
  returns (uint256 remaining)
{
  return allowed[_owner][_spender];
}

transferFrom関数はtransform関数と似ていますが、送信元のアドレスも引数に取ります。加えて転送するトークン量が支出可能額を下回っているかどうかを確認する必要があります。実行後はTransferイベントを発火させます。

function transferFrom(
  address _from,
  address _to,
  uint256 _value
) public returns (bool success) {
  require(_value <= balances[_from]);
  require(_value <= allowed[_from][msg.sender]);
  balances[_from] = balances[_from] - _value;
  allowed[_from][msg.sender] = allowed[_from][msg.sender] - _value;
  balances[_to] = balances[_to] + _value;
  emit Transfer(_from, _to, _value);
  return true;
}

これでWorldTokenはERC20に準拠したコントラクトとなりました。一方で、どのようにしてアカウントはWorldTokenを購入し、トークンの所有者になることができるでしょうか?

一つはERC20に準拠した関数ではありませんがEtherと引き換えにトークンを購入するためのカスタム関数buyTokensを実装することです。

function buyTokens() public payable returns (bool success) {
  require(msg.value > 0);
  uint256 num_of_tokens = ((msg.value / 1000000000000000000) * 10);
  balances[msg.sender] = balances[msg.sender] + num_of_tokens;
  balances[owner] = balances[owner] - num_of_tokens;
  owner.transfer(msg.value);
  return true;
}

この関数では1ETHに対して10WorldToken手に入るよう定めています。新しいトークンはownerアカウントから転送されるようにしています。

もう一つはpayableなreceive関数を実装することです。

receive() external payable {
  require(msg.value > 0);
  uint256 num_of_tokens = ((msg.value / 1000000000000000000) * 10);
  balances[msg.sender] = balances[msg.sender] + num_of_tokens;
  balances[owner] = balances[owner] - num_of_tokens;
  owner.transfer(msg.value);
}

buyTokens関数とreceive関数の主な違いは、buyTokensを利用する場合、呼び出し元によって明示的に呼び出される必要があることです。呼び出し側はそのような関数が呼び出し先コントラクトに存在することを知る必要があります。一方でreceiveは単純なEtherの転送時に呼び出されるため、呼び出し元がその存在を知る必要はありません。

ノンファンジブルトークン(NFT)

これまで実装してきたERC20トークンは同じ1ETHに価値の区別がないのと同じように、各トークンを区別して扱うことはありませんでした。しかしNFTでは全てのトークンが異なる価値を持ちます。

ここではSolidityを使用してERC721に準拠したNFTを構築していきます。

ERC721のインターフェース

ERC721標準は https://eips.ethereum.org/EIPS/eip-721 で入手することができます。標準ではインターフェースのみが定められており、実装は開発者に委ねられています。実装する必要のあるインターフェースは以下の通りです。

interface IERC721 {
  event Transfer(
    address indexed from,
    address indexed to,
    uint256 indexed tokenId
  );

  event Approval(
    address indexed owner,
    address indexed approved,
    uint256 indexed tokenId
  );

  event ApprovalForAll(
    address indexed owner,
    address indexed operator,
    bool approved
  );

  function balanceOf(address owner) external view returns (uint256 balance);

  function ownerOf(uint256 tokenId) external view returns (address owner);

  function safeTransferFrom(
    address from,
    address to,
    uint256 tokenId,
    bytes calldata data
  ) external;

  function safeTransferFrom(
    address from,
    address to,
    uint256 tokenId
  ) external;

  function transferFrom(
    address from,
    address to,
    uint256 tokenId
  ) external;

  function approve(address to, uint256 tokenId) external;

  function setApprovalForAll(address operator, bool _approved) external;

  function getApproved(uint256 tokenId)
    external
    view
    returns (address operator);

  function isApprovedForAll(address owner, address operator)
    external
    view
    returns (bool);
}

ERC721の実装

ERC721ではERC721と同時にERC165も実装するように求められているので、両インターフェースを実装していきます。

contract CryptoNFT is IERC165, IERC721 {}

NFTは名前とシンボル(NFT名の略称)を持ちます。これをコンストラクタで設定できるようにします。

string private _name;
string private _symbol;

constructor(string memory name_, string memory symbol_) {
  _name = name_;
  _symbol = symbol_;
}

コンストラクタで設定したステート変数のgetter関数を取得します。

function name() public view returns (string memory) {
  return _name;
}

function symbol() public view returns (string memory) {
  return _symbol;
}

次にNFTの所有権を管理するために以下のステート変数を定義しておきます。

mapping(address => uint256) internal ownedTokens;
mapping(uint256 => address) internal tokenOwner;
mapping(address => uint256) internal ownedTokensCount;

ownedTokensはアドレスまたは所有者からトークンIDへのマッピング、tokenOwnerはトークンIDからアドレスへのマッピング、ownedTokenCountはアドレスが所有するトークン数へのマッピングを保存しています。

ownedTokensマッピングとtokenOwnerマッピングの両方を実装する理由は、例えばownedTokensだけが実装されている場合にトークンIDに紐付く所有者アドレスを調べたい場合、ownedTokens全体をループして調べる必要があり、ガスの使用量が増大してしまうからです。できる限り処理を減らし、ガスを節約する必要があります。

ステート変数の準備ができたところでownerOf関数とbalanceOf関数を用意します。

function ownerOf(uint256 tokenId) public view override returns (address owner) {
  return tokenOwner[tokenId];
}

function balanceOf(address owner)
  external
  view
  override
  returns (uint256 balance)
{
  return ownedTokensCount[owner];
}

NFTコントラクトで実装すべき重要な関数はmint関数です。mint関数は新しいNFTを生成する役割を担います。この関数は生成するトークンの所有者を表すアドレスとトークンIDを受け取ります。トークンIDはNFTそのものを表す文字列であったり、URLであったり、メタデータと紐付く関連IDだったりします。どのような仕様にするかは開発者が決められます。

function mint(address to, uint256 tokenId) external {
  require(to != address(0), "address cannot be default null address");
  require(tokenId > 0, "token id cannot be zero or less!");
  ownedTokensCount[to] += 1;
  tokenOwner[tokenId] = to;
  ownedTokens[to] = tokenId;
  emit Transfer(msg.sender, to, tokenId);
}

コントラクトにNFTを生成する機能がある場合、トークンを破棄またはバーンすることもできるべきです。この関数はERC721の仕様の一部ではないので、開発者は必要に応じて自由に実装することができます。

function burn(uint256 tokenId) public {
  require(tokenId > 0, "token id cannot be zero or less!");
  address from = ownerOf(tokenId);
  tokenApprovals[tokenId] = address(0);
  ownedTokensCount[from] = ownedTokensCount[from] - 1;
  delete tokenOwner[tokenId];
  delete ownedTokens[from];
  emit Transfer(from, address(0), tokenId);
}

より堅牢で使いやすいものにするために、NFTコントラクトに実装すべき他のEIP仕様がいくつかあります。これにはEIP-165(Standard Interface Detection)EIP-223(Token standard)が含まれます。

次の変数は誰かが所有するトークンを操作するために別のアカウントを承認するのに役立ちます。

mapping(address => mapping(address => bool)) internal operatorApprovals;

外側のアドレスはトークン所有者のアドレスで、内側のアドレスは元の所有者に代わってトークンを管理する権限を持つアドレスです。Bool値によって承認のON/OFFを切り替えられる仕様です。

この変数にアクセスするために、ERC721ではsetApprovalForAll関数とisApprovedForAll関数が提供されています。

コントラクトは所有者に代わってトークンを使用、または転送することを承認されるアドレスを引数にとるsetApprovalForAll関数を実装する必要があります。この関数は所有者アカウントによってのみ呼び出される必要があります。

この関数は所有者が所有するすべてのトークンの認可を設定することに注意してください。単一のトークンについて認可を設定する場合、追加で関数を実装する必要があります。

また、コントラクトはisApprovedForAll関数を実装する必要があり、所有者と使用者のアドレスから認可状態を参照できるようにする必要があります。

function setApprovalForAll(address operator, bool approved) external override {
  require(
    operator != address(0),
    "operator address cannot be defaul null address"
  );
  operatorApprovals[msg.sender][operator] = approved;
  emit ApprovalForAll(msg.sender, operator, approved);
}

function isApprovedForAll(address owner, address operator)
  public
  view
  override
  returns (bool)
{
  return operatorApprovals[owner][operator];
}

トークンの機能の一つとして、トークンの所有者が使用者アカウントにそのトークンを管理する権限を与えられることが挙げられます。2つのアカウント間の関係を管理するためのステート変数が必要です。次のtokenApprovals変数はトークンIDとその使用者アドレスのマッピングを保存します。

mapping(uint256 => address) internal tokenApprovals;

更に使用者アドレスとトークンIDを引数にとるapprove関数と権限の状態を参照するgetApproved関数を用意し、認可状態を変更できるようにします。

function approve(address spender, uint256 tokenId) external override {
  require(tokenId > 0, "token id cannot be zero or less!");
  require(spender != address(0), "to address cannot be default null address");
  address owner = tokenOwner[tokenId];
  require(
    msg.sender == owner || operatorApprovals[owner][msg.sender],
    "not owner nor approved for all"
  );
  tokenApprovals[tokenId] = spender;
  emit Approval(owner, spender, tokenId);
}

function getApproved(uint256 tokenId)
  public
  view
  override
  returns (address spender)
{
  return tokenApprovals[tokenId];
}

ERC223

ERC20やERC721にはコントラクト間の通信において2つの問題があります。一つはコントラクトアカウントがトークンを受け取っても受け取り側に通知が来ないことと、もう一つはトークンを受け取ることを意図していないコントラクトアドレスに送信した場合にトークンが失われることです。これを改善する仕様がERC223です。

ERC223ではトークンを受信したときにイベントを発火させるtokenReceived関数の実装を求めています。以下がその例です。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract ERC223Recipient {
  event TokenReceived(address, uint256, bytes);

  function tokenReceived(
    address _from,
    uint256 _value,
    bytes memory _data
  ) public {
    emit TokenReceived(_from, _value, _data);
  }
}

ERC721では3種類の転送関数が用意されています。safeTransferFrom関数の2つのバリエーション(関数オーバーロード)とtransferFrom関数です。

safeTransferFrom関数はERC223の仕様を実装しており、宛先アドレスがコントラクトアドレスであるとき、そのアドレスを使ってコントラクトインスタンスを参照し、tokenReceived関数を呼び出してパラメータの値を渡します。さらにsafeTransferFrom関数のバリエーションとして引数に任意のメタデータ(bytes memory data)を渡せるようにしたものを用意します。

最終的にsafeTransferFrom関数はtransferFrom関数を呼び出すようにします。

function safeTransferFrom(
  address from,
  address to,
  uint256 tokenId
) external override {
  safeTransferFrom(from, to, tokenId, "");
}

function safeTransferFrom(
  address from,
  address to,
  uint256 tokenId,
  bytes memory data
) public override {
  uint256 length;

  assembly {
    length := extcodesize(to)
  }

  transferFrom(from, to, tokenId);

  if (length > 0) {
    ERC223Recipient(to).tokenReceived(from, tokenId, data);
  }
}

function transferFrom(
  address from,
  address to,
  uint256 tokenId
) public override {
  require(from != address(0), "from address cannot be default null address");
  require(to != address(0), "to address cannot be default null address");
  require(tokenId > 0, "token ID cannot be zero or less!");
  require(ownedTokensCount[from] > 0, "from address should own a token!");
  require(tokenOwner[tokenId] == from, "from address should own a token!");

  ownedTokensCount[from] = ownedTokensCount[from] - 1;
  ownedTokensCount[to] = ownedTokensCount[to] + 1;
  tokenOwner[tokenId] = address(0);
  tokenApprovals[tokenId] = address(0);
  tokenOwner[tokenId] = to;
  ownedTokens[to] = tokenId;

  emit Transfer(from, to, tokenId);
}

ERC165

ERC165の目的はコントラクトのインターフェースと関連する関数のメタデータを公開することです。このメタデータは呼び出し側がコントラクトと通信し、機能を呼び出すかどうかを判断するために使用することができます。

ERC165はsupportsInterface関数の仕様を提供しています。この関数はインターフェースIDを受け取り、コントラクトがそのインターフェースIDに対応するインターフェースを実装しているかどうかをBool値で返します。

interface IERC165 {
  function supportsInterface(bytes4 interfaceId) external view returns (bool);
}

インターフェースIDは4バイトのデータで構成されています。仕様ではインターフェース内の関数の関数識別子同士のXORをとった値の最初の4バイトをインターフェースIDとしていますが、Solidityではtype関数を利用して簡単にインターフェースIDを取得することができます。以下がそのコード例です。

function supportsInterface(bytes4 interfaceId)
  external
  pure
  override
  returns (bool)
{
  return
    interfaceId == type(IERC721).interfaceId ||
    interfaceId == type(IERC165).interfaceId;
} 

この章の内容は以上です。

Discussion