Smart Contract Solidity ブロックチェーン

ERC20Permitについて非エンジニアでもわかるように1から丁寧にわかりやすく解説

かるでね

こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。

今回は「ERC20Permit」について解説していきます!

ERC20Permit」を理解する上で必要な知識についてもこの記事では取り上げているため、過去に理解できなかった人でもこの記事であれば理解できるようにしています。

丁寧に解説している分だいぶ長くなってしまっているので時間があるときに一気に読むのをお勧めします。

それでは早速中身を見ていきましょう!

ERC20

ERC20Permitを理解するには、まず前提としてERC20について理解しておく必要があります。

ERC20については以下の記事にまとめているので、まだERC20についてしっかり理解できていない人はぜひ読んでみてください。

トランザクションとは?

次にトランザクションが何かを理解する必要があります。

この部分はなんとなく理解している人が多いのではないでしょうか?

トランザクションについて、bitbankの用語集には以下のように書かれています。

仮想通貨におけるトランザクション(Transaction)とは簡単にいえば取引のことで、前の持ち主から受け取った取引のハッシュ値と、送り先のアドレスなどを含め、所有者の秘密鍵で電子署名したものを言います。

https://bitbank.cc/glossary/transaction

トランザクションとは、一言で言えば「ある特定の取引」のことであることがわかります。

トランザクションには、送金したいユーザーの所有する秘密鍵を使用して作成した署名と、送作のアドレスや1つ前のトランザクションのハッシュ値、取引内容などのデータが入っています。

上記はビットコインの例で、Ethereumの場合はトランザクションに含まれるデータが異なってきます。

Ethereumの場合は、ガス価格や送信先のアドレス、送信するETHの量、トランザクションを発行するアドレスによる署名などがトランザクションに含まれています。

そもそもビットコインとEthereumでトランザクションの構成が違うことを初めて知った人もいるのではないでしょうか?

UTXO

ビットコインはUTXO(Unspent Transaction Output)という仕組みを使用して、コインの残高や取引履歴を管理しています。

UTXOとは、「未使用トランザクションアウトプット」と呼ばれ、「特定の所有者が保持している分割不可能なビットコインの塊」としてブロックチェーンに記録されています。

つまり、ビットコインはどこか口座などに残高として記録されているわけではなく、取引データの中に散らばっているUTXOを集めて残高を計算しています。

もう少しだけ詳しく説明すると、取引データはインプットとアウトプットの2つから構成され、トランザクションのインプットの合計額とアウトプットの合計額は等しくなります。

このアウトプットのトランザクションが「未使用トランザクションアウトプット(UTXO)」であり、次に受け取る人にとってはインプットとなります。

ブロックの中の取引データはこれが永遠に繰り返されています。

具体例を見ていきましょう。

Aさんが合計30BTCを持っているとします。

ただ、先ほど説明したようにインプット部分では30BTCではなく、複数のインプットに分割されています。

AさんからBさんに10BTCを支払うとします。

この時10BTCだけ渡すのではなく、分割された25BTC全てを渡し、計算して余った15BTCを送り元のAさんに再度送っています。

ビットコインではこのように、インプットの時点で一旦全ての資金を消費し、再度自分自身や送り先の別のアドレスに資金を送っています。

上記の説明を図にしたのが以下の図になります。

左がインプットで右がアウトプットです。

インプットとアウトプットの合計金額が同じなのが確認できます。

ビットコインのブロックではこれが繰り返されていっています。

この記事ではこれ以上深く取り扱わないので、より詳しくは以下の記事を参考にしてください。

アカウントステート

EthereumではUTXOというモデルを使用せずに、アカウントステートというモデルを使用しています。

アカウントステートを理解する前に2つのアカウントについて解説しています。

EthereumにはEOA(外部所有)アカウントとコントラクトアカウントの2つが存在します。

EOAアカウント

ウォレットに紐づく、秘密鍵によって制御されています。

トランザクションを生成することができます。

コントラクトアカウント

秘密鍵を持っておらず、スマートコントラクトの実行コードを持っていて、スマートコントラクトによって制御されています。

ETHを受け取ることはできますが、トランザクションを生成することができません。

では、アカウントステートに話を戻しましょう。

Ethereumはトランザクションに基づいたステート(状態)という概念があり、新たにトランザクションが生成されるごとにステート(状態)が変化していきます。

このステート(状態)のことをアカウントステートといいます。

アカウントステートは以下の4つのデータを持っています。

データ

  • nonce: アカウントが送ったトランザクションの数。コントラクトアカウントの場合は、アカウントが作成したコントラクトの数。
  • Balance: アドレスが持っている資金の量。
  • Storage Root: アカウントが持っているデータのマークルツリーのルートハッシュ。
  • Code Hash: EOAの場合は空文字のハッシュで、コントラクトアカウントの場合は、そのアカウントが所有しているコードのハッシュ。

マークルツリーについては以下の記事を参考にしてください。

ビットコインでは、残高のデータではなく、残高を出すのに必要な計算データだけ持っています。それに対しEthereumでは、残高を出すのに必要なデータに加え、残高も保持しているためUTXOと比べてデータ量が多くなります。

これはEthereumではスマートコントラクトを使用してアプリケーションを作成できるため、各トランザクションからその時点の残高を取得しやすいようにしているのではないかと考えています。

ちなみにUTXOでは、アウトプットから次のインプットが作られるため、複数の全く同じトランザクションが通る心配はないが、Ethereumだと通ってしまいます。

そのため、nonceという「何番目のトランザクション」かというデータを含めることで、複数の全く同じトランザクションが通らくなるようにしています。

例えばnonceの値が100だとして、同じ100というnonceを持つデータは処理されず、同じ処理でもnonceが101であれば別のトランザクションとみなされて処理されます。

ビットコインは先ほど説明したように、インプットの値を全て送り、送り先がないものについては自分自身に送っているため、同じ処理を複数回通すことがデータの構造上不可能です。

一方、Ethereumの場合は、ステート(状態)でデータを管理しているため、十分な資金があれば同じ処理を通すことができてしまうので、nonceという値を用いてそれを防いでいます。

前提知識としてトランザクションについて解説しましたが、ここだけでだいぶ長くなってしまいました…。

疲れた人は一旦休憩して先を読んでください!

メタトランザクションとは

次にメタトランザクションについて理解していきましょう。

メタトランザクションとは、簡単にいうと「ガスを支払うことなくトランザクションを実行できる」ことです。

ガスを支払う必要がないとなると誰にとってもメリットに感じますよね。

ただし実際ガス代が全く不要になるわけではありません。

正確には、以下の画像のように支払うガス代を第3者に肩代わりしてもらっています。

そもそもガス代を仲介してくれるユーザーなんているのでしょうか?

例えば、ブロックチェーンゲームにおいてユーザーにガス代を負担させてしまうと、UXが悪くなります。

そこで運営がガス代を負担することで、ユーザーは気軽にゲームを楽しむことができるようになります。

ではどのようにしてガス代を第3者に負担してもらっているのでしょうか?

ブロックチェーンゲームを想定すると、手順は以下になります。

手順

  • ユーザー向けのフロントエンドを作成しユーザーに提供。
  • ユーザーは自分が所有している秘密鍵でメッセージに署名する。ただし、署名のみのためガスがかからず、署名済みのメッセージと署名の情報を運営に送る。
  • 運営はメッセージの署名情報とnonce値を確認し、問題なければ運営が所有している秘密鍵で署名しトランザクションを生成して、コントラクトを呼び出して実行する。
  • 運営は正常にトランザクションが実行されたかを確認できたら、ユーザーにも実行が完了したことを通知する。

上記を図にすると以下のようになります。

このようにするとユーザーは秘密鍵を知られず、ガス代もかからず処理を実行できます。

この一連の処理は、EVMが行なっていることをスマートコントラクトで実行しているだけとも言えます。

ECDSAという方式でユーザーのアドレスが持つ秘密鍵で署名をし、署名を受け取ったコントラクトで署名をもとに公開鍵を導き出して、どのユーザーが署名をしたのか検証してトランザクションを実行しています。

以上がメタトランザクションについての説明です。

ERC20Permitとは

ではやっと本題のERC20Permitの説明に入っていきます。

ERC20Permitとは、ERC20の拡張機能です。

通常、transferFrom(特定のアドレスから特定のアドレスへERC20トークンを送る)関数を呼び出してERC20トークンを送るとき、あらかじめapprove(自分が所有しているERC20トークンを送る許可を他のアドレスに与える)関数をトランザクションで呼び出す必要があります。

この手順はユーザーからすると手間がかかります。

なぜなら2つのトランザクションが発生するため、2回ウォレットのサインを求められ、ガス代を支払うために十分なETHを保有する必要があります。

この問題を解決するのがERC20Permitです。

ERC20Permitのざっくりとした処理は以下の手順で行われます。

手順

  • オフチェーン上でユーザーのウォレットの秘密鍵でメッセージに署名してもらい、署名データと署名したメッセージの情報をコントラクトに渡しながら、ガス代を負担するアドレスで署名してトランザクションを生成する。
  • コントラクトでは、渡された署名データと署名したメッセージの情報をもとに、署名したアドレスの公開鍵を再生成し、送りたいERC20トークンの保有者か確認します。
  • 確認ができたのち、トランザクションを生成し、approve関数を実行して他のアドレスに署名したアドレスが保有するERC20トークンの指定量分の送金許可を別アドレスに許可します。
  • ERC20トークンの送金許可されたアドレスから、transferFrom関数を実行して別のアドレスへ実際にERC20トークンを送金します。

このメリットとしては、ユーザーはETHを保有する必要がなく、ガス代も負担する必要がない点です。

また、approve関数の処理とtransferFrom関数の処理を1つのトランザクションにまとめることができ、トランザクションの回数が1回で済みます。

署名部分ではEIP712の理解が必要なので今後別記事でまとめていきます。

ちなみになぜtransfer関数を実行して直接ERC20トークンを送ることをせず、approve関数とtransferFrom関数を実行するという一見回りくどい方法をとっているのでしょう?

この理由としては、ERC20Permit自体がDEXとコントラクトのより使いやすくするために提案されたものなのであり、もしtransfer関数を実装すると、開発者はDEXの処理を変更しなければいけなくなってしまうためです。

EIP2612

EIPとは

EIPとは 「Ethereum Improvement Proposals」の略で、「Ethereumの新しい機能やプロセスに関する提案を規定する標準規格」のことです。

EIPの細かい説明は以下に書かれています。

簡単に言うと以下になります。

EIPは、Ethereum Improvement Proposalsの略でイーサリアムをより良いものにするために議論される改善提案のこと。イーサリアムは、特定の誰かによって管理されるものではないため、世界中の誰もがEIPを提出することで、イーサリアムの発展に貢献することができる。

20という数字は20番目の提案ということです。

EIP2162

EIP2162とは、ERC20Permitの元となった提案です。

ERC20に以下の3つの関数を追加しています。

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external
function nonces(address owner) external view returns (uint)
function DOMAIN_SEPARATOR() external view returns (bytes32)

1つずつ紹介していきます。

permit

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external

ERC20トークンの保有者かつ、オフチェーンで署名したユーザーのアドレスがownerに渡されます。

spenderには、ERC20トークンの送金許可を与えたりアドレスを格納します。

valueにはどれくらいの量のERC20トークンの送金許可を与えるか指定し、deadlineにはいつまでに処理が有効か渡します。

v, r, sには、署名したユーザーのアドレスの公開鍵を復元するために必要な情報が格納されます。

この関数で前章で解説した処理を実行しています。

条件

  • 現在のタイムスタンプはdeadline以下である。
  • ownerには0アドレスを指定できない。
  • アドレスごとのnonce値を格納しているnonces配列の値が、配列の更新前と等しい。
  • v, r, sは、メッセージのownerによる有効なsecp256k1署名である。

secp256k1署名について詳しくは以下の記事が参考になります。

nonces

function nonces(address owner) external view returns (uint)

アドレスごとのnonce値を格納しているnonces配列から、ownerに渡されたアドレスのnonce値を返す関数。

nonceというトランザクションごとに異なる値を使用することで、同じトランザクションを複数回と生成されるのを防ぐことができます。

DOMAIN_SEPARATOR

function DOMAIN_SEPARATOR() external view returns (bytes32)

EIP712で管理されているメタデータのハッシュ値を返す関数。

メタデータは以下のような構造体になっています。

{
  "types": {
    "EIP712Domain": [
      {
        "name": "name",
        "type": "string"
      },
      {
        "name": "version",
        "type": "string"
      },
      {
        "name": "chainId",
        "type": "uint256"
      },
      {
        "name": "verifyingContract",
        "type": "address"
      }
    ],
    "Permit": [{
      "name": "owner",
      "type": "address"
      },
      {
        "name": "spender",
        "type": "address"
      },
      {
        "name": "value",
        "type": "uint256"
      },
      {
        "name": "nonce",
        "type": "uint256"
      },
      {
        "name": "deadline",
        "type": "uint256"
      }
    ],
    "primaryType": "Permit",
    "domain": {
      "name": erc20name,
      "version": version,
      "chainId": chainid,
      "verifyingContract": tokenAddress
  },
  "message": {
    "owner": owner,
    "spender": spender,
    "value": value,
    "nonce": nonce,
    "deadline": deadline
  }
}}

この情報は署名の検証に必要となります。

ERC20Permitのコード確認

EIP20Permitについて確認してきたので、この章では実際にERC20Permitのコードを確認していきましょう。

今回はOpenzeppelinの以下のコードをもとに1つずつ解説していきます。

読み込み

pragma solidity ^0.8.0;

import "./IERC20Permit.sol";
import "../ERC20.sol";
import "../../../utils/cryptography/ECDSA.sol";
import "../../../utils/cryptography/EIP712.sol";
import "../../../utils/Counters.sol";

Solidityのバージョンと他のコントラクトを読み込んでいます。

IERC20Permit.sol

ERC20PermitInterfaceが定義されています。

interfaceとは、コントラクトに似ていますが実行することはできません。

interfaceには関数名やひきすうなどのみ定義されていて中身は一切定義されていません。

そのため、interfaceを継承したコントラクト内で関数を再度定義して中身を記述する必要があります。

interfaceいらなくね?

こう思う方もいると思います。

interfaceを使用するメリットは、実装で必要な関数を確認できることにあります。

最初で述べたように、interfaceには実装する上で必要な関数が定義されているので、開発者やコントラクトを実行するユーザーからどんな機能があるのかを簡単に確認できます。

有名なプロジェクトでもinterfaceはよく使用されているので、この機会に理解しておくと後々役に立ちます。

公式ドキュメントは以下になります。

ERC20.sol

ERC20コントラクトを定義しています。

ERC20トークンを発行したり、送ったりできます。

ECDSA.sol

メッセージに署名したアドレスを復元するrecover関数を使用するために読み込んでいます。

EIP712.sol

ユーザーは、EIP712のデータ構造を使用してメッセージに署名をして渡します。

Counters.sol

安全にインクリメント・デクリメントができるカウンタを使用するために読み込んでいます。

変数定義

abstract contract ERC20Permit is ERC20, IERC20Permit, EIP712 {
    using Counters for Counters.Counter;

    mapping(address => Counters.Counter) private _nonces;

    bytes32 private constant _PERMIT_TYPEHASH =
        keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 private _PERMIT_TYPEHASH_DEPRECATED_SLOT;

ERC20Permitという名前のコントラクトを定義していますが、abstractというものがついています。

これはabstractコントラクトを他のコントラクトで継承してもらい、abstractコントラクト内では中身がない関数を定義し、継承先で必ず上書きしないといけないにすることができます。

ERC20Permit内には中身が記述されていない関数がないため、他の理由でabstractにしているはずですが明確な理由はわかりませんでした。

_nonces

各アドレスごとのnonce値を格納。

_PERMIT_TYPEHASH

署名したアドレスを生成するために必要なデータのハッシュ値です。

_PERMIT_TYPEHASH_DEPRECATED_SLOT

_PERMIT_TYPEHASH変数はconstantとついているように変更ができません。

そのため、_PERMIT_TYPEHASHを変更できるように定義した変数。

constructor

constructor(string memory name) EIP712(name, "1") {}

EIP712コントラクトのconstructorを実行。

nameはトークン名で、version1を指定しています。

permit

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) public virtual override {
    require(block.timestamp <= deadline, "ERC20Permit: expired deadline");

    bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));

    bytes32 hash = _hashTypedDataV4(structHash);

    address signer = ECDSA.recover(hash, v, r, s);
    require(signer == owner, "ERC20Permit: invalid signature");

    _approve(owner, spender, value);
}

ownerに渡されたアドレスと署名したアドレスが同じかを検証し、spenderに渡されたアドレスにownerが所有するERC20トークンをvaluetransfer関数を実行して送金する許可を与える関数。

10行目では、現在のタイムスタンプがdeadlineに渡された値より小さいか確認しています。

12行目では、署名したアドレスを再生成するために、EIP712に順序した構造体データをエンコードしてハッシュ化しています。

14行目で、ハッシュ化された構造体データを完全にエンコードしたハッシュを返します。

16行目で、先ほどのハッシュ化したデータとvrsというトランザクションの署名と公開鍵の復元に関係する値(正確にはrsはECDSA署名の出力で、vはリカバリーIDと呼ばれる値)を渡して、署名したアドレスの公開鍵を取得しています。

17行目で、署名したアドレスと復元したアドレスが同じか確認し、同じであれば19行目で_approve関数を実行して、spenderに渡されたアドレスにownerが所有するERC20トークンをvaluetransfer関数を実行して送金する許可を与えています。

nonces

function nonces(address owner) public view virtual override returns (uint256) {
    return _nonces[owner].current();
}

_nonces配列からownerに渡されたアドレスのnonce値を取得する関数。

DOMAIN_SEPARATOR

function DOMAIN_SEPARATOR() external view override returns (bytes32) {
    return _domainSeparatorV4();
}

EIP712で管理されているメタデータのハッシュ値を返す関数。

_useNonce

function _useNonce(address owner) internal virtual returns (uint256 current) {
    Counters.Counter storage nonce = _nonces[owner];
    current = nonce.current();
    nonce.increment();
}

_nonces配列のownerに渡されたアドレスの現在のnonce値を返しインクリメントする関数。

ERC20Permitの実装

では次にERC20Permitを実装していきましょう!

以下のOpenZeppelinのWizardを使用すると簡単にERC20Permitを実装することができます。

ERC20のタブを選択し、「FEATURES」メニュのPermitにチェックを入れるだけです。

今回1から実装するのが大変だったので、以下の方のコードを参考にさせていただきました🙏🏻

環境構築

まずは最低限の部分を実装していきましょう!

以下の記事の「環境構築」の章を参考にして進めてください。

上記完了しましたら、最後にhardhat.config.jsの中身を以下にしてください。

require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: {
    version: "0.8.17",
    settings: {
      optimizer: {
        enabled: true,
        runs: 1000,
      },
    },
  },
};

コントラクト作成

雛形は作成できたので、次にコントラクトを作成していきます。

CardeneToken.sol

まずはERC20トークンを発行するコントラクトを作成していきましょう。

ERC20Permitは、ERC20と含まれているようにERC20トークンを扱います。

そのため、ERC20トークンをあらかじめ用意しておく必要があるので、簡単にサクッとコードを書いていきます。

以下のコマンドを実行してファイルを作成してください。

$ mkdir contracts/CardeneToken.sol

CardeneToken.solファイルを作成したら、以下を貼り付けてください。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract CardeneToken is ERC20 {
    // トークン名は"Cardene Token"、トークンシンボルは"CARD"。
    constructor() ERC20("Cardene Token", "CARD") {}

    /// @notice CARDトークンを発行する関数。
    /// @ param _to 発行したトークンを送るアドレス。
    /// @ param _amount 発行するトークン数。
    function mint(address _to, uint _amount) external {
        _mint(_to, _amount);
    }
}

これでERC20トークンを発行するコードは完成です。

このファイルは今回使いません。参考のために作成しています。

思ったより短く感じたのではないでしょうか?

IERC20Permit.sol

次にERC20Permitinterfaceを作成していきます。

以下のコマンドを実行してファイルを作成してください。

$ mkdir contracts/IERC20Permit.sol

ファイルを作成したら、以下をファイル内に貼り付けてください。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

interface IERC20Permit {
    /// @notice ERC20トークンの総量を返す関数。
    function totalSupply() external view returns (uint);

    /// @notice accountに渡されたアドレスが所有しているERC20トークンの量を返す関数。
    /// @param account 特定のアドレス。
    /// @return アドレスが所有しているERC20トークンの量。
    function balanceOf(address account) external view returns (uint);

    /// @notice recipientに渡されたアドレスに、amount分のERC20トークンを送る関数。
    /// @param recipient ERC20トークンの送り先アドレス。
    /// @param amount 送りたいERC20トークンの量。
    /// @return 送金が成功したか失敗したかのbool値。
    function transfer(address recipient, uint amount) external returns (bool);

    /// @notice spenderに渡されたアドレスが、ownerが所有するERC20トークンをどれくらい送る許可を与えられているか返す関数。
    /// @param owner ERC20トークンの所有者アドレス。
    /// @param spender ownerが所有するERC20トークンをどれくらいの量送る許可を与えられているか確認したいアドレス。
    /// @return ERC20トークンを送れる許可を与えられている量。
    function allowance(address owner, address spender) external view returns (uint);

    /// @notice spenderで指定されたアドレスに、amout分のERC20トークンの送る許可を与える関数。
    /// @param spender ERC20トークンを送る許可を与えたいアドレス。
    /// @param amount 許可を与えるERC20トークンの量。
    /// @return 実行が成功したかのbool値。
    function approve(address spender, uint amount) external returns (bool);

    /// @notice spenderに渡されたアドレスから、recipientに渡されたアドレスへamount分のERC20トークンを送る関数。
    /// @param sender ERC20トークンの所有者アドレス。
    /// @param recipient senderのERC20トークンを送る許可を与えられているアドレス。
    /// @param amount 送りたいERC20トークンの量。
    /// @return 実行が成功したかのbool値。
    function transferFrom(
        address sender,
        address recipient,
        uint amount
    ) external returns (bool);

    /// @notice 第3者アドレスがERC20トークンを送る関数。
    /// @param owner ERC20トークンの所有者アドレス。
    /// @param spender ERC20トークンを代わりに送るアドレス。(ガス代を代わりに負担)
    /// @param value 送りたいERC20トークンの量。
    /// @param deadline 送れる期限。
    /// @param v リカバリーID。
    /// @param r ECDSA署名の出力。
    /// @param s ECDSA署名の出力。
    function permit(
        address owner,
        address spender,
        uint value,
        uint deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external;

    // イベント
    event Transfer(address indexed from, address indexed to, uint value);
    event Approval(address indexed owner, address indexed spender, uint value);
}

各関数の説明や引数の説明をコメントしているので、気になる方はそちらを確認してください。

このファイルは今回使いません。参考のために作成しています。

Bank.sol

次にERC20Permitを作成していきます。

以下のコマンドを実行してファイルを作成してください。

$ mkdir contracts/Bank.sol

Bank.solファイルを作成したら、以下を貼り付けてください。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol";

contract Bank is ERC20, Ownable, ERC20Permit {
    // ERC20とERC20Permitのconstructorを呼び出しています。
    constructor(string memory name, string memory symbol) ERC20(name, symbol) ERC20Permit(name) {}

    /// @notice ERC20トークンを発行する関数。
    /// @param to ERC20トークンを送りたいアドレス。
    /// @param amount 発行するERC20トークンの量。
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    /// @notice msg.senderからコントラクトにamount分のERC20トークンを送る関数。
    /// @param amount 送りたいERC20トークンの量。
    function deposit(uint amount) external {
        transferFrom(msg.sender, address(this), amount);
    }

    /// @notice permit関数を実行し、第3者がガス代を負担して、特定のアドレスにERC20トークンを送る関数。
    /// @param amount 送りたいERC20トークンの量。
    /// @param deadline 実行できる期限。
    /// @param v リカバリーID。
    /// @param r ECDSA署名の出力。
    /// @param s ECDSA署名の出力。
    function depositWithPermit(address _owner, address _spender, uint amount, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
        permit(_owner, _spender, amount, deadline, v, r, s);
        require(allowance(_owner, _spender) > 0, "There are no tokens allowed for transfer.");
        transferFrom(_owner, _spender, amount);
    }
}

これでコントラクト部分は完成です!

テストの作成

では次にテストを作成していきましょう!

以下のコマンドを実行してテストファイルを作成してください。

$ mkdir test/bank-test.js

bank-test.jsファイルを作成したら、以下を貼り付けてください。

const { expect } = require("chai")
const { ethers } = require("hardhat")

// 署名を作成。
async function getPermitSignature(owner, contract, spender, value, deadline) {
    const [nonce, name, version, chainId] = await Promise.all([
        contract.nonces(owner.address),
        contract.name(),
        "1",
        owner.getChainId(),
    ])

    return ethers.utils.splitSignature(
        await owner._signTypedData(
        {
            name,
            version,
            chainId,
            verifyingContract: contract.address,
        },
        {
            Permit: [
            {
                name: "owner",
                type: "address",
            },
            {
                name: "spender",
                type: "address",
            },
            {
                name: "value",
                type: "uint256",
            },
            {
                name: "nonce",
                type: "uint256",
            },
            {
                name: "deadline",
                type: "uint256",
            },
            ],
        },
        {
            owner: owner.address,
            spender,
            value,
            nonce,
            deadline,
        }
        )
    )
}

describe("ERC20Permit", function () {
    it("ERC20 permit", async function () {
        // アドレスを用意
        // owner: ERC20トークンの所有者。
        // contractOwner: Bankコントラクトをデプロイするアドレス。
        // Admin: ERC20トークンを受け取り、ガス代を支払うアドレス。
        [owner, contractOwner, admin] = await ethers.getSigners();

        // Bankコントラクトをデプロイ。
        const Bank = await ethers.getContractFactory("Bank", contractOwner)
        const bank = await Bank.deploy("Cardene Token", "CARD")
        await bank.deployed()

        // 1000のERC20トークンを発行。
        const amount = 1000
        await bank.connect(contractOwner).mint(owner.address, amount)

        // ERC20Permitの処理が有効な期間を指定。
        // 今回は指定できる最大値を指定。
        const deadline = ethers.constants.MaxUint256

        // ownerアドレスによる署名を作成。
        const { v, r, s } = await getPermitSignature(
            owner,
            bank,
            admin.address,
            amount,
            deadline
        )

        // ERC20Permitを実行して、ownerアドレスからadminアドレスにERC20トークンを送る。
        await bank.connect(admin).depositWithPermit(owner.address, admin.address, amount, deadline, v, r, s)

        // adminアドレスにERC20トークンが送れているか確認。
        expect(await bank.connect(admin).balanceOf(admin.address)).to.equal(amount)
    })
})

処理の詳細はコメントでコード内に記述しています。

ざっくり処理の手順をまとめると以下になります。

手順

  • ERC20トークンを1000個発行します。
  • ownerアドレスにより署名を作成。
  • adminアドレスがガス代を負担して、ERC20Permitを実行してownerアドレスからadminアドレスにERC20トークンを送る。
  • 最後にしっかりadminアドレスにERC20トークンを送れているか確認。

ERC20Permitの実行

では前章で作成したコードをもとにテストを実行してみましょう!

以下のコマンドを実行してください。

$ npx hardhat compile
$ npx hardhat test

以下のように出力されていればテストは成功です!

ERC20Permit
	✔ ERC20 permit (1507ms)


1 passing (2s)

コードは以下に置いてあります。

最後に

今回は「ERC20Permit」について解説してきました。

前提知識を最初に解説した後に「ERC20Permit」について解説したので理解しやすかったのではないでしょうか?

後半はコードも確認しながら解説してきたので、実装についても理解できていたら嬉しいです!

もし何か質問などがあれば以下のTwitterなどから連絡ください!

普段はSolidityやブロックチェーン、Web3についての情報発信をしています。

Twiiterでは気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!

参考

-Smart Contract, Solidity, ブロックチェーン
-,