[Ethereum] DApps開発でgas代行と戦うためのメモ

はじめに

EthereumでDApp開発を行うにあたり、まずはじめに思い浮かべる構成はMetamask + Web3.js + truffleでの開発だと思いますが、一般ユーザーにブロックチェーンを使ったアプリケーションを広く提供する場合、MetamaskやDAppsブラウザーを前提とした構成を採用するのはハードルが高いため、gas代行、つまりサーバーサイドでトランザクション発行をする構成を採用することが少なくありません。
BlockchainTokyo23.png
【Metamask + Web3.js + truffleのパターンのアーキテクチャ】
BlockchainTokyo23.png
【サーバーサイドでトランザクション発行をするパターンのアーキテクチャ】
サーバーサイドでのトランザクション発行は、Metamask使用するパターンとは全く異なった点に注意して実装する必要があるため、下記の3点を軸にこれまでの実装経験をもとに詰まった点や工夫した点をまとめてみようと思います。
  1. sendSignedTransaction
  2. nonce
  3. rollback

サーバーサイドでのトランザクション発行で注意すべきポイント

1. sendSignedTransaction

サーバーサイドで署名付きトランザクションを発行する際の基本となるweb3.jsの機能です。
ドキュメントにも書いてある通り、ethereumjs-txと合わせて使います。
https://web3js.readthedocs.io/en/v1.2.0/web3-eth.html#sendsignedtransaction
//  ドキュメントより抜粋
var Tx = require('ethereumjs-tx');
var privateKey = new Buffer('e331b6d69882b4cb4ea581d88e0b604039a3de5967688d3dcffdd2270c0fd109', 'hex')

var rawTx = {
  nonce: '0x00',
  gasPrice: '0x09184e72a000',
  gasLimit: '0x2710',
  to: '0x0000000000000000000000000000000000000000',
  value: '0x00',
  data: '0x7f7465737432000000000000000000000000000000000000000000000000000000600057'
}

var tx = new Tx(rawTx);
tx.sign(privateKey);

var serializedTx = tx.serialize();

// console.log(serializedTx.toString('hex'));
// 0xf889808609184e72a00082271094000000000000000000000000000000000000000080a47f74657374320000000000000000000000000000000000000000000000000000006000571ca08a8bbf888cfa37bbf0bb965423625641fc956967b81d12e23709cead01446075a01ce999b56a8a88504be365442ea61239198e23d1fce7d00fcfc5cd3b44b7215f

web3.eth.sendSignedTransaction('0x' + serializedTx.toString('hex'))
.on('receipt', console.log);

> // see eth.getTransactionReceipt() for details
ドキュメント通り実行すれば問題なく実行できますが、メインネットやテストネットで使う場合は要注意です。
ganacheやtruffleなどのローカル環境、もしくはプライベートチェーンで開発している場合は気づかないのですが、上記サンプル通り実装すると、トランザクションが確定するまで(デフォルトは24block)待ってからPromiEventのreceiptに設定したfunctionが実行されことになります。
そのため、上記サンプルのままメインネットに対してトランザクションを発行すると処理はなかなか終わりません。
lambdaを使っているのであれば必ずタイムアウトします。
この謎のデフォルト設定に対する対策は2つあります。

対策1. transactionConfirmationBlocksの設定変更

デフォルトだと24ブロック待ってしまうのが問題なので、transactionConfirmationBlocksの設定を変更することで待ち時間を減らします。
https://web3js.readthedocs.io/en/v2.0.0-alpha/web3.html#transactionconfirmationblocks
    :
  this.web3 = new Web3(provider, null, {
    transactionConfirmationBlocks: 1,
  });
    :

対策2. そもそも待たない

トランザクションの確定を待たずにとりあえずonceメソッドでtransactionHashだけ受け取って後続処理を実行してしまいます。
その際に、予期せぬエラーは検知すべきなので、errorイベントだけは監視しておきます。
https://web3js.readthedocs.io/en/v2.0.0-alpha/callbacks-promises-events.html#promievent
    :
  return new Promise<string>((resolve, reject) => {
    this.web3.eth
      .sendSignedTransaction('0x' + serializedTx.toString('hex'))
      .once('transactionHash', (res: string) => resolve(res))
      .on('error', (err: any) => reject(err));
  });
    :

2. nonce

Metamaskを使用しているとnonceについて気にすることはないと思いますが、サーバーサイドでトランザクション発行する際には自身で設定する必要があります。
例えば、引数に渡されたnonceをセットしてsendSignedTransactionを実行するcallHogeContractというfunctionが存在するとして、下記のように順番にfunctionを実行する場合は、getTransactionCountでpendingを含むトランザクション数を取得してインクリメントする必要があります。
  let nonce = await this.web3.eth.getTransactionCount(accountAddress, 'pending');
  const targets = ['hoge', 'fuga', 'hage']

  for (const target of targets) {
    await this.callHogeContract(target, nonce);
    nonce += 1;
  }
しかし、コントラクトを呼び出す順番に依存がないのであれば全て同時にfunctionを実行した方が早いので、下記のようにPromise.allを使いたくなりますが、Promise.allには実行順序に保証がないため、不正なnonceでトランザクションを発行しようとしていると判断されエラーが発生する可能性があります。
  let nonce = await this.web3.eth.getTransactionCount(accountAddress, 'pending');
  const targets = ['hoge', 'fuga', 'hage']

  // Promise.allには実行順序に保証がない
  await Promise.all(targets.map((target, index) => this.callContract(target, nonce + index)));
そのため、実行された順番にnonceがインクリメントされるよう、下記のような工夫が必要になります。
  let nonce = await this.web3.eth.getTransactionCount(accountAddress, 'pending');
  const getNonce = () => {
    const rv = nonce;
    nonce += 1;
    return rv;
  };
  const targets = ['hoge', 'fuga', 'hage']

  // 実行された順番にインクリメントされたnonceが引数に渡される
  await Promise.all(targets.map((target, index) => this.callContract1(target, getNonce())));
これで単一のAPIとしてはnonceの不整合はおきなくなりました。 ただし、同時に複数回APIが実行された場合においては、まだまだエラーが起きる可能性があります。
例えばgetTransactionCountをしてから実際にPromise.allでコントラクトをコールするまでの間に、他のプロセスがコントラクトをコールしてpendingトランザクションが発生してしまった場合などです。
このケースに対応するにはweb3.jsの使い方によって対応するのはもはや不可能だと思われるので、キューイングサービスなどを用いてトランザクションの発行を単一のトリガーからのみ実行されるようにするなどといった工夫が必要になります。

3. rollback

DBとは異なりコントラクトはロールバックできませんが、サーバーサイドでコントラクトを呼び出す場合は合わせてDBにもデータを記録することが多くなるため、DBのロールバックを考慮する必要があります。
基本的にはDBへのデータ保存後にコントラクトを呼びだすことで、予期せぬエラーが起きた際にDB側をロールバックできるようにします。
(下記例ではORマッパーであるTypeORMを使っています。)
import { getConnection } from 'typeorm';
      :

export class HogeRepository {
  // トランザクションの開始
  const connection = getConnection();
  const queryRunner = connection.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
      :
    // データの保存
    await queryRunner.manager.save(Hoge, data);

    let nonce = await this.web3.eth.getTransactionCount(accountAddress, 
'pending');
    // コントラクトの呼び出し
    await this.callHogeContract(target, nonce);

    // コミット
    await queryRunner.commitTransaction();
  } catch(e) {
    // ロールバック
    await queryRunner.rollbackTransaction();
  } finally {
    await queryRunner.release();
  }
}
上記手法でロールバックできないパターンもあるので、それぞれ対応方法をご紹介します。

パターン1: コントラクトアドレスをDBに保存する

コントラクトをAPIの処理内でデプロイする場合に発生するパターンです。
デプロイ後のアドレスはコントラクトをデプロイするアドレス(EOA)とnonceで一意に決まるため、デプロイ前に下記のようにアドレスを取得できます。
import * as RLP from 'rlp';
      :

public genContractAddress(nonce: number, accountAddress: string): string {
  return (
    '0x' +
    this.web3.utils
      .sha3(RLP.encode([accountAddress, nonce]))
      .slice(12)
      .substring(14)
  );
}
これにより、コントラクトアドレスをDBに保存した後に、コントラクトのデプロイができるるため、上記のDBのロールバックで対応できるようになります。

パターン2: トランザクションハッシュをDBに保存する

このパターンは、全ての処理を1本のAPIで実行しようとするとDBのロールバックでは対応できません。
必ずコントラクトを呼び出した後にトランザクションハッシュをDBに登録する必要があるためです。 対応方法は要件によりますが、トランザクション一覧を作成したい場合であれば、別途コントラクトのイベントを監視する処理を作成し、その処理内でトランザクションハッシュをDBに記録するようにする必要があります。
それにより、APIとしてはコントラクト呼び出し後にDBにデータを保存する必要がなくなるため、上記のDBのロールバックで対応できるようになります。

パターン3: コントラクトを複数回呼びだす

1回のAPIコールでコントラクトを複数回呼びだす場合に、一部がエラーとなったパターンの話です。
これはもはや共通の対応方法はないと思われます。 再実行すれば問題ない構成のコントラクトであればフロント側にもう一度同じAPIを叩いてらもうようにする必要がありますし、途中からやり直す必要がある場合はどこまで処理が終わったをフロント側に検知させ、途中から実行できるようAPIも設計する必要があります。
一番難しいパターンです。

まとめ

これまでの開発経験を踏まえ、備忘録がてら様々な注意点についてまとめてみました。
ライブラリのバージョンが変われば実装も変わりますし、見逃している実装方法もあったりすると思いますので、何か指摘事項ありましたらどしどしコメントしていただければと思います。
The following two tabs change content below.
Akihiro Tanaka

Akihiro Tanaka

Smart Contract Engineer
Since 2009, I have been a software engineer at Accenture for 9 years, managing, designing, and developing many services, mainly web and mobile apps.
In 2013, I met Bitcoin and started to work on blockchain-related development in 2018, developing an entertainment DApp for underground idols, a blockchain analysis tool, and an STO platform.
Currently, I am working as a Smart Contract Engineer at Secured Finance, developing a DeFi product.

WEB: https://tanakas.org/