Skip to main content

原文準拠 Phase 3:DApp開発

4.1 Preprod へのデプロイ

Midnight Academy Phase 3 / Unit 4 / 4.1 の原文準拠版。掲示板(bulletin board)DApp をコンパイルから Preprod 上での投稿・取り下げまで、一連のライフサイクルを正確に・やさしく。


📘 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 を検知します。

PreprodRemoteConfiggenerateDust = true なので、CLI は自動で generateDust() を実行します。

この関数は、まだ登録されていない unshielded UTXO を探し、dust 生成の登録トランザクションを作って署名し、ネットワークに送信します。そして DUST がたまるのを待ちます。

DUST は手数料用の shielded トークンです(Lesson 27)。これが無いと、どんなトランザクションも送信できません。

Step 6:プロバイダの組み立て

ウォレットに資金が入り、DUST が使えるようになったら、run() は6つのプロバイダを組み立てます。

ここが、Lesson 24〜27 で出てきたすべてのインフラ部品がつながる瞬間です。

  • privateStateProvider … ユーザーの秘密鍵を、ローカルの暗号化された LevelDB データベースに保存する
  • publicDataProvider … オンチェーン状態の問い合わせ・観測のために indexer へつなぐ
  • zkConfigProvidercontract/src/managed/bboard からコンパイル済みの ZK 回路ファイルを読み込む
  • proofProvider … ローカルの Docker proof server へつなぐ
  • walletProvidermidnightProvider … トランザクションの balancing(手数料の調整)と送信を担う

Step 7:デプロイか参加か

CLI は deployOrJoin() に入ります。

デプロイする(選択肢 1)

deploy を選ぶと BBoardAPI.deploy() が動きます。中で起きることは次のとおりです。

  1. BBoardAPI.getPrivateState() が LevelDB に既存の秘密鍵があるか確認する。初回なら新しく生成する:createBBoardPrivateState(utils.randomBytes(32))
  2. @midnight-ntwrk/midnight-js-contractsdeployContract() が、契約の constructor をローカルで実行する。constructor は state = State.VACANTmessage = nonesequence = 1 をセットする。
  3. proof server が、その constructor 実行についての ZK 証明を生成する。
  4. ウォレットがトランザクションを balancing し、手数料用の DUST を加える。
  5. トランザクションが Preprod ノードへ送信され、ブロックに取り込まれる。

CLI は契約アドレスを出力します。

このアドレスが、他のユーザーが掲示板に参加するために必要なものです。保存しておきましょう。

参加する(選択肢 2)

すでに誰かが掲示板をデプロイしているなら、そのアドレスを貼りつけます。BBoardAPI.join()findDeployedContract() を呼び、indexer に契約の状態を問い合わせ、それをローカルに同期し、操作用のハンドルを返します。

Step 8:メインループ

deploy か join のあと、対話メニューが表示されます。

その裏では、bboardApi.state$ が購読されていて、オンチェーン状態が変わるたびに currentState を静かに更新し続けています。

Step 9:メッセージを投稿する

選択肢 1 を選び、メッセージを入力します。一連の流れはこうです。

  1. CLI → bboardApi.post(message) … API 層を呼ぶ
  2. API → this.deployedContract.callTx.post(message) … SDK のトランザクション処理を起動する
  3. SDK → post 回路をローカルで実行する
    • localSecretKey witness を呼ぶ → TypeScript が privateState.secretKey を返す
    • publicKey(secretKey, sequence) を走らせる → 32 バイトのハッシュを作る
    • 結果を disclose() で包む → オンチェーン保存対象として印をつける
    • messageowner を ledger 状態に書く
    • stateOCCUPIED に変える
  4. proof server … 回路が正しく実行されたという ZK 証明を、秘密鍵を明かさずに生成する
  5. ウォレットbalanceTx() がトランザクションに DUST 手数料を加える
  6. ノード … トランザクションが送信され、ブロックに取り込まれる
  7. indexer … 状態変化を検知し、WebSocket を通じて更新をプッシュする
  8. 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 = VACANTsequence が増え、message = none
  • proof server が証明を生成する
  • トランザクションが balancing され、送信される

取り下げ後、ledger 状態はこうなります。

sequence が 2 に増えました。owner フィールドはまだ古い公開鍵のまま(回路はそれをクリアしません)ですが、stateVACANT になっているので、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 がソルトとして働き、同じ秘密鍵でも投稿ごとに違う公開鍵になる(リプレイ保護・名寄せ防止)

やさしい版・公式へ

✍️ 白紙練習

答えを見る前に、いったん自分の頭と手から出してみよう。手で書くと「わかったつもり」がはがれます。

コード白紙

compact スクリプトが実行する compact compile のコマンドを書いてみよう(入力ファイルと出力先)。

コード白紙

run() が組み立てる providers のうち、privateStateProvider の定義を再現してみよう(levelPrivateStateProvider の中身)。

白紙練習

このレッスンの核心を自分の言葉で説明してみよう。掲示板 DApp のデプロイから投稿・取り下げまでの一連の流れで、各層がどう連携してプライバシーを守るのか?

クイズ

Preprod では何が無いとどんなトランザクションも送信できない? standalone モードではどうやってそれを省略する?

クイズ

post 回路をローカルで実行するとき、secretKey はどうやって回路に渡される? オンチェーンに出るのは何?

クイズ

User B が他人のメッセージを takeDown しようとすると何が起きる? proof や tx や DUST はどうなる?

クイズ

メッセージを取り下げて再投稿すると、同じ秘密鍵なのに公開鍵が変わるのはなぜ? これは何と呼ばれる?

つぎに読むページ

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