🪝

Evernodeのコントラクトチュートリアル

2023/08/15に公開

Evernodeとは

EvernodeはXRPレジャー上で動作する「レイヤー2」スマートコントラクトを提供するプロジェクトです。HotPocketプロトコルとHooks Amendmentsを組み合わせ、XRPレジャーと連携して任意のdAppを安価かつ高速に実行できるグローバルな分散ネットワークを作成します。

2023年中に予定されているのXRPL Hooksサイドチェーンのリリースに合わせリリースされる予定です。

https://zenn.dev/tequ/articles/xrpl-evernode-concept

HotPocket

HotPocketはスマートコントラクトのコンセンサスエンジンです。JavaScriptやPaythonな従来のプログラミング言語で書かれたアプリケーションを分散型アプリケーションに変換できます。

クラスタ

HotPocketはクラスタを構成し、それぞれのインスタンス(ノード)同士で通信します。所定のアルゴリズムを通してクラスタ内の信頼できるインスタンスの出力値を比較し、最終的な合意(コンセンサス)に達します。

このコンセンサスはXRP Ledgerが採用しているUNLの仕組みを利用していますが、XRPLのUNLを直接利用しているわけではありません。

このクラスタは1つの小さなブロックチェーンと考えることもできます。

スマートコントラクト

HotPocketスマートコントラクトはクラスタ内の各ノードにそのコピーが存在するアプリケーションソフトウェアです。HotPocketはコンセンサスラウンドの終了時にスマートコントラクトを起動し、ユーザの入力の処理、状態の変更、ユーザの出力を生成します。

スマートコントラクトの処理中でもクラスタ内の他のノードと通信し、サブコンセンサスを形成可能です。

コンセンサス

HotPocketでは次のデータをコンセンサスの対象としています。

  • ユーザ
  • ユーザの入力
  • ユーザの出力
  • 状態

各ノードは他のノードと通信し、クラスタの最終的な見解と見做される結論に達します。

このコンセンサスの処理は事前に決められたアルゴリズムによって行われ、コンセンサスの処理は、スマートコントラクトによって事前に設定されたスケジュール(通常は数秒ほど)によって実行されます。

consensus

UNL

各HotPocketノードは信頼できる他のノードのリストを保持しています。これはユニークノードリスト(UNL)と呼ばれ、このリスト上の信頼できるノード間でのみ処理が行われるコンセンサスに必要です。信頼できるノードは、2つのノード間の接続を確立する際に提示され、公開鍵によって識別されます。接続されたノードの公開鍵がUNLの中にある場合、HotPocketはコンセンサスプロセスにおいて他のノードから提示された情報を考慮することを選択します。

Users

"ユーザ"と呼ばれる外部の参加者は、WebSocketを使用してHotPocketノードに接続できます。各HotPocketノードは、そのノードに接続しているユーザーを追跡します。クラスタ内のすべてのノードは、相互に接続ユーザ情報を共有し、接続しているすべてのユーザの情報を共有するために使用されます。要するに、概念的には、ユーザがHotPocketノードに接続する時、そのユーザはクラスタに接続しています。実際、ユーザが接続するノードは、クラスタへのユーザの出入り口として機能します。

HotPocketは、HotPocketとのWebSocket接続を確立する際にユーザが提示する公開鍵によってユーザを一意に識別します。これはHotPocketによって暗号的に検証され、この確立された接続を介して交換される後続のすべてのデータは、その公開鍵によって表されるユーザのものであると仮定されます。

User inputs

ユーザは任意のデータをHotPocketノードに送信し、各HotPocketノードに紐付くスマートコントラクトアプリケーションによって処理されます。各HotPocketノードは、受け取った全てのユーザ入力をクラスタ内の他のノードと共有します。コンセンサスプロセスが終了すると、各ノードはコンセンサスされた入力をスマートコントラクトに送信します。これにより、各HotPocketノード上のスマートコントラクトのコピーには、他のノードと同じユーザ入力セットが入力されるようになります。スマートコントラクトは、必要と思われるユーザ入力を処理し、この情報に基づいて行動します。

User outputs

HotPocketスマートコントラクトアプリケーションは、そのロジックに基づいて、クラスタに接続された特定のユーザに送信する任意のデータ(ユーザ出力)を生成できます。各HotPocketノードはスマートコントラクトによって生成されたユーザ出力を共有し、コンセンサスにかけます。コンセンサスは、HotPocketノードが生成したユーザ出力セットに過半数が同意することを保証するのに効果的です。合意された出力は、各ユーザが接続しているHotPocketノードを経由して、本来の受信先ユーザに送り返されます。

State

HotPocketスマートコントラクトは、通常のファイルシステム操作によってデータをディスクに永続化できます。HotPocketは、ディスクに永続化されるデータについて大多数のノードが合意するように、永続化されたデータをコンセンサスにかけます。合意されたディスク上のデータは、HotPocketクラスタ全体のスマートコントラクトの"State"と呼ばれます。コンセンサスラウンド後にスマートコントラクトが起動されると、通常のファイルシステム操作でディスク上のコンセンサスデータ(State)を読み書きできます。

HotPocketノードは、自分のStateがクラスタ上の他のノードと"同期していない"ことを発見した場合、自分のStateをクラスタの合意されたStateと同期させようとします。この場合、自分のStateがクラスタと同期するまでスマートコントラクトを起動しません。

Stateにあるスマートコントラクト: 必須ではありませんが、スマートコントラクトのアプリケーションソフトウェア自体が"State"に存在することが望ましいです。これにより、スマートコントラクトのアプリケーションファイルもコンセンサスの対象となり、各HotPocketノードが同じスマートコントラクトのバージョン/ロジックをホストしていることが保証されます。

LCL(Last Closed Ledger)

各コンセンサスラウンドの終了時(コンセンサスが得られ、スマートコントラクトの実行が開始される前)に、HotPocketはレジャーを作成します。すべてのレジャーは、前のレジャーハッシュと現在のデータハッシュを含むハッシュを持っています。このハッシュを組み合わせることで、すべてのレジャーのハッシュが前のレジャーのハッシュに基づいているチェーンを構築します。さらに、レジャーには状態ハッシュ、ユーザハッシュ、ユーザ入出力ハッシュなどが含まれます。すべてのレジャーにはシーケンス番号があり、レジャーの作成時にインクリメントされます。

コンセンサスラウンド

以下は、任意のN番目のコンセンサスラウンドで起こることの概要を説明したものです。

  1. N番目のコンセンサスラウンドの役割は、"ローカル"データに基づいて"コンセンサス"データを決定することです。ローカルデータとは、1つのHotPocketノードで記録されたデータのことです。
  2. ラウンドNのローカルデータの構成
    • ユーザが最近このノードに送信した新しいユーザ入力。
    • ラウンドN-1からのコントラクト実行によって生成されたユーザ出力。
    • ラウンドN-1からのファイルシステムの状態ハッシュ。
  3. N番目のコンセンサスラウンドは上記のデータをUNLノード間で共有し、コンセンサスの結論を出します。ラウンドNの終わりに近づくと、データが"合意"されます。
  4. この時点でHotPocketはコンセンサスデータのハッシュを含むレジャーを作成します。このレジャーには
    • Nラウンドの合意されたユーザ入力のハッシュ
    • ラウンドN-1からの合意されたユーザ出力のハッシュ
    • ラウンドN-1からのファイルシステム状態ハッシュ
  5. レジャー作成後、HotPocketはユーザと通信します。
    • 合意されたユーザ入力については、関連するユーザに入力がレジャーに登録されたことを通知します。
    • N-1ラウンドで合意されたユーザ出力は、関連するユーザーに送信されます。
  6. その後、スマートコントラクトが実行されます。
    • ファイルシステムは、最後にクローズされたレジャー(ラウンドN-1からの状態)にあるコンセンサスされた状態を表します。
    • コントラクトに入力されるユーザー入力は、最後にクローズされたレジャーにあるものです(Nラウンドからのユーザ入力)。
  7. コントラクトの実行終了後、生成された「ローカル」ユーザー出力は、ラウンドN+1で使用するために共有されます。その後、ラウンドN+1に移行し、このプロセスを繰り返します。

round

Readリクエスト

前のセクションでは、HotPocketノードがどのように一体となって動作し、どのようにスマートコントラクトがコンセンサスされた情報に基づいて動作するように呼び出されるかを説明しました。これにより、HotPocketクラスタ全体が互いに同期して前進し、各スマートコントラクトが合意された情報に基づいて行動することが保証されます。これはセキュリティ上望ましいことですが、ノードクラスタ全体で合意確認を行う必要があるため、時間的な問題があります。

"Readリクエスト"は、コンセンサスのオーバーヘッドなしに単一のHotPocketノード上のスマートコントラクトと通信する最適な方法です。これは、スマートコントラクトがユーザが要求したデータを提供するリクエスト/レスポンスサイクルを構成します。これを容易にするために、ユーザから読み取り要求を受け取ると、HotPocketは読み取り専用モードでスマートコントラクトを起動します。このモードでは、スマートコントラクトは最後に受信した状態に読み取り専用でアクセスし、「ユーザ入力」として受信したリクエストに従って、そこから必要な情報を抽出します。そして、抽出された情報を「ユーザ出力」の形でユーザーに送信できます。

HotPocketチュートリアル

準備

DockerおよびNodeJsが動作するLinux環境を用意しましょう。執筆時点においてM1 Mac環境では動作しません。

HotPocket開発キットのインストール

npm i hpdevkit -g

https://www.npmjs.com/package/hpdevkit

https://github.com/EvernodeXRPL/hp-devkit

【コントラクト】 チュートリアルリポジトリの作成

HotPocketスマートコントラクト用の作業ディレクトリを作成します。

hpdevkit gen nodejs blank-contract mycontract

【コントラクト】 コードの確認

作業ディレクトリ内のコントラクトファイルを確認してみましょう。

mycontract/src/mycontract.js
const HotPocket = require("hotpocket-nodejs-contract");

const mycontract = async (ctx) => {
  // Your smart contract logic.
  console.log("Blank contract");
};

const hpc = new HotPocket.Contract();
hpc.init(mycontract);

通常、HotPocketはアプリケーションを起動し、関連する情報をコントラクト関数のctx引数に渡します。これはコントラクトの実行に関する情報を含む'コントラクトコンテキスト'と呼ばれます。
このコードではコントラクト実行時に"Blank contract"をログ出力するのみで、ユーザの入力情報や出力情報、Stateの処理は行なっていません。

【コントラクト】 動かしてみる

では実際に動かしてみましょう。

cd mycontract
npm i
npm start

上のコマンドを実行すると次のような出力になります。

20220821 09:23:50.213 [inf][hpc] ****Ledger created**** (lcl:1-a80e9b9d state:aab8e909 patch:7365a671)
Blank contract
20220821 09:23:51.209 [inf][hpc] ****Ledger created**** (lcl:2-55e988d9 state:aab8e909 patch:7365a671)
Blank contract
20220821 09:23:52.209 [inf][hpc] ****Ledger created**** (lcl:3-835c39b2 state:aab8e909 patch:7365a671)
Blank contract

npm startコマンドではmycontract.jsファイルのビルドと、ビルド先のファイルをHotPocketへデプロイするhpdevkit deploy distコマンドを実行しています。

【コントラクト】 コードの変更

コントラクトのコンテキスト情報を取得してみましょう。
次のようにmycontract.jsを修正してください。

mycontract/src/mycontract.js
const HotPocket = require("hotpocket-nodejs-contract");

const mycontract = async (ctx) => {
  // Your smart contract logic.
  console.log("Ledger number", ctx.lclSeqNo);
  console.log("Connected users", ctx.users.count());
};

const hpc = new HotPocket.Contract();
hpc.init(mycontract);

同じようにnpm run startを実行するとlclSeqNousersのサイズを取得できます。

20220821 10:01:53.215 [inf][hpc] ****Ledger created**** (lcl:1-d526d397 state:8a2aee45 patch:7365a671)
Ledger number 1
Connected users 0
20220821 10:01:54.211 [inf][hpc] ****Ledger created**** (lcl:2-f39ab881 state:8a2aee45 patch:7365a671)
Ledger number 2
Connected users 0
20220821 10:01:55.212 [inf][hpc] ****Ledger created**** (lcl:3-6617ef82 state:8a2aee45 patch:7365a671)
Ledger number 3
Connected users 0

【クライアント】 コードの作成

ここまではコントラクトのみで完結していたコードであり、ユーザの入力に関する処理は行なっていませんでした。HotPocketコントラクトと通信するには、クライアントアプリケーションが必要となります。

hpdevkit gen nodejs blank-client myclientを実行し、クライアントアプリケーションを作成しましょう。(mycontractディレクトリとは別に作成することをお勧めします。)

【クライアント】 コードの確認

myclient/src/myclient.js
const HotPocket = require("hotpocket-js-client");

async function clientApp() {
  const userKeyPair = await HotPocket.generateKeys();
  const client = await HotPocket.createClient(
    ["wss://localhost:8081"],
    userKeyPair
  );

  // Establish HotPocket connection.
  if (!(await client.connect())) {
    console.log("Connection failed.");
    return;
  }

  console.log("HotPocket Connected.");
}

clientApp();

HotPocket.generateKeys()はユーザを識別するための公開鍵・秘密鍵を生成します。
HotPocket.createClient()ではDockerコンテナのHotPocketノードにユーザの公開鍵・秘密鍵を使って接続します。

client.connect()でHotPocketノードとの接続を確立し、その結果に応じてログを出力します。

このコードではHotPocketノードとの接続は行いますが、ユーザ入力情報の送信は行いません。

【コントラクト】 ユーザの接続を確認

現在動いているノードの出力ログを確認するためhpdevkit logs 1を実行しましょう。末尾の1はノードの番号であり正しくコンセンサスが取れている限り2,3でも同じ出力となるはずです。

ここでmyclient/src/myclient.jsを実行すると、hpdevkit logs 1は次のような出力となり、Connected users 1からユーザの接続が行われていることが確認できるでしょう。

20220821 10:55:04.069 [inf][hpc] ****Ledger created**** (lcl:1521-d4b742af state:8a2aee45 patch:77e05022)
Ledger number 1521
Connected users 1
20220821 10:55:05.070 [inf][hpc] ****Ledger created**** (lcl:1522-824619a0 state:8a2aee45 patch:77e05022)
Ledger number 1522
Connected users 1
20220821 10:55:06.070 [inf][hpc] ****Ledger created**** (lcl:1523-203c654b state:8a2aee45 patch:77e05022)
Ledger number 1523
Connected users 1

【コントラクト】 ユーザ情報の取得

コントラクトを次のように書き換えましょう。

my-contract/src/my-contract.js
const HotPocket = require("hotpocket-nodejs-contract");

const mycontract = async (ctx) => {
  // Your smart contract logic.
  for (const user of ctx.users.list()) {
    console.log("User public key", user.publicKey);
  }
};

const hpc = new HotPocket.Contract();
hpc.init(mycontract);

ctx.users.list()ではノードに接続中のユーザ一覧を取得できます。上のコードでは接続中のユーザの公開鍵情報を表示します。

mycontractディレクトリ内でnpm run start, myclientディレクトリ内でnode myclient.jsファイルを2プロセス実行すると、コントラクトの出力は以下のようになります。

20220823 16:09:34.873 [inf][hpc] ****Ledger created**** (lcl:25-c69f184b state:14ca33f4 patch:28c55e24)
User public key eddf24ddcdddac0e4a7087529e3420575707791b1d7d201ec4efff0edbba62c2b2
User public key ede63c896f04aef76df1d77a476ac511dc2b92da74557bbe1988846e84261ee71a
20220823 16:09:35.874 [inf][hpc] ****Ledger created**** (lcl:26-8623280c state:14ca33f4 patch:28c55e24)
User public key eddf24ddcdddac0e4a7087529e3420575707791b1d7d201ec4efff0edbba62c2b2
User public key ede63c896f04aef76df1d77a476ac511dc2b92da74557bbe1988846e84261ee71a

2回実行したmyclient.jsはそれぞれの処理で公開鍵・秘密鍵を生成しているため、別ユーザとして処理されています。

入力情報(user.inputs)

コントラクトはユーザの入力に応じた処理を行うことが多いでしょう。

入力に関するコントラクト側とクライアント側のコードについて説明します。

【コントラクト】 入力情報を処理する

mycontract.jsを次のように変更しましょう。

mycontract/src/mycontract.js
for (const user of ctx.users.list()) {
  console.log("User public key", user.publicKey);

  // Loop through inputs sent by the user.
  for (const input of user.inputs) {
    const buffer = await ctx.users.read(input);
    console.log("Received input:", buffer.toString());
  }
}

user.inputsからユーザの入力情報リストを取得でき、ctx.users.read(input)により入力情報をBuffer形式で取得できます。

【クライアント】 入力情報を送信する

myclient.jsを次のように変更しましょう。

myclient/src/myclient.js
// Establish HotPocket connection.
if (!(await client.connect())) {
  console.log("Connection failed.");
  return;
}

console.log("HotPocket Connected.");
console.log("Saying hello...");
await client.submitContractInput("hello");

client.submitContractInput()によりユーザの入力情報に送信され、クラスタ内の他のノードと共有されます。

その入力情報がコンセンサスを得ることができれば、スマートコントラクト内でユーザの入力情報として処理できるようになります。

入力情報の確認

コントラクトを再起動し、クライアントのコードを実行しましょう。

コントラクト側の出力では次のようにユーザの入力情報を得ることができます。

20220821 14:18:10.069 [inf][hpc] ****Ledger created**** (lcl:39-4ccab9bc state:81b360bc patch:77e05022)
User public key ed9a4cf5eba65fb12e8971dd8e4fec352601814214bb54c696dfd0a77bbdf4427e
Received input: hello

出力情報(user.outputs)

コントラクトは処理結果をユーザに出力できます。

出力に関するコントラクト側とクライアント側のコードについて説明します。

【コントラクト】 出力情報の送信

mycontract/src/mycontract.js
// Loop through inputs sent by the user.
for (const input of user.inputs) {
  const buffer = await ctx.users.read(input);

  const message = buffer.toString();
  console.log("Received input:", message);
  await user.send(`You said '${message}'`);
  await user.send(`Thanks for talking to me!`);
}

user.send()により、ユーザへ情報を送信できます。

【クライアント】 出力情報の処理

myclient/src/myclient.js
console.log("HotPocket Connected.");

// Register event handler to receive outputs before we start sending inputs.
client.on(HotPocket.events.contractOutput, (result) => {
  console.log("Received outputs:");
  result.outputs.forEach((o) => console.log(o));
});

console.log("Saying hello...");
await client.submitContractInput("hello");

contractOutputイベントをlistenすることで接続しているノードからユーザへ送信された出力情報を取得できます。

出力情報の確認

コントラクトを再起動し、クライアントのコードを実行しましょう。

クライアント側の出力では次のようにコントラクトの出力情報を得ることができます。

HotPocket Connected.
Saying hello...
Received outputs:
You said 'hello'
Thanks for talking to me!

参考資料

https://evernode.wordpress.com

https://github.com/EvernodeXRPL/hp-nodejs-contract

https://github.com/EvernodeXRPL/hp-js-client

おわりに

Evernodeにおけるコントラクト開発に必要な概念の説明とそのチュートリアルを紹介しました。

今回は説明しなかった「NPL (Node Party Line) メッセージング」、「データの永続化」や「Read Requestのチュートリアル」などは参考資料のリンクから学ぶことができます。

EvernodeのベースネットワークとなるXRPLに興味を持たれた方はXRPL開発者のDiscordチャンネルへ是非お越しください!
日本語チャンネルもありますので、英語ができなくても大丈夫です!

https://xrpldevs.org

私のTwitterはこちら!

Discussion