Skip to main content

原文準拠 Phase 3:DApp開発

3.2 けいじ板DApp — API層

Midnight Academy Phase 3 / Unit 3 / 3.2 の原文準拠版。けいじ板DApp の api/ パッケージ(型定義・randomBytes・BBoardAPI クラス)を本物のコードで正確に。deploy・join・post・takeDown・state$ の流れを。


📘 Academy原文準拠 | Phase 3 · Unit 3 · Lesson 3.2 Bulletin Board DApp — The API Layer 内容に忠実な日本語版です。原文(英語)・図・動画は公式 Academy(外部リンク・別タブで開きます)を正本に。

レッスン3.1では、けいじ板(bulletin board)の契約・Compact のコード・witness を学びました。あれらはすべて、ユーザーの端末の中(ローカル)で動きます。でも契約を実際に Midnight ネットワークへデプロイして、そこでやりとりするには、契約とユーザーインターフェイス(UI)のあいだをつなぐ層が必要です。それが api/ パッケージの役目です。

API層は4つの仕事をします。新しい契約のデプロイ既にある契約への参加(join)circuit の呼び出し(post と takeDown)、そして公開状態(public)と秘密状態(private)を1本の観測可能なストリームにまとめて UI に渡すことです。

このレッスンでは、api/ パッケージのすべてのファイルを、example-bboard リポジトリにある姿そのままで一つずつ見ていきます。

アーキテクチャの中で API はどこにいるか

レッスン2.1で、プロバイダ基盤(provider infrastructure)— proof server、indexer、node、そして private state provider — を学びましたね。API層は、それらのプロバイダがすべて使われる場所です。

contract パッケージは Compact のコードと witness を定義します。api パッケージはそれを受け取り、プロバイダを通じてネットワークへつなぎ、post(message)takeDown() のようなきれいなメソッドとして外に出します。

api パッケージには3つのファイルがあります。

型定義(common-types.ts)

このファイルは、契約と Midnight SDK をつなぐ型システムを定義します。ファイル全体はこうです。

中身が多いので、ひとつずつ分解しましょう。

秘密状態のキー(private state key)

private state provider は、秘密状態を文字列の識別子をキーにして保管します。その識別子が定数 bboardPrivateStateKey(中身は 'bboardPrivateState')です。SDK が秘密状態を読み書きするとき、このキーを使って暗号化されたローカルのデータベースから探し出します。

PrivateStates スキーマ

これは型レベルのスキーマで、秘密状態のキーを、その型に対応づけます。もし DApp が複数の契約を、それぞれ違う秘密状態とともに使うなら、ここにエントリを増やします。けいじ板は契約タイプが1つだけなので、エントリも1つです。SDK はこのスキーマを使って、秘密状態へアクセスするときの型安全を保証します。

契約とプロバイダの型

この4つの型は、おたがいの上に積み重なっています。

  • BBoardContract — 秘密状態と witness をパラメータに持つ契約の型ContractWitnesses は、契約コードから Compact コンパイラが生成します。
  • BBoardCircuitKeys — impure circuit の名前('post''takeDown')を、文字列のユニオン型として取り出します。Exclude<..., number | symbol> が、文字列でないキーを取り除いています。
  • BBoardProviders — SDK が必要とするプロバイダ一式。public data provider(indexer)、proof provider(proof server)、ZK config provider、wallet provider、そして private state provider です。MidnightProviders は、circuit のキー・秘密状態の ID・秘密状態の型をパラメータに取ります。
  • DeployedBBoardContract — ネットワークへデプロイ済み、あるいは見つかった契約を表す FoundContract。circuit を呼ぶときに使うハンドル(取っ手)です。

派生状態(derived state)

これは UI が受け取る、まとめた1つのビューです。公開台帳(ledger)の状態(statesequencemessage)に、計算で求めるフィールド isOwner を合わせています。isOwner は、チェーン上の owner 公開鍵と、今のユーザーから導いた公開鍵を比べて決まります。

UI は、生の ledger 状態や秘密状態に直接さわる必要はありませんBBoardDerivedState をただ観測するだけです。

ユーティリティ関数(utils/index.ts)

小さいけれど、なくてはならない補助関数です。

これは、ユーザーが初めてけいじ板にさわるときに、新しい秘密鍵(secret key)を作るために使われます。Web Crypto API の getRandomValues暗号的に安全なランダムバイトを返します。そして秘密鍵は、この契約上でのユーザーの身元(identity)の土台になります。

BBoardAPI クラス(index.ts)

ここが API層の中心です。ファイル全体はこうです。

なかなか大きいファイルです。セクションごとに見ていきましょう。

インターフェイス(interface)

この公開インターフェイスは、利用者(CLI でも、ブラウザ UI でも、テストでも)がデプロイ済みのけいじ板に対してできることを定義します。契約アドレスを読む / 状態の変化を観測する / メッセージを投稿する / 取り下げるposttakeDownPromise<void> を返し、取引がチェーン上で確定(finalize)するまで待ってから解決します。

private constructor と state$ オブザーバブル

constructor は private です。つまり new BBoardAPI(...) を直接は呼べません。代わりに、静的メソッドの deploy()join() を使います。このやり方によって、BBoardAPI のインスタンスはつねに、完全にデプロイ済み(または発見済み)の契約をラップしていることが保証されます。

constructor のいちばん複雑な部分が state$ オブザーバブルです。RxJS のパイプラインを追っていきましょう。

combineLatest は2つのストリームを受け取り、どちらかが新しい値を出すたびに新しい値を出します。

  • 第1のストリームは公開台帳(ledger)の状態です。誰かが投稿・取り下げをして契約の状態がチェーン上で変わるたびに、indexer が新しい値を押し出してきます。map 演算子が、生の契約状態を、コンパイラ生成の BBoard.ledger() 関数で型付きの Ledger オブジェクトに変換します。
  • 第2のストリームは秘密状態です。コードのコメントが、大事な設計判断を説明しています。けいじ板の秘密状態は変わらない(秘密鍵は固定)ので、from(Promise)一度だけ問い合わせ、その1つの値を combineLatest がどの emission でも使い回します。

秘密状態が変わるような、もっと複雑な DApp なら、ここは本物のオブザーバブルなストリームにします。

combiner(合成)関数は isOwner を計算します。ユーザーの秘密鍵と今の sequence から公開鍵を導き、それをチェーン上の owner フィールドと比べます。これは契約にあったのと同じ publicKey pure circuit を、BBoard.pureCircuits.publicKey() でローカルに呼んでいるものです。

convertFieldToBytes の呼び出しは、sequence カウンタを circuit が期待する Bytes<32> の形に変換します。そして toHex が、両方のバイト配列を16進数の文字列にして比較できるようにします。

circuit を呼ぶメソッド

どちらのメソッドも同じパターンです。this.deployedContract.callTx.<circuitName>(args) を呼び、結果を await します。

裏では、callTx がレッスン25で学んだ取引フロー全体を引き起こします。circuit が witness とともにローカルで実行され、proof server が ZK 証明を生成し、wallet が取引を DUST で balance(バランス調整)し、node がそれをネットワークへ提出します。await は、取引が確定した(ブロックに含まれた)ときに解決します。

返ってくる txData には、取引のハッシュとブロック高が txData.public に入っています。post メソッドはメッセージ文字列を circuit の引数として渡します。

takeDown メソッドは引数を取りません。秘密鍵は呼び出し側からではなく、witness から来るからです。

静的メソッド deploy と join

deploy()@midnight-ntwrk/midnight-js-contractsdeployContract を、3つのものとともに呼びます。コンパイル済みの契約(レッスン28の index.ts から)、秘密状態のキー、そして初期の秘密状態です。

deployContract 関数が、constructor の実行・証明の生成・チェーン上への提出をまとめて面倒みてくれます。

デプロイされると DeployedBBoardContract が返り、それが新しい BBoardAPI インスタンスにラップされます。

join() は、findDeployedContract を使ってネットワーク上の既存の契約を見つけます。契約アドレス(デプロイ時に分かるか、誰かが共有してくれる)を渡すと、SDK が indexer を通じてそれを探し出し、現在の状態を同期し、やりとりに使えるハンドルを返します。

これが、ほかの誰かがデプロイしたけいじ板に、2人目のユーザーがつなぐやり方です。

秘密状態の初期化

このメソッドは、ローカルの暗号化データベースに秘密状態がすでにあるかを確認します。あれば(過去にけいじ板にさわったことがある)、既存の状態 — つまり同じ秘密鍵 — を返します。なければ(初回のユーザー)、createBBoardPrivateState(utils.randomBytes(32))新しいランダムな32バイトの鍵を作ります。

これは身元の永続性(identity persistence)のために重要です。あなたの秘密鍵は、けいじ板上でのあなたの身元です。もし毎回新しい鍵を作っていたら、自分が出したメッセージを自分で取り下げられなくなりますprivateStateProvider(レッスン26で扱った LevelDB が裏にいる)が、あなたの鍵をセッションをまたいで生き残らせます

クラスの JSDoc コメントは、いまの制限を記しています。秘密状態は、すべてのけいじ板インスタンスで共有されていて、契約アドレスごとには分けられていないということです。つまり、あなたがさわるどのボードでも秘密鍵は同じになります。

deploy と join のパターン

deploy と join の違いは、Midnight の DApp にとって根本的です。

  • Deploy は、ネットワーク上に新しい契約インスタンスを作ります。デプロイした人はデプロイ取引のために DUST を払い、契約はユニークなアドレスをもらいます。デプロイするのは1者だけです。
  • Join は、既存の契約(既知のアドレス)につなぎます。join する人は何も払いません — indexer から現在の状態を同期するだけです。複数のユーザーが同じ契約に join できます

けいじ板の典型的な流れはこうです。

  1. ユーザーA がボードをデプロイし、契約アドレスを共有する。
  2. ユーザーB がそのアドレスで join する。
  3. ユーザーA がメッセージを投稿する。ユーザーB は自分の state$ オブザーバブルでそのメッセージを見る。ユーザーB は取り下げできない(秘密鍵が違うから)。
  4. ユーザーA がそれを取り下げる。ユーザーB はボードが vacant(空き)になるのを見る。

deploy も join も、同じ能力を持つ同じ BBoardAPI インスタンスを生み出します。違うのは、最初のつなぎ方だけです。

フルスタックとのつながり

API層は、オンチェーンの契約オフチェーンのユーザー体験をつなぐ橋です。

Midnight 固有の概念(プロバイダ・circuit 呼び出し・ZK 証明)と、アプリレベルの概念(メッセージを投稿する・自分が owner かどうか確認する)のあいだを翻訳します。

次のレッスンでは、CLI(bboard-cli/)がプロバイダを組み立て、BBoardAPI インスタンスを作り、ユーザーに対話メニューを出すところを見ます。

API層のきれいなインターフェイス — deploy・join・post・takeDown・state$ — のおかげで、CLI は ZK 証明・witness・取引のバランス調整について何も知らなくてよいのです。ただメソッドを呼び、状態を観測するだけです。

開発者として押さえる点

  • API層は4つの仕事:deploy(新規)・join(既存に参加)・circuit 呼び出し(post/takeDown)・公開+秘密状態を1本の state$ ストリームにまとめる。
  • 型は積み重ねContract/Witnesses はコンパイラ生成 → BBoardContract → circuit 名を取り出す BBoardCircuitKeysMidnightProviders から BBoardProvidersFoundContractDeployedBBoardContract
  • constructor は privatenew は禁止で、静的な deploy()/join() 経由のみ。これで「つねに本物の契約をラップ」を保証。
  • state$ は RxJS の combineLatest:ledger は indexer から流れ続けるストリーム、秘密状態は変わらないので from(Promise) で一度だけ取って使い回す。isOwnerBBoard.pureCircuits.publicKey() をローカルで呼んで toHex 比較。
  • 秘密鍵=身元。初回は utils.randomBytes(32) で生成し、privateStateProvider(LevelDB)がセッションをまたいで保持。だから自分のメッセージを後で取り下げられる。
  • 現状の制限:秘密状態は契約アドレスごとに分かれず、全ボードで共有。将来 Midnight.js がアドレス単位の private state provider を提供予定。

やさしい版・公式へ

つぎに読むページ

➡️ 原文準拠コースの入口へ戻る。このコースについて(次のレッスンは順次追加します)