JavaサーバーサイドエンジニアのEthereum開発入門
Ethereumのスマートコントラクトを活用したアプリケーション開発についていろいろやった内容をまとめる
Ethereumおさらい
コンピュータサイエンスの観点から見たイーサリアムは、決定論的であるものの実質的に制約のない状態マシンであり、グローバルにアクセス可能なシングルトン状態(単一の状態)と、その状態に変更を加える仮想マシンで構成されています。
実用的な観点から言うと、イーサリアムは、スマートコントラクト(smart contract)と呼ばれるプログラムを実行する、グローバルに非中央集権化された、オープンソースの演算の基盤です。
Andreas M. Antonopoulos; Gavin Wood. マスタリング・イーサリアム ──スマートコントラクトとDAppの構築 (Kindle の位置No.565-569).
サーバーサイドエンジニアから見たEthereum
厳密さを完全に抜くと、
- パブリックに公開された
- 誰でも操作可能で
- 改竄不可能で
- トリガーやイベント通知システムを持ち
- 結果整合性を持つ
分散データベース みたいなもんとして考えられます。
Ethereumブロックチェーンは、Yellow Paperの仕様を実装した共通のプロトコルで通信するEthereumクライアントプログラムが相互に接続して形作られるネットワークです。
- このクライアントプログラムによって、ブロック検証やデータの保持、状態の導出、クライアントAPI、プログラム実行環境が提供されます。
クライアントはさまざまな言語で実装され、サポートする機能によってクライアントの種類が変わります
Ethereum Yellow Paper: https://ethereum.github.io/yellowpaper/paper.pdf
Ethereum Virtual Machine [EVM]
イーサリアムはチューリング完全であり、EVM(イーサリアム仮想マシン)上でプログラム(スマートコントラクト)を実行可能です。 つまり、任意の操作を行うスマートコントラクトを実行可能ですが、以下の制約があります。
- EVM上で利用可能なコンポーネントへのアクセスのみ許可されます
つまりEVM上で実行されるプログラムではネットワークアクセスやEVM外のストレージアクセスは許可されません
JVMなどとは異なり、実行順序の決定がEVM外部で決定されるのでスケジューリング機能がありません
- スマートコントラクトは、各イーサリアムクライアントのEVM上で実行されるので、決定的である必要があります
- つまり非決定的である乱数などの値を利用することができません
- 一方、「ガス」というEVMバイトコード毎に割り当てられている実行コストによって実行上限が決定される。このガスの上限に到達した時点でコードの実行がrevertされることになる => 停止性問題への対応
EVM上で動かすことができるのは、EVM独自のバイトコードで記述された命令セットです。 実際にはEVMバイトコードにコンパイル可能な、Solidityなどのスマートコントラクト用のプログラミング言語でコードを記述して、コンパイルすることでEVMバイトコードを生成可能です。
- 算術演算とビット単位の論理演算
- 実行コンテクストに関する問い合わせ
- スタック、メモリ、ストレージへのアクセス
- 制御フローに関する操作
- ロギング、関数コール、その他の演算子
Andreas M. Antonopoulos; Gavin Wood. マスタリング・イーサリアム ──スマートコントラクトとDAppの構築 (Kindle の位置No.10163-10164).
Ethereumの状態更新
- スマートコントラクトを宛先にして以下のパラメータなどを指定したトランザクションを発行することにより、スマートコントラクトを実行します。
- 引数
- 実行する関数を示すセレクタ
- 実行のために設定するガス
- スマートコントラクトの実行結果は決定的であり、全てのクライアントで実行される結果は同じになります。正しくスマートコントラクトが実行されると、イーサリアムの状態であるストレージが更新され、やがて全てのクライアントが同一の状態について合意することになります。
実際のアプリケーションアーキテクチャの例
よくEthereum上で動作するアプリケーションとしてDAppsが有名ですが、これはバックエンドをEthereumとし、特定の中央集権のインフラストラクチャによらずアプリケーションを提供するものです。 ブロックチェーン上のアドレスの所有権というのは秘密鍵と署名によって担保されているので、プライベートネットワーク内のServer Side Applicationからでもトランザクションを送信したり現在のチェーンの状態を取得することは可能です。
分権型 予約型純広告アドネットワークを作る
では実際に以下の構成で分権型の純広告アドネットワークを作っていきましょう。
- Ethereum上の広告ネットワークコントラクト
- 広告枠と広告を管理可能なDApps
- 広告枠に対して実際に広告を配信するサーバアプリケーション
- 承認と配信のための実際の広告情報をホストする管理サーバアプリケーション
アドネットワークの全体像
では開発するアドネットワークの全体像は以下のようになります。
(DAppsと言っているが、ちょっと時間の都合上ローカルサーバで動かすためにホスティングがDecentralizedではないのは :sorry: )
分権型 予約型純広告アドネットワークで広告を配信するまでの流れ
実際に開発の説明に入る前に、どのような流れで広告配信が行われるかを確認します。
- アドネットワークコントラクトがEthreumネットワーク上にデプロイされる
- 広告枠主はDAppsにアクセスし、自身の所有する広告枠を作成するトランザクションを発行します。広告枠に関する情報はコントラクトの状態として記録されます。
- 広告主はDAppsにアクセスし、広告管理バックエンドを利用するためにEthereumアカウントによる署名を用いてログインを行います。
- DApps上で、広告を配信したい広告枠を探し、その広告枠に掲載するための広告情報を事前に作成します。これはAdFormatV1というフォーマットを定義します。AdFormatV1に沿ったデータは、広告承認フローと広告配信フローで利用されます。
interface AdFormatV1 { inventoryId: number; ownerAddress: string; startTime: number; endTime: number; adPrice: number; adTitle: string; adDescription: string; landingPageUrl: string; displayImageUrl: string; nonce: string; }
- Ethereumアカウントでログインしている場合、広告管理バックエンドで広告ストレージと配信管理が利用可能です。まず、広告情報を、広告承認/配信で利用できるように広告管理バックエンド経由でストレージにアップロードします。
- AES-GCM方式の128bit長の共通鍵を生成しておきます
- AdFormatV1をJSONにエンコードします
- 共通鍵を用いてエンコードされたデータを暗号化します
- 広告枠のRSA Public Keyを用いて共通鍵を暗号化します
${version}:${encryptedEncryptionKey}:${ciphertext}
の形式で、広告管理バックエンドにアップロードします。- 広告管理バックエンドでは、ログインしていることを確認し、アップロードされたデータの SHA-3(Keccak)を計算し、このハッシュでアクセスできるように静的ホストします。
- レスポンスとして、SHA-3ハッシュの値を返却します
これを、広告枠主のPublic Keyと、事前に入手しておいた配信サーバ用のPublic Keyの両方で処理しておきます。
- 広告枠主が自分の所有する広告枠の承認待ちの広告をチェックします
- 広告枠主は、承認待ちの広告のSHA-3ハッシュを用いて、広告管理バックエンドから暗号化されたAdFormatV1データをダウンロードします。自身の秘密鍵を用いて、共通鍵を複合化します。そして共通鍵によってAdFormatV1データを複合します。この結果を閲覧し、審査作業を行います。
広告枠主が広告の掲載が問題ないと判断すれば承認のためのトランザクションを実行し、コントラクト上で配信可能なステータスに遷移します。
広告主は広告管理バックエンドを介して、広告配信サーバに対し、配信前に事前に配信有効化を行います
- 広告配信サーバは、定期的にコントラクトの状態をチェックし、配信設定が有効化されているものについて、ロードを行います。ロードの際に、公開鍵を用いてAdFormatV1を複合しlandingPageやdisplayImageなどの情報をキャッシュしておきます。
- 広告枠主が事前にJS SDKにinventoryIdを設定し、広告枠のメディアページに設置しておきます。
- エンドユーザーがアクセスしたときに、inventoryIdを含めてリクエストし、配信サーバでキャッシュされている広告のうちランダムなものを返します
- JS SDKで表示します
では実際にコントラクトの開発から見ていきます。
アドネットワークコントラクトの開発
- Solidityによってスマートコントラクトを開発します
SolidityはC++, Python, JavaScriptを参考に作られたスマートコントラクト向けの静的言語です
- int8,32,256, uint8,32,256, 配列, mappingなど基本的なデータ型
- if, forなど基本的な制御構文
- address,payable address型, memory/storage修飾子, イベントなどのSolidity特有の型
- Structs 構造体
https://solidity-jp.readthedocs.io/latest/ 詳細はドキュメントで
Ethereum 開発エコシステム
- IntelliJ: 開発環境, Solidityプラグインがあり開発可能. サジェスト弱め.
- solc: Solidityのコンパイラ
- Truffle: コンパイル,テスト,パッケージ化のためのツール
- Ganache: GUIで操作可能なローカルEthereum環境
最近はHardhatというツールがTruffleよりも流行りらしい。
アドネットワークコントラクトの状態
状態として以下を保持します
- カウンタ
- 広告枠ID採番のためのカウンタ
- 広告ID採番のためのカウンタ
- データ
- 広告枠Set
- 広告Set
- マッピング型
- 承認待ちの広告のためのadId => inventoryId mapping
- 承認済みの広告のためのadId => inventoryId mapping
- 広告主の現在の広告数のためのaddress => adCount mapping
- 広告枠主の現在の広告枠数のためのaddress => inventoryCount mapping
- 広告枠に登録されている現在の広告数のためのinventoryId => adCount mapping
- 広告枠に登録されている現在の承認待ちの広告数のためのinventoryId => adCount mapping
アドネットワークコントラクトで提供する関数
- 広告枠の作成
function createAdInventory(string memory name, string memory uri, string memory publicKey, uint256 floorPrice) external)
- 広告枠の削除 - 広告が1件も登録されてなければ広告枠を削除します
function removeAdInventory(uint256 _inventoryId) external
- 広告の作成 - 広告を作成する際に広告枠のフロアプライスを超えるデポジットが必要です
function createAd(uint256 _inventoryId, bytes32 _hash, bytes32 _hashForDelivery, uint32 _start, uint32 _end) external payable
- 広告の承認 - 広告を承認すると配信対象になり配信期間の間だけ配信されます
function approveAd(uint256 _inventoryId, uint256 _adId) external
広告の却下 - 広告を却下すると広告は削除されデポジットは返却されます
function rejectAd(uint256 _inventoryId, uint256 _adId) external
広告枠から広告の回収 - 広告配信が完了した後、広告枠を占有されるのを防ぐため回収して削除する必要がありますが、広告主と広告枠主の両方がこの操作が可能です。広告主が行う場合、インセンティブとしてデポジットされている価格のうち一部が返却され、回収された時点で広告枠主のアドレスに料金が支払われます。
function collectAd(uint256 _inventoryId, uint _adId) external
- 広告枠の広告の取得
function getAdsOf(uint256 _inventoryId) external view
- 広告主アドレスから広告の取得
function getAdsByOwnerAddress(address _ownerAddress) external view
- 広告枠主アドレスから広告枠の取得
function getInventoriesByOwnerAddress(address _ownerAddress) external view
- 広告枠の取得
function getInventory(uint256 _inventoryId) external view
- 広告枠の取得 by (offset, limit)
function getInventories(uint offset, uint limit) external view
スマートコントラクトでデータの削除を考える
ブロックチェーンで改竄やデータの削除ができない、というのは履歴データとして残るのでデータが削除できない、という話です。スマートコントラクトの状態データとして、追加のみをサポートしてしまうとストレージの中にゴミデータが大量に残ってしまいます。 ゴミデータが増えれば、EVM上で処理をする際に追加のコストを払う必要が出てきますし、最悪ガス代がブロックガス上限を超えることでコントラクトが全く動作しないものになってしまう恐れもあります。
そのため、コントラクトの状態データは不要になった時点で削除することも考えなければなりません。
しかし、Solidityで基本的に提供されているコレクションのデータ構造は固定長/可変長配列とマッピングに限られます。
可変長配列を使うケース
では単なる可変長配列をマスターデータを管理するためのデータ型とします。
可変長配列でサポートされている操作は、array[x]
のような添字アクセスとdelete array[x]
のような特定の要素の削除に加え以下のメソッドがサポートされています。
length
: 配列の長さを返しますpush
: 配列の最後に要素を追加しますpop
: 配列の最後から要素を削除します
広告構造体を格納するAd[] storage ads
フィールドに新しい広告を追加する時はpush
を呼び出せば十分です。
しかし、ある広告IDをもつ広告を削除したい時にどうすれば良いでしょう?
普通に削除すると、pop
だと最後の要素しか削除できないですし、delete
を使うと削除した要素が歯抜けになります。配列を毎回作り直すと、これはコントラクトの状態を大きく変化させるガスコストが非常に高い操作であるので避けなければなりません。
Array Members - Solidity https://solidity-jp.readthedocs.io/ja/latest/types.html?highlight=array#array-members
EnumerableSet
解決策として、SolidityでHashSet相当を実装します。
ここで OpenZeppelin
というSolidityのライブラリでは、EnumerableSetという32bytes値をkey、32bytes値をvalueとするHashSetが定義されています。
これはそのままだと目的の利用ができないので、参考にしつつInventorySet, AdSetというデータ型を新しく作成します。
struct InventorySet { Inventory[] _values; mapping(uint256 => uint256) _idxMapping; } struct AdSet { Ad[] _values; mapping(uint256 => uint256) _idxMapping; }
内部で、「Key => 配列のIndexのmapping」と、「データを保持する配列」の二つを保持します
- OpenZeppelin EnumerableSet.sol https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/structs/EnumerableSet.sol
EnumerableSetの実装を確認してみます。これは以下の操作を行えます。
add
: 要素の追加をO(1)で行います. 単に配列の最後にpushし、KeyのIndexにlengthの値をセットします。remove
: 要素の削除をO(1)で行います. keyが存在する場合、まずkeyが示すindexの要素と最後の要素をスワップします。スワップした後、popによって最後の要素を削除することで配列のフラグメンテーションが発生せずに削除可能ですcontains
: 要素の存在判定をO(1)で行います.- 内部の配列に直接アクセスして要素を列挙したりすることも可能です
この実装により、要素の削除がO(1)で行えるようになるのでガスコストを節約しつつストレージの整理を行うことができます。
スマートコントラクトのテスト
Truffleではスマートコントラクトの以下のテストをサポートしている
- JavaScriptでのクライアントサイドテスト
- Solidityでコントラクトを直接呼び出すテスト
Solidityでのテストは以下のように記述できる
contract AdNetworkTest { address deployedAddress = DeployedAddresses.AdNetwork(); AdNetwork adNetwork = AdNetwork(deployedAddress); InventoryOwner inventoryOwner = new InventoryOwner(adNetwork); function testCreateAdInventory() public { string memory name = "sample_inventory"; string memory uri = "https://example.com/"; string memory publicKey = "dummy_public_key"; uint256 floorPrice = 0.01 ether; uint256 inventoryId = inventoryOwner.createAdInventory(name, uri, publicKey, floorPrice); Assert.equal(inventoryId, 1, "Ad Inventory can be created"); (uint256[] memory inventoryIds,,,,,) = adNetwork.getInventoriesByOwnerAddress(address(this)); Assert.equal(inventoryIds.length, 0, "Inventory can't be retrieve by not owner address"); (inventoryIds,,,,,) = inventoryOwner.getInventoriesByOwnerAddress(); Assert.equal(inventoryIds.length, 1, "Inventory can be retrieve by owner address"); } }
管理用のDAppsを開発する
次に、広告主と広告枠主が実際に広告枠や広告を管理するためのフロントエンドアプリケーションであるDAppsを開発していきます。
主に以下の項目がサポートされます。
管理用のDAppsを開発する - フロントエンドからイーサリアムを操作する
フロントエンドからMetamaskなどのEthereum Providerを通してEthereumネットワークに接続するためのライブラリとして、web3.js
というJavaScriptのライブラリが利用できる。
// Modern dapp browsers... if (window.ethereum) { const web3 = new Web3(window.ethereum as any); // Request account access if needed await (window.ethereum as any).enable(); // Acccounts now exposed web3.xxx();
ChromeにMetamaskなどのProvider Extentionがインストールされていれば、Providerを検出してそれをもとにWeb3オブジェクトを生成し、このオブジェクトを介してフロントエンドでEthereumとやりとりをすることができる。
管理用のDAppsを開発する - コントラクトを操作する
- スマートコントラクトをビルドすると、ABI(Application Binary Interface)というスマートコントラクトとやりとりを行うためのインタフェース定義も出力される。 https://raw.githubusercontent.com/deftfitf/blockchain-adnetwork/master/adnetwork-contracts/client/src/contracts/AdNetwork.json
- このjsonを、先ほどのweb3.jsで提供されるコントラクトクラスをインスタンス化する際の引数に渡すことで、コントラクトを操作するためのオブジェクトが生成できる。
const contract = new web3.eth.Contract( abi as any, deployedNetwork && deployedNetwork.address, )
管理用のDAppsを開発する - コントラクトの操作を型安全にする
web3.jsでコントラクトを操作する際、型がないので何を書いているのか分からなくなる。 TypeChainというnpmでインストール可能な、ABIからTypeScriptの型定義を出力してくれるツールがある。 * TypeChain https://github.com/dethcrypto/TypeChain
以下のようなコマンドで型定義を生成し、
npx typechain --target=web3-v1 --out-dir=src/types/web3-v1-contracts src/contracts/**
const contract = (new web3.eth.Contract( abi as any, deployedNetwork && deployedNetwork.address, ) as any) as AdNetwork;
このようにキャストすることでコントラクトから呼び出し可能な関数を補完することができて快適になる。
それ以外は特に普通にフロントエンドアプリ書く時と特に変わりないはず。
管理用Server Side Applicationを開発する - 実装する機能
管理用アプリケーションを開発する - Ethereumアカウントでのログイン実装
- 誰でも任意の広告枠の広告配信を有効化したりできてしまうと問題です。広告枠の配信有効化や、配信に利用する画像アップロード機能などは、特定のアドレスのEthereumアカウントの所有者にのみ限定したりする必要があります。
- 通常のEthereumのトランザクションは、そのアカウントの秘密鍵による署名によって所有を証明します。Ethereumアカウントでのログインも、同様の仕組みで実装します。
以下の手順でログイン処理を行います。
- ログインしたいEthereumアカウントに対して一度だけ有効なランダムなチャレンジ文字列を生成するようにバックエンドにリクエストする[UUID]
- DApps上でチャレンジ文字列に対してMetamaskを介してログインしたいEthereumアカウントで署名を行う
- 署名後の文字列と、所有を証明したいEthereumアカウントをログインエンドポイントに投げる
- 対象のアカウントのチャレンジ文字列と送信された署名を用いてアドレスを複合する. (EthereumアドレスはECDSA公開鍵の後ろ20bytesなので、ECDSAの署名検証の処理によりアドレスが復元可能)
- 復元されたアドレスとログインリクエストしたアドレスが同一であればログイン許可し、セッションを発効。以後Spring Sessionでウンタラカンタラ。
配信用アプリケーションを開発する - JavaからEthereumを操作するには
- Java 開発者のためのイーサリアム(https://ethereum.org/ja/java/) というまさにうってつけの公式ページがある。
JavaからEthereumを操作するにはWeb3Jというライブラリを利用する。
EthereumクライアントはJSON-RPCを提供しているため、JSON-RPCを介してEthereumを操作することができる。
なので、基本的に定期的に実行するローディング処理によって対象のコントラクトからデータをロードするだけで普通にDBからデータをロードするようなものと変わりません。