📘 Academy原文準拠 | Phase 3 · Unit 4 · Lesson 4.1 Bulletin Board DApp — Deployment to Preprod 内容に忠実な日本語版です。原文(英語)・図・動画は公式 Academy(外部リンク・別タブで開きます)を正本に。
これまでの3つのレッスンで、掲示板(bulletin board)契約(Lesson 3.1)、API 層(Lesson 3.2)、CLI のしくみ(Lesson 3.3)を学びました。
それぞれのレッスンは、ひとつの層だけを切りはなして見てきました。このレッスンではそれを全部つなげて、DApp の最初から最後まで(契約をコンパイルし、実際のネットワークでメッセージを投稿し、それを取り下げるまで)を通して歩きます。
これは掲示板の総まとめです。読み終わるころには、どの段階で何が起きるのか、どのコードがどこで走るのか、各層がどう連携してプライバシーを守るアプリになるのかが、はっきり分かるようになります。
前提(Prerequisites)
CLI を動かす前に、3つのものをインストールしておきます。
- Node.js(LTS、v24.11.1 以上)
- Docker(proof server 用)
- インターネット接続(Preprod ネットワークへのアクセス用)
Step 1:契約をコンパイルする
Compact コンパイラは bboard.compact を、TypeScript バインディングと ZK 回路ファイルに変換します。
compact スクリプトはこれを実行します。
これで managed/bboard/contract/ ディレクトリが生成され、その中に Contract クラス、Ledger 型、State enum、pureCircuits、そしてコンパイル済みの ZK 回路ファイルが入ります。コンパイラの出力は回路の複雑さを示します。
k=14 は、制約システム(constraint system)の中で回路が 2^14(16,384)行を使うことを意味します。rows の数は、実際に使われた制約の数です。どちらの回路も複雑さが近いのは、両方とも publicKey() を呼び、その中の persistentHash が計算の大半を占めているからです。
そのあと build スクリプトが TypeScript をコンパイルし、managed ディレクトリを dist/ にコピーします。
Step 2:CLI をビルドして起動する
preprod-remote スクリプトは src/launcher/preprod.ts を通して起動します。これは PreprodRemoteConfig を作り、run() を呼びます。ここから順番に何が起きるかを見ていきます。
Step 3:環境の起動
run() 関数は testEnv.start() を呼びます。Preprod の場合、これは単にリモートのエンドポイントへ接続するだけです。
Step 4:ウォレットの作成
CLI はこう尋ねます。
選択肢 1 を選ぶと、toHex(randomBytes(32)) を使ってランダムな 32 バイトのシード(seed)が生成されます。
このシードがすべての根っこです。MidnightWalletProvider.build() が FluentWalletBuilder を使って、そこから3つのサブウォレット(shielded・unshielded・dust)を導出します。
CLI はこう出力します。
このシードは保存しておきましょう。あとでこのウォレットを復元できる唯一の方法です(起動時の選択肢 2)。
Step 5:資金を入れて DUST を生成する
この時点でウォレットの残高はゼロです。次のことをします。
- unshielded アドレス(
mn_addr_preprod1...)をコピーする - Preprod の faucet(蛇口)を開く
- アドレスを貼りつけて tNight トークンをリクエストする
waitForUnshieldedFunds() 関数が待っています。faucet のトランザクションが確定すると(だいたい2分くらい)、ウォレットが同期して、届いた tNight を検知します。
PreprodRemoteConfig は generateDust = true なので、CLI は自動で generateDust() を実行します。
この関数は、まだ登録されていない unshielded UTXO を探し、dust 生成の登録トランザクションを作って署名し、ネットワークに送信します。そして DUST がたまるのを待ちます。
DUST は手数料用の shielded トークンです(Lesson 27)。これが無いと、どんなトランザクションも送信できません。
Step 6:プロバイダの組み立て
ウォレットに資金が入り、DUST が使えるようになったら、run() は6つのプロバイダを組み立てます。
ここが、Lesson 24〜27 で出てきたすべてのインフラ部品がつながる瞬間です。
privateStateProvider… ユーザーの秘密鍵を、ローカルの暗号化された LevelDB データベースに保存するpublicDataProvider… オンチェーン状態の問い合わせ・観測のために indexer へつなぐzkConfigProvider…contract/src/managed/bboardからコンパイル済みの ZK 回路ファイルを読み込むproofProvider… ローカルの Docker proof server へつなぐwalletProviderとmidnightProvider… トランザクションの balancing(手数料の調整)と送信を担う
Step 7:デプロイか参加か
CLI は deployOrJoin() に入ります。
デプロイする(選択肢 1)
deploy を選ぶと BBoardAPI.deploy() が動きます。中で起きることは次のとおりです。
BBoardAPI.getPrivateState()が LevelDB に既存の秘密鍵があるか確認する。初回なら新しく生成する:createBBoardPrivateState(utils.randomBytes(32))。@midnight-ntwrk/midnight-js-contractsのdeployContract()が、契約の constructor をローカルで実行する。constructor はstate = State.VACANT、message = none、sequence = 1をセットする。- proof server が、その constructor 実行についての ZK 証明を生成する。
- ウォレットがトランザクションを balancing し、手数料用の DUST を加える。
- トランザクションが Preprod ノードへ送信され、ブロックに取り込まれる。
CLI は契約アドレスを出力します。
このアドレスが、他のユーザーが掲示板に参加するために必要なものです。保存しておきましょう。
参加する(選択肢 2)
すでに誰かが掲示板をデプロイしているなら、そのアドレスを貼りつけます。BBoardAPI.join() は findDeployedContract() を呼び、indexer に契約の状態を問い合わせ、それをローカルに同期し、操作用のハンドルを返します。
Step 8:メインループ
deploy か join のあと、対話メニューが表示されます。
その裏では、bboardApi.state$ が購読されていて、オンチェーン状態が変わるたびに currentState を静かに更新し続けています。
Step 9:メッセージを投稿する
選択肢 1 を選び、メッセージを入力します。一連の流れはこうです。
- CLI →
bboardApi.post(message)… API 層を呼ぶ - API →
this.deployedContract.callTx.post(message)… SDK のトランザクション処理を起動する - SDK →
post回路をローカルで実行する:localSecretKeywitness を呼ぶ → TypeScript がprivateState.secretKeyを返すpublicKey(secretKey, sequence)を走らせる → 32 バイトのハッシュを作る- 結果を
disclose()で包む → オンチェーン保存対象として印をつける messageとownerを ledger 状態に書くstateをOCCUPIEDに変える
- proof server … 回路が正しく実行されたという ZK 証明を、秘密鍵を明かさずに生成する
- ウォレット …
balanceTx()がトランザクションに DUST 手数料を加える - ノード … トランザクションが送信され、ブロックに取り込まれる
- indexer … 状態変化を検知し、WebSocket を通じて更新をプッシュする
state$… この observable が、投稿されたメッセージとisOwner: trueを持つ新しいBBoardDerivedStateを発行する
CLI はステップ 6(トランザクションの確定)を待ってから、メニューに戻ります。全体で Preprod ではだいたい 30 秒くらいかかります。
Step 10:状態を表示する
3つの表示オプションは、同じデータを違う視点から見せます。
選択肢 3:ledger 状態(みんなが見えるもの)
owner は 32 バイトのハッシュ、つまり導出された公開鍵です。誰でも見られますが、誰もそれを逆算して秘密鍵を求めることはできません。
選択肢 4:private 状態(あなただけが知るもの)
これはローカルの暗号化された LevelDB に保存された、生の秘密鍵です。オンチェーンには決して現れません。
選択肢 5:derived 状態(組み合わせた視点)
derived 状態は BBoard.pureCircuits.publicKey() を使って、あなたの秘密鍵がオンチェーンの owner と一致するかを確認します。生の公開鍵を見せるかわりに、'you'(あなた)か 'not you'(あなたではない)を表示します。
Step 11:メッセージを取り下げる
選択肢 2 を選びます。流れは投稿と似ていますが、走るのは takeDown 回路です。
- 回路は
state == OCCUPIEDを assert する(通る) owner == publicKey(localSecretKey(), sequence)を assert する。あなたの秘密鍵が一致する公開鍵を作る(通る)- 掲示板がリセットされる:
state = VACANT、sequenceが増え、message = none - proof server が証明を生成する
- トランザクションが balancing され、送信される
取り下げ後、ledger 状態はこうなります。
sequence が 2 に増えました。owner フィールドはまだ古い公開鍵のまま(回路はそれをクリアしません)ですが、state が VACANT になっているので、post 側の assert が、それ以上の takeDown 呼び出しを防ぎます。
もう一度投稿すると、sequence が変わったので、同じ秘密鍵でも違う公開鍵が作られます。これが Lesson 3.1 で出てきたリプレイ保護(replay protection)です。
別のユーザーがあなたのメッセージを取り下げようとしたら
ここがプライバシー保証の核心です。User B が同じ契約に join して、選択肢 2(取り下げ)を試したとしましょう。
起きることは次のとおりです。
takeDown回路が User B のマシン上でローカルに走る- User B の witness は User B の秘密鍵を返す(あなたのとは違う)
publicKey(userB_secretKey, sequence)は違うハッシュを作るowner == publicKey(...)の assert が失敗する- トランザクションはローカルで拒否される。proof server にもネットワークにも届かない
User B はこんなエラーを見ます。
ZK 証明は生成されません。トランザクションも送信されません。DUST も消費されません。失敗は、ローカルの回路実行のあいだに、完全に User B のマシン上で起きます。
プライバシーモデルの実際
全体の流れを見ると、プライバシー保証が具体的になります。
- 秘密鍵はオンチェーンに決して現れない。 ユーザーのローカル LevelDB に住み、witness を通って回路に入り、ZK 証明の中でハッシュ計算にだけ使われます。
- 公開鍵はオンチェーンに現れるが、一方向のハッシュ。 そこから秘密鍵は導けません。それどころか、別々の掲示板にある2つの公開鍵が同じ人のものかどうかさえ分かりません(
sequenceのソルトが違うため)。 - メッセージは意図的に公開。 契約の設計者が
disclose()を選びました。これは制限ではなく設計上の判断です。掲示板は、投稿者の身元を隠したまま、メッセージを公に表示するためのものだからです。 - 証明は、witness データを明かさずに回路が正しく実行されたことを確認する。 ネットワークは、投稿者が保存された公開鍵に一致する秘密鍵を知っていたことを、その鍵が何かを知らずに検証できます。
これが選択的開示(selective disclosure)です。開発者が、何を公開し(メッセージと導出公開鍵)、何を秘密にするか(秘密鍵とユーザーの身元)を、ちょうど自分で選びます。
別の契約なら違う選択もできます。メッセージを秘密にする、投稿者の身元を明かす、あるいはその組み合わせも自由です。
standalone で動かす(ローカル Docker)
Preprod なしで開発やテストをするなら、完全にローカルな環境を動かせます。
これは StandaloneConfig を使い、compose.yml に定義された3つの Docker コンテナを起動します。
standalone モードでは、ウォレットは自動で genesis シード(000...001)を使います。これはジェネシスブロックでミントされたトークンにアクセスできます。faucet も DUST 生成も不要です。開発のイテレーションには、これが一番速い道です。
まとめ:あなたが作ったもの
Lesson 3.1〜3.3 を通して、完全な Midnight DApp のすべての層を解剖しました。
- 契約(Lesson 3.1):状態管理、witness 宣言、assert、
disclose()、そして身元のためのドメイン分離ハッシュを持つ Compact コード - API(Lesson 3.2):公開状態と private 状態を組み合わせる RxJS ベースの状態観測。
deploy()/join()/post()/takeDown()メソッドを持つ - CLI(Lesson 3.3):ウォレットの構築、プロバイダの配線、DUST 生成、対話メニュー
- エンドツーエンド(このレッスン):ユーザー入力からオンチェーン状態の変化までの、完全なトランザクションライフサイクル
ここで出てきたパターン(witness ベースの身元、シミュレータでのオフチェーンテスト、プロバイダの構築、state$ observable、deploy か join か)は、これから作るどんな Midnight DApp でも使う、同じパターンです。
開発者として押さえる点
- 流れは決まっている:契約コンパイル → CLI 起動 → 環境接続 → ウォレット作成 → 資金+DUST → プロバイダ組み立て → deploy/join → メインループ。各段階で「どのコードがどこで走るか」を意識する
- DUST が無いと何も送れない。Preprod では faucet で tNight を得て
generateDust()で DUST を作る。standalone は genesis シードでこれを丸ごと省略できる - 秘密鍵はローカルの暗号化 LevelDB に留まり、witness で回路に入るだけ。オンチェーンに出るのは一方向ハッシュの公開鍵だけ
- 権限チェックはローカルの回路実行で完結する。他人の
takeDownは assert 失敗でローカル拒否され、proof も tx も DUST も発生しない - 選択的開示が設計の中心:
disclose()で「何を公開し、何を秘密にするか」を開発者が明示的に選ぶ sequenceがソルトとして働き、同じ秘密鍵でも投稿ごとに違う公開鍵になる(リプレイ保護・名寄せ防止)
やさしい版・公式へ
- やさしい版:掲示板(Bulletin Board)チュートリアル
- 公式:Academy Courses(外部リンク・別タブで開きます)(Phase 3 / Unit 4 / 4.1)
- 関連ドキュメント:Midnight 開発ドキュメント(外部リンク・別タブで開きます) / Compact リファレンス(外部リンク・別タブで開きます)
つぎに読むページ
➡️ 原文準拠コースの入口へ戻る。このコースについて(次のレッスンは順次追加します)