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 がソルトとして働き、同じ秘密鍵でも投稿ごとに違う公開鍵になる(リプレイ保護・名寄せ防止)

やさしい版・公式へ

つぎに読むページ

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