📘 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 をパラメータに持つ契約の型。ContractとWitnessesは、契約コードから 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)の状態(state・sequence・message)に、計算で求めるフィールド isOwner を合わせています。isOwner は、チェーン上の owner 公開鍵と、今のユーザーから導いた公開鍵を比べて決まります。
UI は、生の ledger 状態や秘密状態に直接さわる必要はありません。BBoardDerivedState をただ観測するだけです。
ユーティリティ関数(utils/index.ts)
小さいけれど、なくてはならない補助関数です。
これは、ユーザーが初めてけいじ板にさわるときに、新しい秘密鍵(secret key)を作るために使われます。Web Crypto API の getRandomValues は暗号的に安全なランダムバイトを返します。そして秘密鍵は、この契約上でのユーザーの身元(identity)の土台になります。
BBoardAPI クラス(index.ts)
ここが API層の中心です。ファイル全体はこうです。
なかなか大きいファイルです。セクションごとに見ていきましょう。
インターフェイス(interface)
この公開インターフェイスは、利用者(CLI でも、ブラウザ UI でも、テストでも)がデプロイ済みのけいじ板に対してできることを定義します。契約アドレスを読む / 状態の変化を観測する / メッセージを投稿する / 取り下げる。post も takeDown も Promise<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-contracts の deployContract を、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 できます。
けいじ板の典型的な流れはこうです。
- ユーザーA がボードをデプロイし、契約アドレスを共有する。
- ユーザーB がそのアドレスで join する。
- ユーザーA がメッセージを投稿する。ユーザーB は自分の
state$オブザーバブルでそのメッセージを見る。ユーザーB は取り下げできない(秘密鍵が違うから)。 - ユーザー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 名を取り出すBBoardCircuitKeys→MidnightProvidersからBBoardProviders→FoundContractのDeployedBBoardContract。 - constructor は private。
newは禁止で、静的なdeploy()/join()経由のみ。これで「つねに本物の契約をラップ」を保証。 state$は RxJS のcombineLatest:ledger は indexer から流れ続けるストリーム、秘密状態は変わらないのでfrom(Promise)で一度だけ取って使い回す。isOwnerはBBoard.pureCircuits.publicKey()をローカルで呼んでtoHex比較。- 秘密鍵=身元。初回は
utils.randomBytes(32)で生成し、privateStateProvider(LevelDB)がセッションをまたいで保持。だから自分のメッセージを後で取り下げられる。 - 現状の制限:秘密状態は契約アドレスごとに分かれず、全ボードで共有。将来 Midnight.js がアドレス単位の private state provider を提供予定。
やさしい版・公式へ
- やさしい版:けいじ板チュートリアル
- 公式:Academy Courses(外部リンク・別タブで開きます)(Phase 3 / Unit 3 / 3.2)
- 関連 docs:Midnight.js の API(midnight-js)(外部リンク・別タブで開きます) / チュートリアル:けいじ板を作る(外部リンク・別タブで開きます)
つぎに読むページ
➡️ 原文準拠コースの入口へ戻る。このコースについて(次のレッスンは順次追加します)