💹

JPYCv2のメタトランザクション機能の紹介

2022/07/19に公開

こんにちは!JPYC研究開発チームのretocroomanです。

メタトランザクションはご存知でしょうか?メタトランザクションとは、一言で言えばガス代を支払わずに実行できるトランザクションです。Ethereum等でトランザクションをマイニングしてもらうのに、通常ガス代をマイナーに支払わなければなりません。しかしメタトランザクションを用いれば、マイナーに払うガス代を誰かに負担してもらうことができるのです。その代わり、ガス代を負担してくれた人にERC20トークンを送ることもあります。

ということで、今回はJPYCv2に実装されているメタトランザクション機能を紹介します。おそらく、ほとんどまだ使われていない機能ですが、今後活用したいと思っています。

JPYCv2にはEIP2612とEIP3009というERC20用のメタトランザクションの規格に対応しています。EIP2612よりEIP3009の方が使いやすいため、EIP2612とEIP3009の違いを簡単に説明した後、EIP3009をメインにコードを解説していきます。特にDappsを開発するときに利用したい機能なのでnode.jsでのメタトランザクションの作り方も簡単に紹介します。

なお、メタトランザクション自体についてもっと詳しく知りたい方はGMOさんの技術ブログが役に立つと思います!分散的にメタトランザクションを実現するGSN(Gas Station Netwark)についても解説されています!

前提知識

Solidity(前提知識集)
ECDSA(前提知識集)
EIP2612(前提知識集)
EIP3009(前提知識集)
EIP712(前提知識集)
ERC20(前提知識集)
JPYCv2のブロックリスト機能

EIP3009.solの目的と概要

現在、ガスの存在はEthereum等を触る際の一番の障壁でしょう。まず最初のトランザクションを実行するためのガスを確保するのが困難です。その他にも、ガス用のネイティブトークンは価格が変動するので持っているだけでリスクとなりますし、法人の場合は経理上の扱いが大変でしょう。もし、ガス用のトークンを一切持たずに色々なDappを利用できたら、それはかなり快適なはずです。

ではどうやってガスレスを実現するのでしょうか?その方法を理解するにはまず、EVM(Ethereum Virtual Machine)がどうやってトランザクションの送信者を確認しているかを知る必要があります。実は、メタトランザクションがやっていることはEVMがしていることをスマートコントラクトでしているだけ、とも言えるのです。

EVMにおいて本人確認はECDSAというデジタル署名を用いています。デジタル署名の流れのイメージとして、まず、Aliceが秘密鍵とそれに対応する公開鍵を持っているとします。AliceがBobに対してメッセージを送りたい場合、Aliceはそのメッセージを秘密鍵で署名して電子署名を作ります。Bobはその電子署名を用いて送られてきたメッセージから公開鍵を復元します。その復元された公開鍵がAliceのものであればそのメッセージはAliceから送られてきたものだと知ることができます。これが一連のざっくりとした流れです。

これをスマートコントラクト上で再現したのがメタトランザクションです。一連のフローは下の図のようになります。

その中でもEIP2612とEIP3009はERC20トークンのメタトランザクション機能を実装する際の規格になります。ではそれぞれの違いをまとめましたので見ていきましょう。

実行内容

  • EIP2612はpermitを使ってapproveをガスレスで実行する。
  • EIP3009はtransferWithAuthorizationを使ってtransferをガスレスで実行する。approvetransferFromのパターンはMultiple withdrawal attackの影響を受けやすいのでtransferを使うEIP3009の方が安全だとされる。

リプレイ攻撃の回避方法

  • EIP2612はシーケンシャルナンス(連番のナンス)で防ぐ。ナンスはnumber used onceの略で、一度だけ使用される任意の値を指す。
  • EIP3009はランダムな32バイトのナンスで防ぐ。順番が関係ないため、マイニング順序やDappsがナンスを再利用してしまう等の影響を受けずに複数のトランザクションを実行できる。

メタトランザクションのキャンセル方法

  • EIP2612はapproveする量を変更しないメタトランザクションでナンスを使用済みにする。
  • EIP3009はcancelAuthorizationで該当のナンスを使用済みにする。

利用ケース

  • EIP2612は uniswapなどで実際に使われており、コントラクトの中でtransferFromと同時にpermitを呼ぶことで通常approvetransferFromの二回必要なトランザクションの処理を一回で済ませている。
  • EIP3009はまだ実装されているERC20トークンが少なく、利用ケースはほとんどない。

以上のように、EIP3009は比較的新しいため実装されていないことも多いですが、EIP2612の欠点を修正して作られた規格であるため、今後はEIP3009が主流になると予想されます。 ただ、それぞれの役割が違うためERC20トークンを発行する際はEIP2612とEIP3009の両方を実装するのが望ましいそうです。

EIP3009.solのコード解説

JPYCv2の全体像

解説するコードのリンク
EIP3009.sol(JPYCv2)
継承しているコードのリンク
AbstractFiatTokenV1.sol(JPYCv2)
EIP712Domain.sol(JPYCv2)
使用しているライブラリのリンク
EIP712.sol(JPYCv2)

EIP3009が継承しているコントラクトの解説

abstract contract EIP3009 is AbstractFiatTokenV1, EIP712Domain {

EIP3009もアブストラクトコントラクトなのでAbstractFiatTokenV1を継承して最終的にFiatTokenV1に継承されます。AbstractFIatTokenV1を継承していることでEIP3009の中で_transferを呼べるようになっています。EIP3009はアブストラクトコントラクトで継承されることが前提ですが、_transferWithAuthorization関数と_receiveWithAuthorization関数と_cancelAuthorization関数はFiatTokenV1で修飾子をつけられているだけでそのまま呼ばれているため、今回はauthorizationState関数とこれらのinternal関数を紹介します。

もう一つの継承しているEIP712Domainはコントラクトで名前、バージョン、チェイン ID、トークンアドレスなどのメタデータを保持しているコントラクトです。メタトランザクションの実行先のコントラクトを限定するために必要です。

contract EIP712Domain {
    bytes32 internal DOMAIN_SEPARATOR; // initialize関数で算出
    uint256 internal CHAIN_ID; // チェインID
    string internal NAME; // JPY Coin
    string internal VERSION; // 1

    // コントラクトのメタデータのハッシュ値を返す関数
    function _domainSeparatorV4() public view returns (bytes32) {
        if(block.chainid == CHAIN_ID) {
            return DOMAIN_SEPARATOR;
        } else {
            return EIP712.makeDomainSeparator(NAME, VERSION);
        }
    }

    uint256[50] private __gap; // アップグレードで追加されるだろう状態変数の分を確保
}

これらのメタデータはFiatTokenV1のinitialize関数(通常、初期化はconstructorだがアップグレーダブルなコントラクトの場合はinitializeで初期化する)で設定し算出されています。ちなみにJPYCv2のEIP712Domainはフォークされても自動でチェインIDが書き換わるようにし、フォーク時のリプレイ攻撃を防いでいます。

使用しているライブラリはEIP712で、デジタル署名による公開鍵の復元やメタデータのハッシュ値の算出に使っています。EIP712自体は構造化データのハッシュ値を求める標準規格ですが、EIP712ライブラリの中でECRecoverライブラリを用いることでdomainSeparatorを組み合わせて公開鍵の復元をする関数を用意しています。

EIP3009の状態変数と修飾子の解説

    // 実行したい関数を32bytesのハッシュ値で区別
    // keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
    bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH =
        0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267;

    // keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
    bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH =
        0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8;

    // keccak256("CancelAuthorization(address authorizer,bytes32 nonce)")
    bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH =
        0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429;

    // 初期値は0で使われたナンスは1になる
    // ガス代節約のためboolではなくuint256を使用
    mapping(address => mapping(bytes32 => uint256)) private _authorizationStates;

    // メタトランザクションが実行されナンスが使われた時のイベント
    event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);
    // メタトランザクションがキャンセルされた時のイベント
    event AuthorizationCanceled(
        address indexed authorizer,
        bytes32 indexed nonce
    );
    .
    .
    .
    uint256[50] private __gap; // アップグレードで追加されるだろう状態変数の分を確保

_authorizationStatesでbool型ではなくuint256型を使っていたり、コードの最後にuint256型の配列を宣言したりしている理由に関してはJPYCv2の新機能紹介シリーズ、JPYCv2のブロックリスト機能の紹介で説明しているのでそちらをご覧ください。

authorizationState関数(80行目)の解説

    // 返り値はbool型
    function authorizationState(address authorizer, bytes32 nonce)
        external
        view
        returns (bool)
    {
        // 使用済みのナンスは1で表している
        return _authorizationStates[authorizer][nonce] == 1;
    }

この関数を呼んでユーザーはメタトランザクションが実行されたかどうか知ることもできます。

_transferWithAuthorization関数(100行目)の解説

    function _transferWithAuthorization(
        address from, // メタトランザクションに署名したアカウントのアドレスかつ送信元
        address to, // 送信先
        uint256 value, // 送信量
        uint256 validAfter, // 実行可能になるunix時間
        uint256 validBefore, // 実行不可能になるunix時間
        bytes32 nonce, // ナンス
        uint8 v, // デジタル署名の一部
        bytes32 r, // デジタル署名の一部
        bytes32 s // デジタル署名の一部
    ) internal {
        // ナンスとブロックタイムが適切か検証する
        _requireValidAuthorization(from, nonce, validAfter, validBefore);

        // 検証用のデータを内部変数と引数から生成
        bytes memory data = abi.encode(
            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
            from,
            to,
            value,
            validAfter,
            validBefore,
            nonce
        );
        // EIP712のライブラリとEIP712Domainのメタデータと署名を使ってデータから公開鍵を復元し、送信元と一致するか確認
        require(
            EIP712.recover(_domainSeparatorV4(), v, r, s, data) == from,
            "EIP3009: invalid signature"
        );

        // ナンスを使用済みにする
        _markAuthorizationAsUsed(from, nonce);
        // 最後に_transferを実行
        _transfer(from, to, value);
    }

それではナンスとブロックタイムが適切か検証する関数を見てみましょう。

    function _requireValidAuthorization(
        address authorizer, // 署名したアカウントのアドレス
        bytes32 nonce, // ナンス
        uint256 validAfter, // 実行可能になるunix時間
        uint256 validBefore // 実行不可能になるunix時間
    ) private view {
        // メタトランザクションが有効になる時間にもうなっているか確認
        require(
            block.timestamp > validAfter,
            "EIP3009: authorization is not yet valid"
        );
        // メタトランザクションが無効になる時間にまだなっていないか確認
        require(
            block.timestamp < validBefore,
            "EIP3009: authorization is expired"
        );
        // ナンスが未使用か確認する関数
        _requireUnusedAuthorization(authorizer, nonce);
    }

さらにナンスが未使用か確認する関数を読んでいますね。

    function _requireUnusedAuthorization(address authorizer, bytes32 nonce)
        private
        view
    {
        require(
            // 0ならばtrue、それ以外はfalse
            _authorizationStates[authorizer][nonce] == 0,
            "EIP3009: authorization is used or canceled"
        );
    }

これが_requireValidAuthorization関数と分けられている理由は後で説明する_cancelAuthorization関数から直接呼ばれているからだと思われます。

では内部変数と引数からデータを生成した後は署名を検証します。EIP712のライブラリの使い方については割愛しますが、いずれ紹介できたらと思います。その次は、ナンスを使用済みにする関数が呼ばれていますね。これも見てみましょう。

    function _markAuthorizationAsUsed(address authorizer, bytes32 nonce)
        private
    {
        // 使用済みとして1にする。
        _authorizationStates[authorizer][nonce] = 1;
        emit AuthorizationUsed(authorizer, nonce);
    }

初期値は0、使用すると1、ナンスはランダムな32バイトなので実質無限にある、という具合にメタトランザクションの管理をしています。コードは少し長いですが、仕組みはシンプルです。

_receiveWithAuthorization関数(145行目)の解説

        require(to == msg.sender, "EIP3009: caller must be the payee");

_receiveWithAuthorization関数は_transferWithAuthorization関数の処理の一番最初にこの1行を足しただけになります。つまり、ERC20トークンの送信先がメタトランザクションの実行者でなければならないという制限がついただけです。これはリレイヤーがコントラクトのときに使われ、信頼できるコントラクトのみ実行できるメタトランザクションを生成できるようにしています。例えば、_receiveWithAuthorization関数で特定のトークンを受け取ると別のトークンを送信してくれるコントラクトなどが考えられます。

_cancelAuthorization関数(185行目)の解説

    function _cancelAuthorization(
        address authorizer, // 署名者
        bytes32 nonce, // ナンス
        uint8 v, // デジタル署名の一部
        bytes32 r, // デジタル署名の一部
        bytes32 s // デジタル署名の一部
    ) internal {
        // ナンスとブロックタイムが適切か検証する
        _requireUnusedAuthorization(authorizer, nonce);

        // 検証用のデータを内部変数と引数から生成
        bytes memory data = abi.encode(
            CANCEL_AUTHORIZATION_TYPEHASH,
            authorizer,
            nonce
        );
        
        // EIP712のライブラリとEIP712Domainのメタデータと署名を使ってデータから公開鍵を復元し、送信元と一致するか確認
        require(
            EIP712.recover(_domainSeparatorV4(), v, r, s, data) == authorizer,
            "EIP3009: invalid signature"
        );

        // 何もせずナンスを使用済みにする
        _authorizationStates[authorizer][nonce] = 1;
        emit AuthorizationCanceled(authorizer, nonce);
    }

リレーサーバーに署名したメタトランザクションを送ったけれど取り消したいと思うことがあるかもしれません。その場合は、そのメタトランザクションで使用したナンスを_cancelAuthorization関数で使用済みにしてしまえば、そのメタトランザクションが実行されることはありません。再実行を防ぐためのナンスはキャンセルにも使えるのです。

メタトランザクションの作り方

では実際にnode.jsでEIP3009のメタトランザクションを作成しましょう。署名の仕方はローカルの秘密鍵を用いる方法とメタマスクで署名する方法の2通りあります。それでは少し長いですが、コードをみていきます。(参考程度にお願いします。)

// 外部ライブラリ
const { fromRpcSig } = require('ethereumjs-util');
const ethSigUtil = require('eth-sig-util');
const crypto = require("crypto");
const Web3 = require('web3');

// JPYCv2のabi情報(JPYCv1とは違います。)
const jpycJson = require('../abi/jpyc.json');

// EIP712Domainの型
const EIP712Domain = [
  { name: 'name', type: 'string' },
  { name: 'version', type: 'string' },
  { name: 'chainId', type: 'uint256' },
  { name: 'verifyingContract', type: 'address' },
];

// TransferWithAuthorizationの型
const TransferWithAuthorization = [
  { name: 'from', type: 'address' },
  { name: 'to', type: 'address' },
  { name: 'value', type: 'uint256' },
  { name: 'validAfter', type: 'uint256' },
  { name: 'validBefore', type: 'uint256' },
  { name: 'nonce', type: 'bytes32' },
];

// 署名するデータを作成する関数を用意しておく
const buildData = (name, verifyingContract, from, to, value, nonce, validAfter = 0, validBefore = MAX_UINT256) => ({
  primaryType: 'TransferWithAuthorization',
  types: { EIP712Domain, TransferWithAuthorization },
  domain: { name, version, chainId, verifyingContract },
  message: { from, to, value, validAfter, validBefore, nonce },
});

async function makeMetaTransaction() {
  // JPYCv2のコントラクトアドレス
  const jpycAddress = '0x78fd..';
  // web3のインスタンス生成
  const web3 = new Web3(provider);
  // コントラクトのインスタンス生成
  const jpycContract = new web3.eth.Contract(
    jpycJson.abi,
    jpycAddress,
  );
  const from = '0x78f1...'; // 署名者かつ送信元のアドレス
  const to = '0xd5c2...'; // 送信先のアドレス
  const amount = '100000000000000000000000'; // 送信量
  const nonce = '0x' + crypto.randomBytes(32).toString('hex'); // ただランダムに生成したナンス
  const privateKey = 'ef3dd67...' // 秘密鍵
  const data = buildData('JPYC Coin', jpycAddress, from, to, amount, nonce); // データの生成
  const signature = ethSigUtil.signTypedMessage(privateKey, {data}); // 秘密鍵で署名
  var { v, r, s } = fromRpcSig(signature); // 署名を分割
  
  // ガスリレイヤーがユーザーの代わりにこれを実行する。(都合上、ここに書いている)
  await jpycContract.transferWithAuthorization(from, to, transferred, 0, MAX_UINT256, nonce, v, r, s);
}

ちょっと複雑ですね…もしメタマスクで署名した場合は公式ドキュメントにメタマスクでの署名のやり方が載っていますのでそちらをご覧ください。最後の関数の引数のfrom, to, transferred, 0, MAX_UINT256, nonce, v, r, sが、ガスリレイヤーに渡す情報になります。これらは改竄されると署名内容と合わなくなるので悪用されませんし、ナンスは使い捨てなのでリプレイ攻撃される恐れもありません。

EIP3009を使用するDapps開発者はこれらの情報をユーザーから受け取り、実行可能期間を見ながら順番に実行していくサーバーを用意することになります。注意事項として、トランザクションを実行するたびにリレイヤーの秘密鍵が使われますのでそこは気をつけてください。

まとめ

いかがでしたか?JPYCv2のメタトランザクション機能への理解は深まりましたでしょうか?

今回は少し難しかったかもしれません。ですが、ユーザーに快適にDappを触ってもらうにはこうしたガスレス技術が欠かせません。特にreceiveWithAuthorizationはコントラクトと組み合わせると色々応用できそうです。まだまだ、使われる機会が少ないEIP3009ですが、ユーザーのガスレス体験のため採用してみてはどうでしょうか?

ここまでお読みいただきありがとうございます。JPYCv2の新機能の紹介はまだ続きます!これからもご愛読よろしくお願いいたします!

日本初のブロックチェーン技術(ERC20)を活用した日本円ステーブルコインJPYCはこちらから購入できます!
JPYC社はブロックチェーンエンジニアを募集中です!こちらからご応募お願いします!(タイミングにより募集を行なっていない場合があります)
また、ラボ形式でブロックチェーンに関する講義をしているJPYC開発コミュニティにも是非ご参加ください!

Discussion