Ethereum のスマートコントラクトで対戦ゲームを作るとどうなるか

1. Ethereum とは

Ethereum(イーサリアム) とは暗号資産(仮想通貨)プラットフォームの一つで、 Ethereum が使用する Ether(イーサ)は、暗号資産界隈では 2 番目に大きな時価総額を持っています。 一番はもちろん Bitcoin です。

暗号資産ってなんやねんって所から説明すると、「暗号資産」とは、暗号技術を駆使して、第三者による改竄が不可能な仕組みのデータベースに記録された、 資産取引記録の事です。「私(の口座)は〇〇という資産を保有しているよ!」という改竄不可能なデータが有ると言う事は、 誰から見ても正にその資産をあなたが保有していると確認できるため、 実際に資産を保有しているという事になります。

ちょうど、銀行口座に取引履歴と残高が記録されているのと同じです。 預金は、銀行を「口座データを改竄しない信用出来る第三者」と見なせるので成り立っています。

暗号資産はこれと同じ事を、ブロックチェーンを始めとする分散ネットワーク技術を用い「銀行」という中央管理機関を設けずに行う事が出来る物です。 従って、第三者からの審査や監視を受けずに資産の取引が行える訳です。

ここまで読んで「おいおい、誰の審査や監視も受けないんだったら、自分勝手に預金残高データ入れ放題やんけ」と思う方もいるでしょう。

これは実際、単なるソフトウェア技術と見た場合は可能です。 そのようにノードのプログラムを作れば、その「ノード自身のデータは」改竄することがいくらでも出来ます。

しかし、分散ネットワークがそれを不可能にします。ブロックチェーンでは合意形成アルゴリズムという物を使って、 ネットワークに参加する過半数以上のノードに対して同じようにデータを修正しない限り、ノード単体で行った改竄はエラーとして処理されます。

また、意図的に、やたらと「データを追加する」という処理が重くなる様「合意形成アルゴリズム」が作られているので、 ネットワークに参加するノードが多ければ多いほど改竄が困難になります。

ここまで読んでも「俺はスーパーハッカーだから、正当な預入を装って残高をいくらでも追加するプログラムを書けるぜ」と思う方もいるでしょう。

Ethereum ネットワーク全体を見た場合、Ether(Ethereum の通貨単位)を生成できるのは、 ネットワークを構成する「ノード」を保有する「マイナー」達が行う、かの「マイニング」だけです。 マイニングは、ブロックチェーンへ送信されたトランザクションを検証して記録する作業の事で、最初に成功すれば幾許かの報酬が得られます。 これは、マイナーに普及しているノード用のプログラムがそのように仕組まれている為です。 マイニングで得られる Ether を多くしようとプログラムを改竄しても、過半数以上のノードを同じように改竄しない限りそれはエラーとして処理されます。

実はインプットすることなのに、「マイニング(採掘)」というアウトプットの様に呼ばれるのが、暗号資産が誤解される所以でしょう。 (これは暗号資産を「宝が眠った山」の様なファンタジーに見せかけるマーケティング手法ではないかと思います) マイナーから見れば報酬というアウトプットなのですが、ノードがやっていることはブロックチェーンへのインプットです。

マイニングは言い換えれば計算資源をネットワークに提供してその対価を得る「レンタルサーバー」という事でもあります。 計算した結果 Ether が入った、データを記録する箱が産出されるわけですから、この結果だけ見れば「マイニング」と呼ぶにふさわしい行為をしているわけです。

尚、Ethereum では、将来的に合意形成アルゴリズムが変わって、呼び方が「バリデーション」になる様です。 これは恐らく、ノードの処理が「マイニング」と呼ぶには語弊があるくらい軽い負荷になるためでしょう。 ノード維持のモチベーションとなる価値であれば、「計算資源の対価」でも「掛け金に対する利回り」でも何でもよい訳です。

Ethereum ネットワークへの「預入」は、マイナーが電気代を掛けて産出した Ether と別の価値(現金とか)を「交換」することで可能になります。 従って、Ether を持つ相手が「交換しない」以上、あなたのアカウントで残高は増えません。

マイナーは掛かった安くない電気代とグラボ代という価値観があるので、 自身の産出した Ether を無料で交換するなんてことは「しないだろう」という所から経済が始まっています。

ところで、マイナーへの報酬の原資は一体何なのでしょうか?

報酬は、利用者が支払う手数料(Ethereum では Gas fee、ガス代と言います)と、ネットワークが新たに発行する Ether から賄われます。 今のところ Ethereum の Ether 発行総量に制限は設けられていませんが、発行しすぎると価値の下落を招くため、 今後ネットワークの発展と共に変更される可能性があります。

ここまで読んでも「俺はウィザード級のハッカーだから、マイナーのウォレットから・・・」という方、 話は署で聴こうか

ブロックチェーンの分散性が「銀行」を代替えする価値保証を与えてくれるという事ですが、 しかしこれはブロックチェーンの合意形成アルゴリズムが民主主義的な原理に基づいているため、 例えば、世界を股にかける独裁者が地球規模で独裁体制を築いて、恐怖政治でもって過半数のノードを改竄 (改竄というより「バージョンアップ」と呼ばれるでしょうが)した場合はこの限りではありません。

これが可能そうな国がお近くにございますか?

さて、暗号資産の詳細はぐぐっとググって頂くとして Ethereum の紹介に戻ります。

Ethereum は暗号資産プラットフォームの一種と冒頭で紹介しましたが、 「プラットフォーム」と呼ばれる所以は、それが「契約の履行もブロックチェーンで行う」という機能を有している為です。 これは「スマートコントラクト」と呼ばれ、任意のプログラムコードをブロックチェーンに格納し、ネットワーク上で動作させることが出来るようにした物です。

2. dApps とは

スマートコントラクトを駆使して作られたアプリは dApps(ダップス)と呼びます。 Decentralized Applications の略で、日本語にすると「分散型アプリケーション」です。

dApps は、中央のサーバーを持たずにオンラインで動作するソフトウェアです。 「分散」と聞くと処理負荷を分散(シャーディング)させてスケールする様な物を想像するかもしれませんが、 この場合は、「特定の中央処理装置を持たずに」という文脈になります。

スマートコントラクトは所謂「契約」ですから、通常の暗号資産が行う「Aという資産を誰誰がいくら保有している」という記録だけではなく、

A という資産を X から Y に、何時何時から送金する。送金は n 回に分けて行われ、間隔は t ヵ月毎で、1 回につき v ずつ送金する

という契約がブロックチェーンに保存されているなら、実際その通り送金が行われます。

後で説明しますが、実際には契約者がスマートコントラクトにアクセスした時点で、スマートコントラクトに記載された通りの動作が行われます。 スマートコントラクトがロボットの様に自律性を持って契約を履行する訳では「未だ」ありません。 引き落としの残高がないからと言ってターミネーターが取り立てに来る様には出来てません。

スマートコントラクトは普通のプログラム言語で書かれており、コンピューターが解釈して実行出来る物です。 便宜上「契約(コントラクト)」という呼び方をしていますが、単にプログラムをブロックチェーンに保存しノードで実行できるようにした、と言った方が簡単かもしれません。

ここまで読んで「それに一体何の意味が?」と思う方もいるでしょう。

スマートコントラクトは、大抵が「A から X を引き算して B に X を足し算する」と言ったような他愛もないコードですが、 一旦プログラムがネットワークにデプロイされると、ネットワークの全てのノードを駆逐しない限りサービスを提供し続けます。

プログラムに自爆機能を設けない限り、デプロイした本人にも止める術はありません。

そして、世の中が石器時代に戻らない限り(または Ethereum が廃れない限り。これは Ether 価格の変動に現れる)、 いつでも同じアドレスでスマートコントラクトにアクセスしてサービスを受けることが出来ます。

この強烈な不変性がメリットとなり、同時にデメリットでもあります。 更にプログラムの実行結果もブロックチェーンに保存されますから、改竄の心配もありません。

ただ、過去に一度だけこの不変性を覆す事件が起こっており、それが The DAO 事件です。SAO 事件ではありません。

これは不変性のデメリットをもろに食らった事件で、対策として開発者自らが「せかいのほうそく」を乱した例です。興味ある方はググってみてください。

「分散だのなんだの言って結局開発者達の胸先三寸じゃん」と言われかねない物ですが、世界が崩壊するよりはましと言った苦渋の選択だったのでしょう。 この例を見ると、確かに Ethereum 上で巨額の資産を保有することのリスクは低くないなと感じます。

ここまで読んでも「なんかよくわかんないし、つまんない。ウマ娘でもやろっと」と思う方もいるでしょう。

ちょっと待ってほしい。 あなたがやってるゲームのテーマである「競馬」も、あなたが働いている会社との「雇用契約」さえも、将来的にスマートコントラクトへ置き換わるかもしれませんよ?

3. dApps を作ろう

一通り暗号資産と Ethereum、スマートコントラクト 及び dApps について説明してきました。ここからは実際に dApps の作成を行います。

dApps の開発は一言でいえば・・・死ぬ程書き込みが遅いデータベースにアクセスするアプリ・・・と言うと、その筋の人にはイメージし易いかと思います。

dApps のパフォーマンスは・・・言ってしまえば「地獄の沙汰も金次第」。カネです。マネーが物を言います。 大丈夫、心配しないでください。開発者の皆さんが支払う訳ではありません。 利用者が支払います。その恐ろしさの片鱗は完成した暁に味わって頂くとして、dApps は大きく分けて二つのパートに分かれます。

  1. スマートコントラクト
  2. スマートコントラクトにアクセスするフロントエンド

1 は Solidity という専用の言語で書かれる、大抵は数百行からなるコードです。

2 は普通の Web アプリケーションです(プラットフォームが Web なら)。 場合によっては Android/iOS ネイティブや、デスクトップアプリ、CLI もあり得ますが、今回は Web をターゲットとします。

さて、「データベースにアクセス」と言われると、IP アドレスや URL を指定してバックエンドからノードに接続するのかなと思うかもしれませんが、 実際はそうでもありません。 バックエンドと呼べる構成を取るのは、ブロックチェーンの分散性に反するため、dApps の世界では積極的に避けられます。 (ただし、これはパフォーマンスとコストの問題を解決するために、幾らか妥協されます)

dApps ではユーザーが接続ノードを指定し、指定されたノードに対してフロントエンド側からアクセスします。 基本的に、物理的なノードを意識する必要はありません。デプロイしたコントラクトのアドレスさえ特定できればよいです。

接続ノードの指定方法は、利用者が「プロバイダー」をアプリに接続することで行います。 プロバイダーとはノードと通信を行うミドルウェアの様な物です。 ウォレット機能を搭載した物がほとんどで、Ethereum では MetaMask が有名です。

ウォレットとは、ユーザーのアカウントと秘密鍵を保存し管理するソフトウェアの事です。

MetaMask はブラウザープラグインで、プロバイダー APIをブラウザに「注入」する事でアプリとノードのゲートウェイになります。 図にすると以下の様な感じです。

基本的にアプリ側は web3.jsethers と言った ライブラリを使っておけば、如何なるプロバイダーが接続されようとも問題ありません。

では dApps の開発を始める為に、開発環境のセットアップから始めましょう。

Ethereum では Truffle Suite というツールが有名ですが、 今回は Hardhat というツールを使います。 理由としては導入手順が比較的簡単で、開発効率にも優れるためです。 また、筆者が普段使っている TypeScript との相性も良いです。

3.1. Hardhat の導入

3.1.1. インストール

Node.js の実行環境と、パッケージ管理ツールの yarn は導入済みの前提とします。

プロジェクトのディレクトリを作成してください。 次に yarn で hardhat をインストールします。

mkdir hardhat
cd hardhat
yarn init
yarn add -D hardhat

yarn init 実行で色々聞かれますが、全て Enter を押して飛ばしても問題ないです。 これで hardhat の CLI がプロジェクトにインストールされ、 npx hardhat で使用することが出来ます。

次に hardhat 環境の初期化を行います。 プロジェクトルートでコマンドを実行してください。

$ npx hardhat
888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

Welcome to Hardhat v2.1.1

? What do you want to do? ...
> Create a sample project
  Create an empty hardhat.config.js
  Quit

上記の様に、素敵なメニューが表示されますので、「Create a sample project」を選択してください。 幾つか質問されますが、全部 Enter で飛ばしても問題ありません。

次に依存するパッケージをインストールしてください。

$ yarn add -D @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers

以下の様なディレクトリが作成されました。

  • hardhat/ プロジェクトルート
    • node_modules/
    • contracts/ スマートコントラクトのソースコード用
      • Greeter.sol サンプルスマートコントラクト
    • scripts/ スマートコントラクトのデプロイスクリプト用
      • sample-script.js サンプルスマートコントラクトのデプロイスクリプト
    • test/ スマートコントラクトの単体テストコード用
      • sample-test.js サンプルスマートコントラクトの単体テストコード
    • .gitignore
    • hardhat.config.js hardhat のコンフィグファイル
    • package.json
    • yarn.lock

なんとたったこれだけの手順で、スマートコントラクトを開発する基本的な環境が整いました。

3.1.2. スマートコントラクトのコンパイル

サンプルでは以下の様なスマートコントラクトが生成されます。 Solidity という言語で書かれています。 テキストを保存したり、保存したテキストを取り出したりするだけのコントラクトです。

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.7.0;

import "hardhat/console.sol";


contract Greeter {
  string greeting;

  constructor(string memory _greeting) {
    console.log("Deploying a Greeter with greeting:", _greeting);
    greeting = _greeting;
  }

  function greet() public view returns (string memory) {
    return greeting;
  }

  function setGreeting(string memory _greeting) public {
    console.log("Changing greeting from '%s' to '%s'", greeting, _greeting);
    greeting = _greeting;
  }
}

スマートコントラクトをコンパイルするには、以下の様に実行します。

$ npx hardhat compile
Compiling 2 files with 0.7.3
Compilation finished successfully
  • hardhat/
    • artifacts/

にコンパイル結果が保存されます。

3.1.3. スマートコントラクトの単体テスト

単体テストを実行するには下記のコマンドを使います。 test/ ディレクトリにある単体テストプログラムが実行されます。

$ npx hardhat test
  Greeter
Deploying a Greeter with greeting: Hello, world!
Changing greeting from 'Hello, world!' to 'Hola, mundo!'
    √ Should return the new greeting once it's changed (986ms)


  1 passing (991ms)

単体テストエンジンは mocha + chai を使用しています。

3.1.4. ローカルノード(Hardhat Network)の起動

次にテスト用のローカルノード(Hardhat Network)を起動しましょう。

$ npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Accounts
========
Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
Private Key: ...

ローカルノードで使用できる Ether 付きアカウントが表示されていますので、 必要に応じて MetaMask 等のウォレットへ Private Key をインポートして使ってください。

暗号資産の「アカウント」で誤解をし易いのは、ブロックチェーン内にアカウントのマスターデータベースがあるように考えてしまいがちな事ですが、 実際は、Private Key(秘密鍵)を使ってユーザー側でトランザクションに署名し、 ノード側ではトランザクションにアドレス所有者本人の署名がある事を確認しているだけにすぎません。

被る事がないハッシュ値を生成する技術がこれを可能にしています。

従って、任意のアカウントアドレスは Ethereum の如何なるネットワーク(ローカルだろうが Test Network だろうが Mainnet だろうが)でも使用可能です。 ただし、当然ながら「残高」は引継ぎされません。

3.1.5. テスト環境へのスマートコントラクトのデプロイ

スマートコントラクトをローカルノードにデプロイするには以下のコマンドを実行します。 別のターミナル(シェル)を起動して実行してください。

$ npx hardhat run scripts/sample-script.js --network localhost
Greeter deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3

ローカルノードに表示されるログ

  Contract deployment: Greeter
  Contract address:    0x5fbdb2315678afecb367f032d93f642f64180aa3
  Transaction:         0x28101d672a659feb8e5105b61a97b9c01b9ae4a5a01c20c1a39a0bad10a53144
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  Value:               0 ETH
  Gas used:            496037 of 496037
  Block #1:            0x06d914ce358ec3cfa36fad2fe45cb2ebd0061de1ccadfbbb3e84547ae8cabfd6

  console.log:
    Deploying a Greeter with greeting: Hello, Hardhat!

これはローカルノードを起動する度に行う必要があります。 ローカルノードはオンメモリにブロックチェーンを保存するので、終了時にデータがリセットされます。

3.1.6. スマートコントラクトへのアクセス

ローカルノードにデプロイしたスマートコントラクトにアクセスしてみましょう。 フロントエンドをいちいち作る手間を省くため、hardhat のコンソールを使用します。

コンソールでは、アクセスに必要なオブジェクトが注入された状態で Node.js コマンドラインが起動します。

$ npx hardhat console --network localhost

hardhat-ethers プラグインをインストールしているので、ethers オブジェクトが使えます。

引数に指定する 0x から始まるコントラクトアドレスは、デプロイの時に表示されたアドレスです。

コントラクトオブジェクトを取得

Welcome to Node.js v14.15.0.
Type ".help" for more information.
> const Greeter = await ethers.getContractAt('Greeter', '0x5fbdb2315678afecb367f032d93f642f64180aa3');
undefined

コントラクトの現在の状態を取得

> await Greeter.greet();
'Hello, Hardhat!'

コントラクトの状態を変更

> await Greeter.setGreeting('Hello, Ethereum!');
{
  hash: '0x365553c667df3927557b3ad431717df7cf6726cb8e4f2a05deca288114ef369a',
  blockHash: '0xe2d7dacc4b3b1e1747776c2f1c7fc876a9c43ce02ff5ed6178babd102df5669e',
  blockNumber: 2,
  transactionIndex: 0,
  confirmations: 1,
  from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
  gasPrice: BigNumber { _hex: '0x01dcd65000', _isBigNumber: true },
  gasLimit: BigNumber { _hex: '0x8b43', _isBigNumber: true },
  to: '0x5FbDB2315678afecb367f032d93F642f64180aa3',
  value: BigNumber { _hex: '0x00', _isBigNumber: true },
  nonce: 1,
  data: '0xa41368620000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001048656c6c6f2c20457468657265756d2100000000000000000000000000000000',
  r: '0x32c153b52dc7915666140ccf2d9d9e1480c18679409c8d5bd3596ff988eb8e3a',
  s: '0x0cdc4e424f262aeb6259046b0b025cb80236caa56bc5a8a826dc5603080ab976',
  v: 62710,
  creates: null,
  chainId: 31337,
  wait: [Function (anonymous)]
}

トランザクションが反映されている事を確認

> await Greeter.greet();
'Hello, Ethereum!'

トランザクション発行元となるアカウントは、ローカルノードを起動したときに表示されるアカウントの1番目が使用されます。

ご覧いただいた通り、たいした準備をしなくても開発を始められるので、非常に楽チンです。

3.1.7. 言語を TypeScript に変更

単なる趣味の問題かもしれませんが、筆者は主に TypeScript を使うので、以下の手順で変更を加えます。

yarn add -D ts-node typescript
yarn add -D @types/node @types/mocha @types/chai

次に、hardhat.config.jshardhat.config.ts にリネームし、 中身を以下の様に書き換えます。

import { task } from "hardhat/config";
import "@nomiclabs/hardhat-waffle";
import { HardhatUserConfig } from "hardhat/config";

// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("accounts", "Prints the list of accounts", async (args, hre) => {
  const accounts = await hre.ethers.getSigners();

  for (const account of accounts) {
    console.log(await account.address);
  }
});

// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more

const config: HardhatUserConfig = {
  solidity: "0.7.3",
};

export default config;

テストも test/sample-test.js から test/sample-test.ts にリネームして中身を書き換えます。

import { expect } from "chai";
import { ethers } from "hardhat";

describe("Greeter", function() {
  it("Should return the new greeting once it's changed", async function() {
    const Greeter = await ethers.getContractFactory("Greeter");
    const greeter = await Greeter.deploy("Hello, world!");

    await greeter.deployed();
    expect(await greeter.greet()).to.equal("Hello, world!");

    await greeter.setGreeting("Hola, mundo!");
    expect(await greeter.greet()).to.equal("Hola, mundo!");
  });
});

デプロイスクリプトも scripts/sample-script.js から scripts/sample-script.ts に変更して書き換えます。

// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
import hre from "hardhat";

async function main() {
  // Hardhat always runs the compile task when running scripts with its command
  // line interface.
  //
  // If this script is run directly using `node` you may want to call compile
  // manually to make sure everything is compiled
  // await hre.run('compile');

  // We get the contract to deploy
  const Greeter = await hre.ethers.getContractFactory("Greeter");
  const greeter = await Greeter.deploy("Hello, Hardhat!");

  await greeter.deployed();

  console.log("Greeter deployed to:", greeter.address);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
  });

プロジェクトルートに tsconfig.json を作ります。

{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist"
  },
  "include": ["./scripts", "./test"],
  "files": ["./hardhat.config.ts"]
}

単体テストと、ローカルノード起動→デプロイを実行してみて、きちんと機能するか確認しましょう。 デプロイコマンドは以下の様になります。ts-node で実行されるので tsc によるコンパイルは不要です。

$ npx hardhat run scripts/sample-script.ts --network localhost

3.2. hardhat-react プラグインの導入

3.2.1. Hardhat プロジェクトルートへのインストール

さて、次はフロントエンドの開発環境を作ります。 また、スマートコントラクトの開発フローも改善の余地がありそうです。 特に、いちいちデプロイというコマンドを打ち込まないといけないのはイケてない感じです。 一度テスト環境を起動したら、ファイルの変更を検知してホットリロードしてくれると嬉しいです。

フロントエンドのフレームワークは React を使います。

まず、フロンエンド用のプロジェクトルートを作成します。Hardhat のプロジェクトルートの直下に作ります。

$ npx create-react-app frontend --template typescript

次に hardhat-react プラグインをインストールします。 これは、Hardhat のプロジェクトルートにインストールしてください(frontend ではありません)

$ yarn add -D @symfoni/hardhat-react

更に hardhat-react が依存するプラグインをインストールします。 同じくインストール先は Hardhat のプロジェクトルートです。

$ yarn add -D hardhat hardhat-deploy hardhat-deploy-ethers hardhat-typechain ts-morph ts-node typescript ts-generator typechain@4.0.0 @typechain/ethers-v5

hardhat.config.ts の先頭に以下の行を追加して、プラグインをインポートします。 (既に記述されていて重複する行は削除してください)

import "@nomiclabs/hardhat-waffle";
import "@nomiclabs/hardhat-ethers";
import "hardhat-deploy-ethers";
import "hardhat-deploy";
import "@symfoni/hardhat-react";
import "hardhat-typechain";
import "@typechain/ethers-v5";

最後にデプロイスクリプトを作ります。 ホットリロードを実現するために hardhat-deploy プラグインを使用します。 先程使った scripts ではなく、deploy というディレクトリを作成してください。

deploy/Greeter.ts というファイルを作成し、中身を以下の様にします。

import {HardhatRuntimeEnvironment} from 'hardhat/types';
import {DeployFunction} from 'hardhat-deploy/types';

const deploy: DeployFunction = async function ({
    getNamedAccounts,
    deployments,
    getChainId,
    getUnnamedAccounts
}: HardhatRuntimeEnvironment) {
    const { deploy, execute } = deployments;
    const { deployer } = await getNamedAccounts();

    const coin = await deploy("Greeter", {
        from: deployer,
        args: ['Hello, Hardhat!'],
    });
};

export default deploy;

tsconfig.json"include""./deploy" を追加してください。

{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist"
  },
  "include": ["./scripts", "./deploy", "./test"],
  "files": ["./hardhat.config.ts"]
}

これで Hardhat プロジェクトルートの対応は完了です。以下のコマンドでローカルノードを起動してみましょう。

$ npx hardhat node --watch

ノード起動時にスマートコントラクトのコンパイルとデプロイが走り、 尚且つ contracts/Greeter.sol を編集すると勝手にコンパイル→デプロイされます。 素晴らしい。

スマートコントラクトの内容が変わると、アドレスも変わるので注意してください。

3.2.2. フロントエンドプロジェクトルートへのインストール

hardhat-react の仕組みは、Hardhat がコントラクトのコンパイルとデプロイを行うと、 それに対応する React コンポーネントコードを、フロントエンドのソースツリー上に生成すると言った具合です。

これには TypeScript の型定義ファイルも含まれるため、コントラクトへのアクセスが単なるオブジェクトメソッドの呼び出しで行えるようになり、 もちろん、IDE 上でのサジェストも可能です。 React と組み合わさって、非常に迅速な開発が可能になります。

さて、フロントエンドプロジェクトルートのセットアップを行いましょう。章の冒頭で React のセットアップは済ませました。

依存するライブラリを追加してください。今度はフロントエンドプロジェクトルートが対象です。

cd frontend
yarn add ethers web3modal

hardhat.config.ts を書き換えます。

import {HardhatUserConfig, task} from "hardhat/config";
import "@nomiclabs/hardhat-waffle";
// ... 中略 ...

/**
 * @type import('hardhat/config').HardhatUserConfig
 */
const config: HardhatUserConfig = {
    react: {
        providerPriority: ["web3modal", "hardhat"],
    },
    solidity: "0.7.3",
};

export default config;

インストール作業はこれだけです。 尚、フロントエンドの開発を始める前に、Hardhat プロジェクトルートで必ずスマートコントラクトのコンパイルを行ってください。 そうしないと、スマートコントラクトにアクセスするための React コンポーネントが生成されません。

npx hardhat node --watch でノードの起動まではしたくない場合は、 npx hardhat react で開発に必要な React コンポーネントの生成を行うことが出来ます。

フロントエンドの動作確認は、

$ yarn start

によってローカル Web サーバーを起動し、ブラウザから http://localhost:3000 にアクセスする事で出来ます。

3.2.3. package.json にスクリプトを追加

良く使うコマンドが簡単に呼び出せる様に、Hardhat プロジェクトルートの package.json にスクリプトを仕込みましょう。

自分は以下の様に組み込みました。

  "scripts": {
    "test": "npx hardhat test",
    "serve": "npx hardhat node --watch",
    "react": "npx hardhat react",
    "frontend": "cd frontend && npm run start"
  }

さあ、後はガリガリとコードを書くだけです。

ここまでの環境整備が終わっているテンプレート(boilerplate)が アップされています

3.3. スマートコントラクトの作成

今回作りたいのは簡単な「じゃんけんゲーム」です。スマートコントラクトではじゃんけんの「契約」となる

  • 参加登録を受け付ける
  • 対戦者 2 名が決まったら、両者はグー・チョキ・パーのいずれかを提出する
  • 提出されたグー・チョキ・パーから、じゃんけんのルールである三竦みによって勝者を決定する
  • 提出期限の 5 分が経ってもグー・チョキ・パーのいずれの提出も無い者は負けとする
  • 勝者に連勝数に応じた枚数の記念コインを授与する
  • 参加者は、記念コインを ERC20 トークンとして、自身のウォレットに引き出す

を行います。 記念コインはウォレットに追加できる様 ERC20 トークンとして実装しますから、じゃんけんゲームのメインのコントラクトとは別のコントラクトにします。

contracts/JunkCoinERC20.solcontracts/Junkeng.sol というファイル2つを作ります。

ERC20 とは、交換や所有確認が可能なトークンをスマートコントラクトで実装する際の標準インターフェース規格です。

その前にトークンとは何ぞやという話になるのですが、Ethereum プラットフォームでは、Ether 以外の「通貨」とでも呼ぶべき独自コインが流通しています。 これらは Bitcoin の様な独自のブロックチェーンを持つ暗号資産と区別して、「トークン」と総称されます。

Ether は Ethereum の活況によって価格が変動しますが、トークンは、トークン自体に持たされた特性によって価格が変動します。 例えば、Bitcoin の価格に連動(ペッグ)する wBTC(Wrapped Bitcoin)や、ドルと連動する USDC 等です。

これらの「独自コイン」はもちろんスマートコントラクトを用いて発行量や所有者を管理しているのですが、好き勝手に実装されると取引所やウォレットの対応が、 その都度コインの仕様を確認して個別に実装を行うという煩雑なものになってしまいます。

そういった事にならない様に、ERC20(Ethereum Request for Comments の20番)で規格を統一しようという提案がなされ、 提案に沿ったインターフェース規格が流布されることとなりました。

尚、ERC20 以外にも規格は存在します。

3.3.1. JunkCoin の実装

まず JunkCoin というトークンを実装します。JunkCoin とはいわば「じゃんけんに勝利した」という価値を交換するための独自コインです。 つまり「ほぼ無価値」です。

ERC20 にする場合は、決められたインターフェースを実装する事となりますが、須らく、トークンなんて同じような実装になるので、 いちいちコードを書く必要が無く、従って既存の物を使います。

Hardhat プロジェクトルートに以下のパッケージをインストールしてください。

$ yarn add -D @openzeppelin/contracts

contracts/JunkCoinERC20.sol を編集して以下の様にします。

// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.8.0;

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

contract JunkCoinERC20 is ERC20 {
    uint constant initialSupplies = 100000000;

    constructor() ERC20("JunkCoin", "JKC") {
        _mint(msg.sender, initialSupplies);
    }

    function decimals() public view override returns (uint8) {
        return 0;
    }
}

実装すべき事は三つ。

  • トークンの名前と記号を決定する(ERC20 コンストラクタに渡す)
  • コンストラクタで管理者(デプロイヤー)のアカウントに初期発行量のトークンを割り当てる。
  • 小数の桁数を決める。この場合 "ゼロ桁"。

以上です。後の必要な実装は ERC20.sol で行われます。 実際のファイルは こちら です。

さて、これだけだと、ERC20 を継承した契約に一体どんな条項があるのか分からなくて目覚めが悪かろうと思いますので、 ERC20 のインターフェースを見てみましょう。

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address recipient, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}

実際のファイルは こちら です。

修飾子に view と付いている function はリードオンリーのインターフェースです。

  • totalSupply 総発行量を取得
  • balanceOf 指定したアカウントのトークン所有数を取得
  • transfer トランザクション送信者が所有するトークンを、指定したアカウントに、指定した量を払い出す
  • allowance アカウント spender に owner が所有するトークンを払い出す許容量(allowance)がいくら分あるかを取得
  • approve トランザクション送信者の所有するトークンを指定した量だけ払い出す allowance を、アカウント spender に付与する
  • transferFrom アカウント sender が所有するトークンを、トランザクション送信者が自身の allowance を使用して払い出しを実行する(これは取引所等、第三者からの実行を想定する)
  • Transfer 払い出しが実行されたときに発生するイベント
  • Approval allowance が変更されたときに発生するイベント

こういったインターフェースとイベントが定義されます。

「イベント」はフロントエンド側からイベントリスナーを登録することで受け取ることが出来ます。 注意としては、トランザクションの関係者だけでなく、コントラクトの当該イベントをリッスンしている全てのイベントリスナーに通知が行くという点です。 特定のアカウントだけに通知されるイベントというのはありません。

また、ERC20 のインターフェースには、コインの追加発行を行う機能・取引を停止する機能・コインを焼却する機能・コントラクトを改定した場合のデータ移行は含まれていません。

これらの機能が必要な場合はインターフェース追加と実装が必要です。 一度デプロイされると変更することは不可能ですから、将来の運営も考えた実装にしなくてはなりません。 そして JunkCoin は将来の運営なんて 1 ミリも考えていないので、何も実装しません。

3.3.2. JunkCoin のテスト

単体テストコードを書いて動作確認をしましょう。 test/JunkCoinERC20.test.ts を作成します。

import {expect} from "chai";
import hre from "hardhat";
import {TransactionResponse} from "@ethersproject/abstract-provider";
import {BigNumber} from "ethers";

describe('JunkCoinERC20', () => {
    beforeEach(async () => {
        await hre.deployments.fixture();
    })

    const getContracts = async () => {
        const { deployer, tester1, tester2 } = await hre.getNamedAccounts();
        const JunkCoinERC20 = await hre.ethers.getContract('JunkCoinERC20', deployer);

        return { JunkCoinERC20, deployer, tester1, tester2 };
    }

    it('Transfer', async () => {
        const { JunkCoinERC20, deployer, tester1 } = await getContracts();

        expect(await JunkCoinERC20.totalSupply()).equal(BigNumber.from(100000000));
        expect(await JunkCoinERC20.balanceOf(deployer)).equal(BigNumber.from(100000000));
        expect(await JunkCoinERC20.balanceOf(tester1)).equal(BigNumber.from(0));

        await JunkCoinERC20.transfer(tester1, 100).then((tx: TransactionResponse) => tx.wait());

        expect(await JunkCoinERC20.balanceOf(deployer)).equal(BigNumber.from(99999900));
        expect(await JunkCoinERC20.balanceOf(tester1)).equal(BigNumber.from(100));
    })

    it('TransferFrom', async () => {
        const { JunkCoinERC20, deployer, tester1, tester2 } = await getContracts();
        const JunkCoinERC20tester1 = await hre.ethers.getContract('JunkCoinERC20', tester1);

        expect(await JunkCoinERC20.allowance(deployer, tester1)).equal(BigNumber.from(0));

        await JunkCoinERC20.approve(tester1, 100).then((tx: TransactionResponse) => tx.wait());

        expect(await JunkCoinERC20.allowance(deployer, tester1)).equal(BigNumber.from(100));

        await JunkCoinERC20tester1.transferFrom(deployer, tester2, 50).then((tx: TransactionResponse) => tx.wait());

        expect(await JunkCoinERC20.balanceOf(deployer)).equal(BigNumber.from(99999950));
        expect(await JunkCoinERC20.balanceOf(tester2)).equal(BigNumber.from(50));
        expect(await JunkCoinERC20.allowance(deployer, tester1)).equal(BigNumber.from(50));
    })

    it('Transfer Error', async () => {
        const { JunkCoinERC20, deployer, tester1, tester2 } = await getContracts();
        const JunkCoinERC20tester1 = await hre.ethers.getContract('JunkCoinERC20', tester1);

        expect(await JunkCoinERC20tester1.transfer(deployer, 100).catch((e: Error) => e.message))
            .to.have.string('ERC20: transfer amount exceeds balance');
        expect(await JunkCoinERC20.transferFrom(deployer, tester2, 100).catch((e: Error) => e.message))
            .to.have.string('ERC20: transfer amount exceeds allowance');
    })
})

テストアカウントの設定をするために、hardhat.config.ts を編集して以下の様にします。 テストコード内の deployer, tester1, tester2 という名前付きアカウントが、 ローカルノード上で生成された何番のアカウントを使うか設定しています。

import {HardhatUserConfig, task} from "hardhat/config";
import "@nomiclabs/hardhat-waffle";
// ... 中略 ...

/**
 * @type import('hardhat/config').HardhatUserConfig
 */
const config: HardhatUserConfig = {
  react: {
    providerPriority: ["web3modal", "hardhat"],
  },
  namedAccounts: {
    deployer: {
      default: 0,
    },
    tester1: {
      default: 1,
    },
    tester2: {
      default: 2,
    }
  },
  solidity: "0.7.3",
};
export default config;

テスト実行は下記のコマンドです。

$ yarn test

ethers.getContract() が無いとエラーが出る場合は、 package.json"@nomiclabs/hardhat-ethers": "^2.0.2""@nomiclabs/hardhat-ethers": "npm:hardhat-deploy-ethers" に書き換えてください。

また、書き換えた後は yarn.lock を一旦削除して yarn を実行してください。

hardhat-deploy-ethers の Issue より

スマートコントラクトにおいてテストは非常に重要です。なぜなら一旦デプロイしたら修正が難しいからです。

利用者が全くいないコントラクトならデプロイした物を一旦忘れて、新たなコントラクトをデプロイしても問題無いかもしれませんが、 忘れたからと言ってコントラクトが無くなる訳でもなく、サービスは提供され続けます。

そして、利用者が多いコントラクトの場合は、大きな事件になり得ます。 万が一利用者が損害を被った場合、誰がどう補償するのか、と言った問題に発展するかもしれません。

リリース後のバグは修正が難しいと心得、十分なテストとレビューを行った後、本番にデプロイしましょう。

また、バグったコントラクトを止められるように、非常用の停止機能を実装しておけばより安心です。

3.3.3. Junkeng コントラクトの作成

いよいよメインのコントラクト「Junkeng(じゃんけん)」を作成します。

という事で出来上がった Junkeng.sol が こちら になります。 テストは こちら です。

長いので、ポイントだけ説明します。

まず、Solidity という言語の特徴から。

Solidity は強い型付け言語で、JavaScript にありがちな暗黙の型変換は期待しない方が良いです。基本的にはキャストが必要で、C言語に近いです。 また、変数は宣言時にデフォルト値で初期化されます。整数なら 0、bool なら false、文字列なら ""、アドレス型なら address(0) です。

nullundefined という概念はないです。このことは構造体の配列・マッピングテーブルを使用する際に注意が必要です。 「存在しない」を表すために 0 をリザーブしておく必要が出てきます。

配列は length で長さを取れますが、マッピングテーブルは length でサイズを取ったり、キーをイテレーションしたりできません。

構造体は以下の様にコンストラクタ的に呼び出すことで、new することが出来ますが、マッピングテーブルを含む構造体では使えません。

    queue.push(Queue({
        addr: msg.sender,
        handShape: HandShape.Undefined,
        timestamp: block.timestamp,
        status: MatchStatus.Participated
    }));

コントラクトのメンバーはブロックチェーンに保存される「状態」、function の引数や変数はスタックが原則、文字列・構造体・配列・マッピングテーブルはメモリーで、 宣言に memory 修飾子を付けます。

さて、Solidity で面白いのは modifier で、function の前処理・後処理を共通化することが出来ます。 引数も渡せるので、バリデーションを行うときに重宝します。

modifier は下記の様に定義します。_; の部分が、本体の function に置換されます。

    /**
     * Sender is registered
     */
    modifier registered() {
        require(participants[msg.sender].status > ParticipantStatus.NoRegistration, "No registration");
        _;
    }

require はバリデーション用の関数で assert の様な物です。第1引数の条件を満たしていない場合、第2引数のメッセージをエラーとして返し、 トランザクションの実行を止め、状態の変更を無かった事にします。

modifier を使う場合は以下の様にします。

    function getStatus() view public registered

modifier は、本体の function 内で return しても必ず実行されます。

また、solidity の function は、配列やオブジェクトを使わなくても、複数の値を返すことが出来ます。

    function getStatus() view public registered
        returns(address addr, uint index, uint8 status, uint timestamp, uint8 handShape, uint streak, uint phase)

上記の様に returns を宣言すると、括弧内がローカル変数として定義されます。

return (hogehoge, hagehage); と明示的に書いてもいいですが、単に代入してやるだけでも戻り値として有効です。

完全なローカル変数として振舞いますから、代入だけでなく右辺に使う事も出来ます。

    addr = msg.sender;
    index = participants[msg.sender].current;
    status = uint8(queue[index].status);

更に特徴として挙げるなら、Ether という非常に桁の大きな数量を扱うので、標準の整数型が uint256(256ビット= 32バイト)です。 何も考えずに returns に入れて、フロントエンドから読みだそうとすると、BigNumber というオブジェクトにラップされて出てきます。 number に入れて扱おうものなら直ぐにオーバーフローしますので注意してください。

今回の記事では Solidity の言語的なレクチャーは目的ではないので、詳細は 他の文書 に委ねたいと思います。 全く難しい言語ではないので、普段からプログラミングしている人なら学習はし易いと思います。

さて、Junkeng コントラクトに戻りますが、 基本的には、join と disclose という二つの function でゲームを進行させます。 参加者 2 名が join をしたらゲーム開始し、2 名が disclose したら終了します。 実装を簡素にするため、マッチングは必ず連続する2名で行います。

問題は、1 名ないし 2 名共々がいつまでも disclose しなかったらどうなるか、という点です。

その場合、次の join 時、またはコインを引き出す withdraw 実行時に敗北処理を行います。timeout という modifier で処理します。

    /**
     * Expire at 5 minutes after the match was established.
     */
    modifier timeout() {
        if (participants[msg.sender].status == ParticipantStatus.Participated) {
            uint index = participants[msg.sender].current;
            uint opponent = calcOpponentIndex(index);

            if (opponent < queue.length) {
                // Keep deadline
                uint duration = calcDuration(queue[index].timestamp, queue[opponent].timestamp);

                if (duration > 5 minutes) {
                    // Timeout -> Reset
                    participants[msg.sender].status = ParticipantStatus.NoParticipating;

                    if (queue[index].handShape != HandShape.Undefined && queue[opponent].handShape == HandShape.Undefined) {
                        // Opponent timeout -> DEFWIN
                        // Get coin
                        coinStock[msg.sender] += ++participants[msg.sender].streak;

                        emit Earned(msg.sender, index, participants[msg.sender].streak);
                    } else {
                        // Self timeout or both timeout -> DEFLOSS
                        participants[msg.sender].streak = 0;
                    }

                    queue[index].status = MatchStatus.Settled;
                }
            }
        }
        _;
    }

獲得したコイン数を返す getCoinBalance は直前のゲームで敗北したものとして状態を返します。

    /**
     * Get own coin balance
     * This is view function so `block.timestamp` isn't update. Obtain actual timestamp from args.
     */
    function getCoinBalance(uint timestamp) view public returns(uint coins) {
        if (participants[msg.sender].status == ParticipantStatus.Participated) {
            // Keep deadline
            uint index = participants[msg.sender].current;

            if (queue[index].status == MatchStatus.Disclosed) {
                // No settled yet
                uint opponent = getOpponent(index);
                uint duration = timestamp - bigger(queue[index].timestamp, queue[opponent].timestamp);

                if (duration > 5 minutes &&
                    queue[index].handShape != HandShape.Undefined &&
                    queue[opponent].handShape == HandShape.Undefined)
                {
                    // DEFWIN previous match, add (streak + 1) coins to balance
                    coins += participants[msg.sender].streak + 1;
                }
            }
        }

        coins += coinStock[msg.sender];
    }

タイマーか何かで時間経過後に、状態を変化させればいいのでは?と思われるかもしれません。

なぜそうしないのかという点は、Ethereum のスマートコントラクトの特性に理由があります。 Ethereum のスマートコントラクトは、トランザクション(function をコールすること)が発生する度に利用料が掛かります。 これは「Gas fee、ガス代」と呼ばれ、利用者の Ether balance から差し引かれます。 ガス代が掛かるのはコントラクトの状態を変更するトランザクションだけで、view が付いた読み出し専用の function は無料で実行できます。

従って、dApps を作るときは、状態を変更するトランザクションを最小限にする事が求められます。 今回の場合は、参加する join と、グー・チョキ・パーを出す disclose の二つだけで、結果を確定させる function は省いています。 なぜなら、負けたと分かっている時に、わざわざわガス代を払ってまで「負け」という状態にするトランザクションを発生させるのはユーザーにとって不利益になるためです。

また、トランザクションにはガス代が掛かり、利用者が支払うと書きましたが、 ガス代はトランザクションで行われる処理の内容とブロックチェーンに書き込むデータ量でも増減します。 これが意味する所は、トランザクションの処理内容やデータ量は、それを発行する利用者の責務範囲内に留められるべきと言う点です。 利用者の責務とは全く関係ないアプリ都合の処理(例えば、ランキングをつけるため参加者リストをソートするとか)を含めるべきではないでしょう。

このように、スマートコントラクトを作るときには、その処理が「利用者の利害にとって必要なのかどうか」を考えなければなりません。

Junkeng の話に戻ると、join も disclose も「後出し」の方に、より多くの責務が発生する様作られています。 スマートコントラクトは自発的に起動したりはしないので、必ず利用者のトランザクション発行が必要です。 後出しがゲームを進行させる責務を負う、とコントラクトで定義することで対処しています。

先も後も公平に扱う方法が無い訳ではありません。 この場合は「進行役」となる第3者をコントラクトに参加させて、状態の確定とゲーム進行という役割を担う事になるでしょう。 もちろん進行役にもガス代が掛かりますから、参加のモチベーションとなる「何らかの報酬」を用意すべきでしょう。 また、進行役は何も人力である必要はありません。

ちなみに実行途中でGasが足りなかった場合(ガス欠と言います)は、トランザクションが失敗します。 尚且つ、それまでに使用されたGasは戻って来ません。

最後に説明しきれなかった Solidity とスマートコントラクトの特徴をまとめます

  • 正確な時間を取ることはできません。 block.timestamp というブロックが生成されたときの時間が取得できるのみです。
  • 乱数を発生させる関数はないです(契約にランダム要素は不要!)
  • 外部 API を叩くといった事は一切できません。全て EVM(Ethereum のノード上で動く仮想マシン)の中で閉じています。
  • トランザクションは全て外部からのアクセスです。ブロックチェーン内部のイベントで起動するといった機能はありません。
  • contract で宣言されるコントラクトは 1 つ 1 つが個別のアドレスを持ち、外部からインタラクトが可能です。 プライベートな内部コントラクトという物はありません。
  • Solidity コード内でコントラクトを new する(デプロイする)事が出来ますが、通常のデプロイと同じだけガス代が取られます。 言ってなかったですが、スマートコントラクトのデプロイには安くないガス代が取られます。1回50円とか?いいえ、執筆現在のレートで数万円とかです!
  • 実行にかかるガス代を見積もる事(Estimate gas)は出来ますが、正確なガス代を実行前に知ることはできません。 大抵は Estimate gas で見積もった(多めの)ガス代を渡して、余ったガス代が終了後に返却されます。
  • require の評価は Estimate gas 実行時でも行われる為、その際 require で弾かれてもガス代が掛かりません。
  • トランザクションの内容は秘匿されず誰からも見ることが出来ます。事実、トラッキングサイト Etherscan では全てのトランザクションが一覧できます。
    Ethereum の分散ネットワークでは、全てのノードがトランザクションの正当性を検証しなければなりませんから、取引の透明性が重要となります。 通常、アカウントアドレスはヒューマンフレンドリーでないハッシュ値ですから、個人情報はなんら含まれていませんが、 生のトランザクションデータは個人情報をやり取りできるようにできていません。

3.4. フロントエンドの作成

コントラクトもできたことなので、次は、コントラクトにアクセスして、色々と表示を行うフロントエンドの作成を行います。

今回は hardhat-react というプラグインを使いますから、非常に簡単に作れることが期待できます。

$ npx hardhat react

上記の様にコントラクトのコンパイルを行った時点で、frontend/src/hardhat 配下に、必要なコンポーネント一式が生成されます。

まず、コントラクトへのアクセスですが、以下の様に Symfoni コンテキストで、自分のコンポーネントをラップしてやります。

import React from "react";
import { Symfoni } from "../hardhat/SymfoniContext";


const withHardhat = (Component: React.FC) => () => {
    return <Symfoni autoInit={true}>
        <Component />
    </Symfoni>
}

export default withHardhat;

コントラクトを使いたい場合は、コンポーネント内でそれぞれのコンテキストを useContext します。

import {JunkengContext} from "../hardhat/SymfoniContext";

const MyComponent = () => {
    const junkeng = useContext(JunkengContext);
    // ...
}

export default withHardhat(MyComponent);

junkeng には、アドレスも含めてコントラクトにアクセスするために必要なもの全てが入っています。

ボタンクリックでコントラクトの join を呼び出したい時は以下の通りにします。

    const join = () => {
        junkeng.instance?.join()
            .then(() => {
                // トランザクションの完了は待ちません
                // ...
            })
    }

junkeng.instanceContract 型 という ethers の API になります。 上記のコードではトランザクションの完了は待たずに then() が呼ばれますが、待ちたい場合は以下の様にします。

    const join = () => {
        junkeng.instance?.join()
            .then((tx: TransactionResponse) => tx.wait())
            .then(() => {
                // トランザクションの完了を待ちます。
                // ...
            })
    }

view function は直ぐに結果が返ってくるので、戻される Promise を処理するだけでいいです。

    const balance = await junkeng.instance.getCoinBalance(moment().unix())

トランザクションのイベントをリッスンしたい場合は

    const joinedHandler = async (addr: string, index: BigNumber) => {
        // ...
    }

    junkeng.instance?.on('Joined', joinedHandler);

となり、同じ関数を junkeng.instance?.off('Joined', joinedHandler) とすればリッスンを解除できます。

イベントについての注意点ですが、ページをリロードした場合(つまり Contract のインスタンスが初期化された場合)、 直近に発生したイベントが再び呼び出される可能性があります。 これは、イベントハンドラでフロンエンドの状態を遷移させるようなコードを書いた場合問題になると思います。 1 発のイベントに対してイベントハンドラが必ず 1 度だけ呼び出される、という前提に立った実装はしないでください。

また、hardhat-react のコンテキストスタックは、状態の永続化を一切行いません。 状態の永続化はローカルストレージ等を使って自前で行ってください。

上で dApps の事を「死ぬ程書き込みが遅いデータベースにアクセスするアプリ」と紹介しましたが、 正しくトランザクションの承認には数十秒から遅くて数分掛かる場合があります。

コントラクトの view function を呼び出して状態を取得し、それを常に正とする実装を行うと、ページをリロードした際に巻き戻った様な UX になります。 トランザクションは発行順に処理されますから、コントラクト内の require できちんとバリデーションをしていれば、二重に処理されることはありませんが、 優れた UX のためには、ブラウザ側で状態を永続化して、要所要所でコントラクトの状態を反映する様に実装しましょう。

Ethereum 2.0 になるとトランザクション処理性能が格段に向上するらしいので、状況は変わるかもしれません。

という事で、あとは普通の React アプリを作る要領で開発を進めるだけです!

実際に開発したフロントエンドのコードは こちら です。

3.5. 動作確認

フロントエンドの開発を進める中で動作確認を行いたい場合は、先程のローカルノードが使用できます。

ブラウザ上で動作確認を行う前に、MetaMask のインストールを行ってください。

ログインする際は「Import using account seed phrase」を使用して、 以下のシードフレーズ(Mnemonic、ニーモニックとも呼ばれる)を入力すれば、 ローカルノード上に生成されたテスト用アカウントをウェレットごと復元できます。

※注意:既にMetaMaskを普段使いしている場合は、自分のシードフレーズを忘れていないか確認してください。後でウォレットを元に戻す時に必要です!

test test test test test test test test test test test junk

こういったウォレットを「HDウォレット」と言います (HDはHierarchy Deterministic、階層的決定性。単一のシード値から芋づる式に秘密鍵を生成する仕組みです) ほとんどのウォレットツールがHDウォレット機能を搭載しているので、本記事では単にウォレットと呼びます。

Hardhat プロジェクトルートで yarn serve を実行するとローカルノードが http://localhost:8545 に立ち上がります。

フロントエンドプロジェクトルートで yarn start を実行して Web サーバーを立ち上げ http://localhost:3000 にアクセスすると、 MetaMask のウインドウが開き、アプリケーションに接続する様に促されます。

一旦接続を行えば、フロントエンドからコントラクトの操作が可能になります。

コントラクトの操作を行おうとすると、以下の様なトランザクション発行の確認画面が表示されます。

ここで重要なのが、以下の2点です。

  • Gas Price (単位:Gwei。Ether の最小単位 wei の 1000,000,000 倍)ガスの単価で利用者が好きに設定できますが、実際のノード上ではガス単価が高いものが優先して処理されます。 Gas Price は市場原理に基づいて相場が形成されており、Etherscan 等のトラッキングサイトで確認できます。
  • Gas Limit (単位:Unit)トランザクションで使用していいガスの最大量で、表示されているものは Estimate gas で見積もられた必要量です。 必要十分な量になっているので、原則変更しなくても良いです。

最終的にかかるガス代は Gas Price * Gas Limit です。

Gas Price の相場は現在 100 ~ 150 Gwei なので、10,000 Unit のガス代は 1,000,000 ~ 1,500,000 Gwei、即ち 0.001 ~ 0.0015 ETH (Ether)。 執筆時点で 1 ETH は 195,000円程度なので、195円 ~ 292.5円です。

ただし、一般的な ERC20 トークンの送信には 1 回につき 65,000 Unit のガスが必要とされており、これは 1,267.5円 ~ 1,901.25円です。

コントラクトのデプロイに至っては数百万 Unit が必要なので、ガス代は数万円のオーダーに乗るわけです。 これは Junkeng の様な単純なコントラクトでの話なので、更に複雑なコントラクトの事は考えたくもないです。

如何に現在の Gas Price が高騰しており、問題になっているか分かるでしょう。 Ethereum の次期バージョン 2.0 では、これを改善するとされています。

「Confirm」をクリックすると、実際にトランザクションが送信されます。トランザクションの承認まではしばらくかかります。 承認されると、MetaMask からテロロ~ンと通知が送られてきます。

ローカルノードでは起こりませんが、実際のノードでは設定した Gas Price が安すぎるといつまで経っても承認されない場合があります。 その場合は、1度だけ Gas Price を設定し直す猶予があります。

テストしてると、MetaMask が Nonce too high 等とエラーを出してトランザクションが実行できない状況に陥ります。

これはローカルノードを再起動した後に良く起こります。

その場合は、MetaMask の Settings → Advanced → Reset Account を実行してください。

MetaMask はアカウントを簡単に切り替えられますから、対戦のテストはアカウントを切り替え、ブラウザをリロードして行います。

3.6. Alchemy を使い、スマートコントラクトを Rinkeby Test Network にデプロイ

完成が近づいてきたので、オンラインにデプロイしましょう。本番である Mainnet にデプロイする度胸も金もないので、 今回は Rinkeby Test Network という、テスト用のブロックチェーンにデプロイします。

3.6.1. ウォレットを切り替え

ローカルノード用に MetaMask へ登録した test test test ... というシードフレーズのウォレットは、 誰でも知ってるので、オンラインで使うと問題が起こる可能性があります。

従って、控えておいたシードフレーズで自分のウォレットに戻すか、新たなシードフレーズを作ってウォレットを作成しなおすべきでしょう。 作り直しはちょっと面倒ですが、一度 MetaMask プラグインをアンインストールして再度インストールし直すことで、 新たなシードフレーズのウォレットとアカウントが作成できます。

シードフレーズはウォレットのマスターパスワードとなりますから、他人に知られない様、細心の注意を払って扱ってください。

3.6.2. Ether の入手

Rinkeby Test Network はテスト用と言っても、アカウントに Ether は付いてきません。 従って、何処からか入手する必要があります。

マイニングするという手もありますが、 Rinkeby の場合は、こちら に Ether を配布するサイトがあります。

アカウントのアドレスを Twitter でツイートして、ツイートの URL を送信することで、ツイートしたアドレスに Ether を送付してくれます。 受け取ったらツイートは削除して構いません。

スパム避けの為、Twitter アカウント 1 つにつき、一定期間で入手できる Ether の額に限りがあるので、 入手した Ether を複数アカウントに送金して使いましょう。

受け取った Ether を MetaMask で確認する場合は、ネットワークを「Rinkeby Test Network」に切り替えてください。 そうしないと残高が更新されません。

3.6.3. Alchemy のユーザー登録

Ether を入手したらデプロイですが、Ethereum の API を叩ける、Rinkeby Test Network に接続しているノードが1つ必要になります。

二つの選択肢があります。

  1. geth 等を使って自前でノードを立ち上げる
  2. Alchemy 等の、ノードをホスティングしてくれる業者を使う

今回は 2 で Alchemy を使います。geth でノードを立ち上げるのは難しくはないのですが、 ネットワークと同期するため、大量の時間とディスク資源とネットワーク資源、更にプロセッサー資源を消費します。 また、インストール環境を用意する手間もゼロでは無いので、Alchemy を使った方が手っ取り早いです。 尚、Alchemy は無料で使い始められます。

こちら でサインアップを行ってください。 クレジットカードは登録をスキップしても大丈夫です。

サインインしたら、「CREATE APP」でアプリを作成してください。ネットワークは必ず「Rinkeby」を選択します。

「VIEW KEY」で「HTTP」の API URL を取得してください。これをもとに hardhat.config.ts を設定します。

import {HardhatUserConfig, task} from "hardhat/config";
import "@nomiclabs/hardhat-waffle";

// ...中略...

/**
 * @type import('hardhat/config').HardhatUserConfig
 */
const config: HardhatUserConfig = {
    networks: {
        rinkeby: {
            url: "Alchemyで取得した API URL",
            accounts: {
                mnemonic: "3.6.1. で新規作成したシードフレーズ(12個の英単語)"
            }
        }
    },
    namedAccounts: {
        deployer: {
            default: 0,
        },
        tester1: {
            default: 1,
        },
        tester2: {
            default: 2,
        }
    },
  solidity: "0.7.3",
};

export default config;

これで準備が整いました。早速デプロイしましょう。

$ npx hardhat deploy --network rinkeby

エラーが出なければ、デプロイ成功です!

本当にデプロイされたか確認するには、frontend/src/hardhat/deployments/rinkeby/ にコントラクトと同じ名前の json ファイルがあり、 冒頭にコントラクトのアドレスが記載されていますので、 それを元に Etherscan で検索を掛けると、デプロイしたコントラクトを見つけることが出来ます。

たとえば JunkCoinERC20 をデプロイした場合は こんな感じ です。

Alchemy は一度コントラクトをブロックチェーンにデプロイしてしまえば、そのあとは頻繁にアクセスするものではありません。 一応、MetaMask の Custom RPC として、Alchemy の API URL を追加すれば、自分専用のノードとして使用することが出来ます。

3.6.4. MetaMask の接続ネットワークを変更してテスト

Rinkeby に接続した状態でフロントエンドのテストをしてみましょう。 フロントエンドはいつもの様に yarn start を実行し、ローカル(http://localhost:3000)で起動しても構いません。

Chrome のプラグインから MetaMask のアイコンをクリックし、ネットワーク選択プルダウンから「Rinkeby Test Network」を選択しましょう。

あとは、ブラウザをリロードしてください。

Etherscan の Transaction Fee を見ると、JunkCoinERC20 のデプロイで 0.001101454 Ether 掛かっているのがわかります。

これは 1 Gwei という最小限の Gas Price でデプロイしている金額なので、 Mainnet の場合は、今 Gas Price が 100 Gwei くらいですから、100倍すると 0.1104154 Ether。つまり21,531円です!

コントラクトでは 100,000,000 個 JunkCoin を発行していますから、一個辺り 0.00022 円くらいで売れば元がとれそうです。

4. 「DeJunkeng」で遊んでみよう!

完成したじゃんけんゲーム「DeJunkeng」は こちら にデプロイしてあります。 早速プレイしてみましょう。

如何にも志が高そうなタイトル画面で「Connect MetaMask」をクリックすると MetaMask に接続します。

「Join match」で対戦キューに並びます。 Join match の時のガス代に注目!

157,696 Unit は、Mainnet の Gas Price(100 Gwei)で計算すると・・・即ち日本円で約3,075円だ! このゲーム、参加するだけで3,000円以上持ってかれるぜ。

Confirm を押し、そのまま相手が現れるまで震えて待て。

一向に現れなければ、MetaMask でアカウントを切り替えて一人じゃんけんが出来ます。みんなも良くやるよな?一人じゃんけん。

アカウントを切り替えてブラウザをリロードすれば、再び Join match を押せます。

首尾よく相手が見つかると、じゃんけんできるので、グー・チョキ・パーのいずれかを出してみましょう。 制限時間の5分以内にトランザクションを通す必要があります。

尚、グー・チョキ・パーを出す時のガス代は日本円で約1,052円です。そして、後出しすると2,655円になります。 2倍以上の料金を支払って敗北のリスクを負い制限時間ぎりぎりまで待つか、それとも半分の料金でエコラウンドするか。熱い駆け引きが有りますね。

(トランザクション内容は公開されるので Etherscan でごにょごにょすれば・・・)

面白すぎる。

二人からグー・チョキ・パーが出そろうと結果画面が出ます。 勝利したら洩れなく JunkCoin がもらえますよ。 連勝すると連勝した数だけ無駄にもらえます。

もらったコインを自分のウォレットに引き出すには、画面右上の「Withdraw」をクリックしてください。 引き出すのに掛かる手数料は約1,506円です。

MetaMask に JunkCoin 残高を表示するには、Add Token で 0xf25Aded893150faDC14aaDf817471f3C44c325eD を追加してください。

えーと、1回じゃんけんをしてコインを引き出すまでに掛かる料金は、足すことの・・・最大で7,236円!

Test Network で本当によかった。

5. dApps を作ってみた所感

ガス代という発想は面白いものの、ガス代高騰問題が dApps 発展の足かせになっている印象でした。 問題を解決すると言われる Ethereum 2.0 には期待したいです。

2.0 を待たずとも、界隈ではレイヤー2・サイドチェーンの技術開発が進行しており、Ethereum外でやり取りを行い、 結果をEthereumのブロックチェーンに反映するという方法を取るのが流れです。 分散しなくてもいいや、と言う部分(ゲームの進行とか)はサイドチェーンで安価に処理して、 重要な部分、例えば料金や報酬等、価値のやり取りだけを Ethereum に載せる感じです。

また、ガス代が高いという事はマイクロペイメントにはすこぶる向いてないという事であり、 少額の電子アイテム販売や、投げ銭等のドネーションプラットフォームとして使おうと思っても、 取引する側・寄付する側にコストが掛かりすぎるという問題があります。 「ガスレス」という技術もあるようですが、誰かが何処かでガス代を払わなければならないのに変わりはありません。

1回に取引する価値のボリュームが大きければ無視できるコストなので、 しばらくは投資や投機と言ったハイリスク・ハイリターンの世界で活用が続くと思います。

スマートコントラクトの技術的な未来としては、如何にリアルの事象をブロックチェーンに取り込むか、 という点で新しいユースケースが現れると、さらなる注目が得られそうです。

今回は「じゃんけんのグー・チョキ・パー」というリアル事象をブロックチェーンに取り込むサンプルとも言えます。 参加者にボタンをクリックさせましたが、カメラで参加者が出した拳の形を認識するとしたら・・・。さてどうでしょう。

また、上の方で「雇用契約もスマートコントラクトになるかも?」みたいなことを書きましたが、 成果や勤怠がブロックチェーンに乗るのであれば、それに準じた給与を支払うスマートコントラクトがあってもおかしくないと思います。

弊社としては、今後もスマートコントラクトの分野に注目し取り組んで行きたいと思います。

長くなりましたが、今回はここまでです!

それではまた!

6. ソースコードは GitHub で公開中

MITライセンスで公開中です。 ご利用は自己責任でどうぞ。

バグなど見つけたら Issue までどうぞ。

次回は → Optimistic rollups で Ethereum のガス代が節約出来るかを検討する