はじめに
イーサリアムのスマートコントラクト開発で利用される Solidity において、イベントの仕様はとてもシンプルです。
イベントは event をつけて宣言します.
event TransferToken( address indexed from, address indexed to, uint id );
イベントは emit をつけて発行します.
emit TransferToken( msg.sender, reciever, tokenId );
発行されたイベントはトランザクションのレシートに記録されます.
indexed のついた引数はフィルタリングに利用でき、特定のイベントを捕捉できます.
めでたし、めでたし.
ネット上の情報や、書籍等での説明はだいたこんな感じです。わかったような気にはなりますが、「発行後のイベントを どうやって処理したらいいの?」という風にモヤモヤします。
この記事では、DApps 開発では避けて通れないのに、Solidity の解説においては多くを語られない、そんな event についてお話ししてみたいと思います。
Solidity はバックエンド向け、 event はフロントエンド向け
バックエンドという言葉があります。
エンジニア視点で見ると「裏方 = ユーザーの目につかないところで データの管理や操作を行うモノ」というイメージです。
DApps 開発におけるバックエンドといえば、イーサリアムブロックチェーンとなります。そして、ブロックチェーン上において、ユーザーの資産やデータを管理するスマートコントラクトも、バックエンドといえるでしょう。
一方、フロントエンドという言葉もあります。
「窓口 = ユーザーと直接やりとりして 裏方とのあいだを取り持つモノ」というイメージです。「顧客」を意味する「クライアント」という言葉が、同じ意味で用いられることもあります。
Solidity の event は バックエンドであるスマートコントラクトから、フロントエンドであるクライアントへ向けたメッセージの役割を担います。event を具体的に説明するには、イベントを受け取るクライアント側の前提がないと始まりません。
では、DApps 開発におけるクライアントとはなんでしょう?
DApps が Web 向けのサービスを想定しているのであれば、Chrome 等のウェブブラウザとなりそうです。PC 向けのサービスであれば、Windows や Mac プログラムになりそうですし、スマホ向けであれば、iOS や Android アプリとなるでしょう。サービスの提供先に応じてクライアントの選定が変わってきますし、当然、開発環境も大きく変わります。
Solidity の解説において event の発行後について多くを言及されないのは、バックエンドとフロントエンドというスタンスの違いがあるからだと思われます。それに、イベント発行後の説明をしようとなると、クライアント側での実装の話が必要となってきて、収拾がつかなくなりそうです。
「そこまで紙面を割くことはできない」というのが解説者さんの本音なのだと思います。
イベントはクライアント本位で考える
では、発行後のイベントについて考えてみましょう。
スマートコントラクトはユーザーの現在の残高や、トークン(資産)の現在の所有者といった、「現在の状況」を扱います。一方で、「昨日受け取った ETH の合計」や「誰が誰にどんなトークンを送った」というような「状況の変化」を追いかけるのは得意ではありません。
DApps を開発する上で、取引履歴や状況変化の通知は、ユーザーの満足度を上げるためにも手厚く実装したいところです。そして、この状況の変化をクライアント側で検出するための仕組みが、Solidity の event となります。
例えば、トークンの送信に対応した DApp を開発するとしましょう。
この DApp はユーザー間でトークンを送信しあえるのですが、コスト削減を理由に、下記の仕様でGOサインがでてしまったと仮定します。
・送信者がトークンを送ったら、そのまま黙ってホーム画面へ戻ってよい
(※トランザクションの結果はイーサスキャンで見てくれ!すまん!)
・トークン情報はトークン画面へ遷移した時に読み込めばよい
(※トークンの情報を知りたければトークン画面を開いてくれ!お願い!)
さて、運悪くこの DApp を利用してトークンを送ったAさんは、次のように思うはずです。
「送信完了のポップすらでないけど、本当に送れてるの…?」
同じく、この DApp を利用してトークンを受け取った(ことに気づいていない)Bさんは、次のように思うはずです。
「Aさんからのトークン、なかなか来ないな…」(※ホーム画面を眺めながら)
トークンの送信自体が無事に完了していたとしても、その過程の説明が不足していると、ユーザーを不安にさせてしまいます。この場合、Aさんに対する「送信が完了しました」通知、Bさんに対する「トークンが届きました」通知ぐらいは実装しておきたいところです。
では、この DApp に通知機能を加えるとしたらどうしたらよいでしょう?
イベントによる通知の実装の流れは、おおよそ下記のようになると思います。
1.コントラクトにてトークンの送信完了イベントを定義する(※例えば冒頭に挙げた TransferToken イベント)
2.コントラクトにて、トークンの送信が正常終了したら「送信完了イベント」を発行する(※ TransferToken の emit)
3.ブロックチェーン上にて、トランザクションのレシートに「送信完了イベント」が記録される
4.クライアントにて、定期的にブロックをチェックし、ユーザーに関わる「送信完了イベント」がないかを監視する
5.クライアントにて、ユーザーに関わる「送信完了」イベントを見つけたら、適切な表示を行う
(※ユーザーが送信者であれば「送信が完了しました」通知、ユーザーが受信者であれば「トークンが届きました」通知を出す)
これならば、トークンの送信完了後にクライアント上で通知が行われるため、Aさんの不安は解消され、Bさんはホーム画面でトークンの取得に気づいたはずです。
処理の結果が同じだったとしても、その過程を丁寧に通知する DApp と、そうでない DApp では、お客さんに与える安心感に大きな差が出ることでしょう。
「どのような通知があれば利用者が 安心&満足してくれるか?」
クライアント側の実装、とくにイベントの設計において、お客さんへの優しさは欠かせません。
イベント実装の具体例
サンプル DApp により、イベントの流れを具体的に見てみましょう。このサンプルのサービス内容はミニゲーム、クライアントは iOS アプリを想定します。
サービス内容
サービス内容は「みんなで カモったり カモられたりする ゲーム」です。
ゲーム内では、参加するプレイヤーのうち1人がカモにされ、周りのプレイヤーから所持ゴールドを盗まれます。逆に、カモられているプレイヤーは、最後にカモったプレイヤーを容疑者として通報することで示談金を得られます。抜け目なくカモり、仮にカモられても示談金でやり返し、所持ゴールドを高めるのがゲームの目的です。
具体的なルール(タップで開閉します)
・プレイヤーの中から「カモ」と「容疑者」が、それぞれ1人づつ認定される
・ゲームに参加するにはETHを送金してゴールドを購入する必要がある
・ゴールドを購入したプレイヤーは、新たなカモに認定される
・カモ以外のプレイヤーはカモの所持ゴールドの「5%」をカモれる(盗める)
・一番最後にカモった(盗みを働いた)プレイヤーは、新たな容疑者に認定される
・カモは容疑者を通報することで、相手の所持ゴールドの「30%」を示談金として分捕れる
・通報されたプレイヤーはペナルティとして、新たなカモに認定される
・通報したプレイヤーは逆恨みされ、新たな容疑者に認定される
ゲームに必要なデータ
ゲームを構成するためにスマートコントラクト側で管理しないといけない情報としては下記となります。
// 管理データ
mapping( address => bool ) internal valids; // プレイヤーの有効性
mapping( address => uint ) internal golds; // プレイヤーの所持ゴールド
address internal targetPlayer; // 現在のカモ
address internal suspectPlayer; // 現在の容疑者
これらの情報がブロックチェーン上で保持され、ゲームの流れによって更新されていきます。クライアント側ではこれらの情報をスマートコントラクトから読み込んで、画面へ表示します。
ですが、上記のデータだけでは「誰が誰をカモった」等の状況説明ができません。そのための情報はイベントにより通知することになります。
イベント設計と定義
さて、このゲームのフローにおいてどのようなイベントが必要になるでしょうか?
別の言い方をすると、どのような通知があるとプレイヤーが楽しんでくれそうでしょうか?
まず、自分がカモった時にはいくら盗めたか通知して欲しいですよね。それに、カモられた時は被害額を知りたいです。また、他人同士のカモりカモられも見られた方が、ワイワイやっている感じがして楽しそうです。
この通知に使うイベントを Steal として定義しましょう。
event Steal( address indexed player, address indexed target, uint ammount );
同様に、通報された時の情報も欲しいので、Report イベントも用意しましょう。
event Report( address indexed player, address indexed suspect, uint ammount );
あとは、ゲームに参加(ETH を送ってゴールドを取得)した際に、いくら入手したかのお知らせを出さないと不親切そうです。
このイベントを BuyGold としましょう。
event BuyGold( address indexed player, uint ammount );
加えて、ゴールドを入手したユーザーはカモにされるので、良くも悪くも目立たせてドキドキしてもらいましょう。
このイベントを Target としましょう。
event Target( address player, uint gold );
以上、4つのイベントがあれば、クライアント側でゲームを盛り上げることができそうです。
通知の具体的なイメージとしては、ゲームに参加するとまず、「BuyGold:ゴールドを入手しました」と表示され、続いて「Target:気をつけろ!カモられるぞ!」と表示します。その後は、自分含めた全てのプレイヤーの「Steal:カモった/カモられた」と「Report:通報した/通報された」が次々と表示されていくことになります。
メモ:
このサンプルでは、イベントをプレイヤー別にフィルタリングできるよう、address に indexed をつけて宣言しています。が、Target イベントのアドレスには indexed がついていません。これは、Target イベントは全プレイヤーに対して必ず通知されることを想定しており、フィルタリングは不要と考えたからです(ようするにどうでもよいこだわり分です)。
Solidityでの実装
ゲームの肝である、「カモる」、「通報する」、「入金する」ための処理を、「steal 関数」、「report 関数」、「ETH の入金を受け取るフォールバック関数」として用意しましょう。
steal 関数(タップで開閉します)
//--------------------------------
// 盗む(※カモをカモる)
//--------------------------------
function steal() external{
// 未参加、自身がカモなら失敗
require( valids[msg.sender], "you need to join the game" );
require( msg.sender != targetPlayer, "you can not steal your own gold" );
// 最後に盗みを働いたプレイヤーが容疑者となる
suspectPlayer = msg.sender;
// カモの所持ゴールドを5%盗む(※端数入り揚げ)
uint ammount = (golds[targetPlayer]+19) / 20;
golds[suspectPlayer] += ammount;
golds[targetPlayer] -= ammount;
emit Steal( suspectPlayer, targetPlayer, ammount );
}
report 関数(タップで開閉します)
//--------------------------------
// 通報する(※カモがカモる)
//--------------------------------
function report() external{
// 未参加、自身がカモでなければ失敗
require( valids[msg.sender], "you need to join the game" );
require( msg.sender == targetPlayer, "you can not report your own crime" );
// 容疑者がカモになり、逆恨みされた通報者が容疑者となる
targetPlayer = suspectPlayer;
suspectPlayer = msg.sender;
// 示談金としてカモ(旧容疑者)が所持ゴールドの30%を支払う(※端数入り揚げ)
uint ammount = (3*golds[targetPlayer]+9) / 10;
golds[suspectPlayer] += ammount;
golds[targetPlayer] -= ammount;
emit Report( suspectPlayer, targetPlayer, ammount );
}
入金(フォールバック)関数(タップで開閉します)
//-------------------------------------------
// ETHを送ったら参加 & ゴールドを得る & 狙われる
//-------------------------------------------
function () external payable {
// 参加費は 1 gwei 以上
if( !valids[msg.sender] ){
require( msg.value >= 1000000000, "please send 1 gwei or more, to join the game" );
valids[msg.sender] = true;
}
// ゴールドの入手
golds[msg.sender] += msg.value;
emit BuyGold( msg.sender, msg.value );
// 早速狙われる
targetPlayer = msg.sender;
emit Target( targetPlayer, golds[msg.sender] );
}
これらの関数が正常に終了すると、それぞれに対応するイベントが emit にて発行され、クライアント側で検出できるようになります。
また、クライアントが管理データを参照できるように、status 関数も用意しましょう。クライアントがイベントを捕捉した際、ゲームの状況を画面へ反映させるために、この関数が呼ばれる想定です。
status 関数(タップで開閉します)
//--------------------------------
// ゲームの状況
//--------------------------------
function status() external view returns( uint retGold, address retTarget, uint retTargetGold, address retSupsect, uint retSuspectGold ){
// 未参加なら失敗
require( valids[msg.sender], "you need to join the game" );
retGold = golds[msg.sender]; // プレイヤーの所持ゴールド
retTarget = targetPlayer; // カモのアドレス
retTargetGold = golds[targetPlayer]; // カモの所持金
retSupsect = suspectPlayer; // 容疑者のアドレス
retSuspectGold = golds[suspectPlayer]; // 容疑者の所持金
}
status 関数の返値は、クライアントの表示の都合によるものです。クライアント側が要求するのは、プレイーヤーの所持ゴールド、現在のカモと容疑者のプレイヤー、それぞれの所持ゴールドとなります。画面仕様等がかわれば、返値の内容はその都度修正されることになります。
これにて、Solidity 側の実装は終了です。
最終的なコードはこちらとなります。
発行されたイベントの登録先
クライアントからスマートコントラクトの関数が呼ばれるとイベントが発行され、トラザクションのレシートに登録されます。この情報はイーサリアム上に保持され、クライアント側から参照することが可能となります。
保存内容は、イベント定義(イベント名と引数の型の羅列)のハッシュ値と、各引数の値を羅列したものとなります。indexed をつけた引数は Topics 枠に登録され、ついていない引数は Data 枠に登録されます。この時、イベント定義のハッシュ値が Topics[0] に登録され、イベント名によるフィルタリングに対応されます。また、event 定義で indexed で指定された引数は、Topics[0] の後ろから定義順に並ぶことになります。
例えば、steal イベントをイーサスキャンで見ると下記のような内容となります。
Topics
[0] 0xc4521c77cd8558a7102424919c444912561b76d8973dfc5482ea96932e5a9a47
[1] 0x00000000000000000000000093231c1b82547b20e85b70b87f1c98dcbd35d88a
[2] 0x000000000000000000000000431e16c88dc8a449b93fb67d2bb08f1f6695348c
Data
Hex 00000000000000000000000000000000000000000000000000000000170bb4c9
indexed で指定した player と target の引数は Topics[1] と Topics[2] として登録されています。一方で、indexed 指定のない ammount の引数は Data に登録されています。
さて、indexed をつけることで、この Topics 配列に追加するメリットはというと、指定の引数が添字で参照可能になるため、クライアント側でイベントのフィルタリングに利用できるという点です。
クライアント側の実装
このサンプルでのクライアントは iOS での実装となります。
先にお話した通り、どのプラットフォームをターゲットにするかにより開発環境は大きく変わります。ここでは、iOS 特有の実装には深入りせず、クライアントがどのようにイベントの処理をするかの流れにしぼって説明します。
まず、イベント処理の基本は、新しく発行されるイベントがないかの監視なので、監視ループを開始します(※コードの詳細はわからなくても雰囲気をつかんでくだされば十分です)。
イベント監視ループの開始(タップで開閉します)
// イベントループの開始
let web = helper.getCurWeb3()!
let functionToCall: web3.Eventloop.EventLoopCall = onNewBlock
let monitoredProperty = web3.Eventloop.MonitoredProperty.init(
name: "onNewBlock",
queue: web.requestDispatcher.queue,
calledFunction: functionToCall
)
web.eventLoop.monitoredProperties.append( monitoredProperty )
web.eventLoop.start( 1.0 )
イベントの監視ループでは一定間隔おきに新規ブロックが監視され、新しいブロックが検出されたら、そのブロック中のイベントををフィルタリングします。
イベントのフィルタリング(タップで開閉します)
var filter = EventFilter()
filter.fromBlock = .blockNumber(UInt64(blockNumber))
filter.toBlock = .blockNumber(UInt64(blockNumber))
// フィルター:BuyGold
if eventName == "BuyGold" {
// 自身のアドレスでフィルタ
filter.parameterFilters = [
([self.helper.getCurAddress()!] as [EventFilterable])
]
}
do{
guard let result = try contract?.getIndexedEvents( eventName:eventName, filter:filter ) else {
return
}
サンプルコードでは、BuyGold はプレイヤーのアドレスでフィルタリングしています。これにより、プレイヤーが発行した BuyGold イベントだけが抽出され、プレイヤー自身のゴールド購入通知のみが表示されることになります。
一方で、Steal と Report イベントはイベント名のみでフィルタリングしています。結果として、全ての「カモられた」、「通報された」イベントが検出されることになりますが、検出後、イベントの引数のプレイヤーが自身であるかどうかにより、表示の割り振りを行なっています。
同様に、Target イベントもイベント名のみでフィルタリングを行い、検出後、自分がターゲットにされたのか否かで表示を割り振っています。
検出したイベントの通知(タップで開閉します)
if result.count > 0 {
for event in result{
switch event.eventName {
case "BuyGold":
addLog( "購入完了 \(event.decodedResult["1"]!) GOLDを手に入れた!" )
break;
case "Target":
let addressTarget = (event.decodedResult["0"] as? EthereumAddress)!.address
if( addressTarget != self.helper.getCurEthereumAddress() ){
addLog( "カモ(\(addressTarget.prefix(10))...)発見! \(event.decodedResult["1"]!) GOLD所持している!" )
}else{
addLog( "カモ認定された! 気をつけろ!" )
}
break;
case "Steal":
let addressPlayer = (event.decodedResult["0"] as? EthereumAddress)!.address
let addressTarget = (event.decodedResult["1"] as? EthereumAddress)!.address
if( addressPlayer == self.helper.getCurEthereumAddress()! ){
addLog( "カモ(\(addressTarget.prefix(10))...)から \(event.decodedResult["2"]!) GOLD 盗んだ!" )
}else if( addressTarget == self.helper.getCurEthereumAddress()! ){
addLog( "容疑者(\(addressPlayer.prefix(10))...)に \(event.decodedResult["2"]!) GOLD 盗まれた..." )
}else{
addLog( "カモ(\(addressTarget.prefix(10))...)が容疑者(\(addressPlayer.prefix(10))...)にカモられてるw" )
}
break;
case "Report":
let addressPlayer = (event.decodedResult["0"] as? EthereumAddress)!.address
let addressTarget = (event.decodedResult["1"] as? EthereumAddress)!.address
if( addressPlayer == self.helper.getCurEthereumAddress()! ){
addLog( "容疑者(\(addressTarget.prefix(10))...)から \(event.decodedResult["2"]!) GOLD の示談金を奪った!" )
}else if( addressTarget == self.helper.getCurEthereumAddress()! ){
addLog( "カモ(\(addressPlayer.prefix(10))...)に \(event.decodedResult["2"]!) GOLD の示談金を奪われた..." )
}else{
addLog( "カモ(\(addressPlayer.prefix(10))...)が容疑者(\(addressTarget.prefix(10))...)を通報してるw" )
}
break;
default:
addLog( "エラー:知らないイベント \(event.eventName)" )
break;
}
}
}
上記の処理において、Topics のフィルタリングによりイベントを抽出し、Data 連想配列から詳細情報が取り出されて処理する流れを、イメージしていただけたことと思います。
これにて、クライアント側での実装も終了です。
最終的なサンプルプロジェクトはこちらとなります。
(※iOS アプリ上でのイーサリアムへの接続に関してはこちらの記事を参照ください)
ゲームの動作確認
複数台で並べてゲームを遊んでみた様子です。
プレイヤーCの様子:両者の様子を眺めつつ、姑息にカモっている
画面下にでている時間付きのログが、イベントによって通知された情報です。各クライアントによる「カモる/通報する」行為の結果がイベントを発生させ、クライアント毎にフィルタリングされて新しい順に表示されているのが見て取れるかと思います。
このサンプル DApp の通知方法はシンプルなログの垂れ流しですが、イベント処理としては十分な機能が備わっています。あとは、絵的/音的な演出を作り込んでいくことで、ゲームとしての面白みを向上していけると思われます。
まとめ
Solidity の仕様としては ほんの小さな event の存在ですが、クライアント側で果たす役割の大きさを具体的にイメージしていただけたのであれば幸いです。
DApps 開発において、クライアントの使い勝手は、ユーザーの満足度に大きく影響してきます。ユーザー本位のクライアントフローを検討し、優しさあふれるイベント設計を心がけたいものです。