📘 Academy原文準拠 | Phase 3 · Unit 3 · Lesson 3.1 Bulletin Board DApp — Understanding the Contract 内容に忠実な日本語版です。原文(英語)・図・動画は公式 Academy(外部リンク・別タブで開きます)を正本に。
これは、本物の Midnight サンプルから実際に DApp を組み立てる4レッスンの最初の1つです。けいじ板(bulletin board)のしくみはシンプルです。だれでもメッセージを貼れますが、それを取り下げられるのは貼った本人だけ。
Midnight で面白いのは、その「本人だけ」をウォレットの署名ではなく、ゼロ知識証明で証明するところです。
レッスン 3.3 を終えるころには、けいじ板の契約を Preprod にデプロイして、メッセージを貼ったり取り下げたりできる動く CLI アプリができあがります。このレッスンでは、まだデプロイはせず、契約そのものと、そのオフチェーン部品を理解してテストすることに集中します。Compact のコードを読むのが主役です。
つくるもの
けいじ板には3つの操作があります。
- Deploy(デプロイ):新しいカラのけいじ板を作る
- Post(投稿):メッセージを書く(けいじ板がカラのときだけ動く)
- Take down(取り下げ):メッセージを消す(最初に貼った本人だけができる)
巧妙なのは、「最初に貼った本人だけ」をどう保証するか、です。投稿者のウォレットアドレスはチェーン上にいっさい現れません。代わりに、投稿者は秘密鍵を witness 経由で渡し、契約はそこから一方向ハッシュで公開鍵を導出して、それを台帳に保存します。
メッセージを取り下げるときは、同じ秘密鍵をもう一度渡します。契約は公開鍵を再導出して、一致するか確かめます。一致すれば、あなたが投稿者です。一致しなければ、assert が失敗します。
チェーンを見ている第三者は、保存された公開鍵だけから「だれが貼ったか」を割り出せません。公開鍵が保存されていることは見えますし、ある秘密鍵がそれに対応するかどうかは検証できますが、ハッシュを逆向きにたどって鍵を求めることはできません。これが、はたらいているゼロ知識の所有権です。
プロジェクトの構成
公式サンプルは https://github.com/midnightntwrk/example-bboard(外部リンク・別タブで開きます) にあり、4つのパッケージからなるモノレポです。
このレッスンでは contract/ パッケージに集中します。残り3レッスンで、API レイヤー・CLI・デプロイを扱います。
Compact 契約
ひとつずつ歩いて見ていきましょう。
state 列挙型(enum)
enum に export を付けると、生成される TypeScript 型に現れます。だから DApp のコードから State.VACANT や State.OCCUPIED を直接参照できます。けいじ板は、つねにこの2つの状態のどちらかにいます。
ledger 宣言
4つのフィールド。すべて公開で、すべて export 付きです。
state:けいじ板がカラ(vacant)か使用中(occupied)か。ただの enum 値。message:貼られたメッセージ。Maybe(Compact 版の Option / Optional)で包まれています。カラのときはnone、使用中のときはsome("the message")です。Opaque<"string">型は、Compact コンパイラがそれを中身を見ない不透明なかたまりとして扱うことを意味します。文字列の中身を解析せず、そのまま通すだけ。TypeScript 側では、ふつうのstringに対応します。sequence:メッセージが取り下げられるたびに増えるCounter。これがセキュリティに決定的に重要です(下で説明)。owner:投稿者から導出された公開鍵。32 バイトを、生のバイト列として保存します。
constructor(コンストラクタ)
契約がデプロイされると、けいじ板はカラの状態で始まります。sequence カウンタは(0 ではなく)1 から始まります。Counter は 0 で始まりますが、すぐに increment(1) するからです。コンストラクタ中では witness は一度も呼ばれません。まだ投稿者がいないからです。
witness(ウィットネス)
witness は1つだけ。引数なし、32 バイトを返します。レッスン26で学んだとおり、これは実装のない宣言で、実際の関数は TypeScript の DApp が提供します。localSecretKey という名前は、この鍵がユーザーの手元の端末から決して外に出ないことを強調しています。
post circuit(投稿)
ここが面白くなるところです。データの流れを追いましょう。
assertでけいじ板がカラか確認します。すでにだれかが貼っていれば、ここで即・失敗します。localSecretKey()が witness を呼び、投稿者の秘密鍵(32 バイト、端末から外に出ない)を取り出します。sequence as Field as Bytes<32>は、いまのカウンタ値をバイト列に変換します。これは公開鍵を導出するときの「salt(ソルト)」として使われます。publicKey(sk, sequence)が、秘密鍵と sequence 値からpersistentHashを使って公開鍵を導出します。結果は 32 バイトのハッシュです。disclose(...)で結果を包みます。witness 由来のデータだからです。disclose()が無いと、コンパイラはそれを台帳に保存することを拒否します。message = disclose(some<Opaque<"string">>(newMessage))でメッセージを保存します。newMessageは witness ではなくcircuit の引数から来ますが、それでもdisclose()が必要です。引数そのものが witness の「汚染(taint)」を帯びているからです(ZK 回路に入るデータであり、別の場面ではプライベートになりうるため)。state = State.OCCUPIEDでけいじ板を使用中に切り替えます。State.OCCUPIEDは定数で、witness 由来ではないので、ここにdisclose()は要りません。
takeDown circuit(取り下げ)
2つの assert がこの circuit を守ります。
- けいじ板が使用中でなければならない(何も無いものは取り下げられません)。
- 呼び出し側は、保存された
ownerと同じ公開鍵を生む秘密鍵を渡さなければならない。これが所有権の証明です。あなたの秘密鍵が保存された公開鍵にハッシュ一致すれば、あなたが投稿者です。一致しなければassertが失敗し、トランザクションは却下されます。
検証のあと、circuit はけいじ板をクリアして、sequence カウンタを増やします。
これは地味ですが重要なセキュリティ対策です。もし sequence が変わらなければ、台帳で過去の公開鍵を見た悪意あるユーザーが、それを将来の投稿で再利用しようとできてしまいます。sequence を増やすことで、同じ秘密鍵でも次回は違う公開鍵になります(ハッシュの入力が変わるため)。これがリプレイ攻撃を防ぎます。
circuit は元のメッセージを Opaque<"string"> として返します。
この戻り値は呼び出し側の DApp コードからは使えますが、自動ではチェーン上に現れません。circuit の戻り値は、明示的に開示しないかぎり、トランザクションのプライベートな出力の一部です。
publicKey ヘルパー
これは純粋な circuitです(副作用なし、台帳への書き込みなし)。3つの値を一緒にハッシュします。ドメイン区切り子("bboard:pk:")・sequence 番号・秘密鍵です。ドメイン区切り子があることで、このハッシュが別の文字列を使う他の契約のハッシュと衝突しないことが保証されます。
export を付けると、この circuit は TypeScript から呼べるようになります。これが重要です。API レイヤーは pureCircuits.publicKey() を使い、トランザクションを送らずに手元で所有権をチェックできます。
TypeScript の witness 実装
レッスン26ですでに見ていますが、プロジェクト全体の文脈でおさらいする価値があります。
- プライベート状態(private state)は、ちょうど1つのフィールド、ユーザーの秘密鍵だけを持ちます。
- witness は
[privateState, privateState.secretKey]を返します。状態は変わらず(鍵は前後で同じ)、秘密鍵が circuit に渡されます。 createBBoardPrivateStateファクトリ関数は、生の秘密鍵から新しいプライベート状態を作ります。初回デプロイ時、DApp はランダムな 32 バイトの鍵を生成してこの関数を使います。再接続時には、プライベート状態は暗号化されたローカルのデータベースから読み込まれます。
契約の index ファイル
これは、レッスン24で見た CompiledContract.make().pipe() パターンで、すべてを配線するものです。コンパイラが生成した契約クラスを受け取り、witness 実装を結びつけ、コンパイル済みの ZK アセットを指し示します。
その結果が CompiledBBoardContractContract で、API レイヤーがデプロイと操作に使います。
この契約から学ぶ重要な概念
先へ進む前に、これらのパターンを身につけておきましょう。これから作るほとんどすべての Midnight DApp に登場します。
- witness 由来の所有権。投稿者は、ウォレットの署名ではなく秘密の知識で所有権を証明します。秘密鍵は witness 経由で circuit に入り、公開鍵にハッシュされ、チェーン上には公開鍵だけが現れます。
- リプレイ防止のための sequence 番号。カウンタが取り下げのたびに増えることで、同じ秘密鍵でもセッションをまたぐと違う公開鍵になります。これは「過去に見た公開鍵を再利用する」という地味な攻撃を防ぎます。
- 意図的な選択としての
disclose()。台帳に届く witness 由来データは、すべて明示的にdisclose()で包まれます。メッセージも、所有者の公開鍵も、開発者が「公開する」と選んだものです。秘密鍵は決して開示されないので、デフォルトでプライベートなままです。 - シミュレータによるオフチェーンのテスト。Compact ランタイムでは、witness や状態遷移を含む契約全体を、ブロックチェーンや証明インフラなしで手元で動かせます。
この先は
レッスン 3.3 では、この契約をデプロイと操作のために包む API レイヤーを作ります。Midnight SDK を通して、デプロイ・join・投稿・取り下げを扱う BBoardAPI クラスです。
動画で学ぶ(公式)
開発者として押さえる点
- 所有権は鍵そのものではなくハッシュ(公開鍵)で表す。
owner = publicKey(localSecretKey(), ...)で導出し、チェーンには公開鍵だけ。逆算で秘密鍵は出せない sequence(Counter)を取り下げのたびにincrement(1)。これがリプレイ攻撃対策の salt になる(同じ鍵でも毎回ちがう公開鍵)- witness 由来データを台帳へ書くなら必ず
disclose()。circuit 引数のnewMessageも taint を帯びるのでdisclose()が要る publicKeyはexport pure的な純粋 circuit。exportするから TS からpureCircuits.publicKey()で送信せず手元検証できるMaybe<Opaque<"string">>はnone/some(...)で扱い、Opaque<"string">は TS ではstring。persistentHashにはpad(32, "bboard:pk:")のドメイン区切り子を混ぜて衝突を防ぐ- 契約は
CompiledContract.make().pipe()で witness とアセットを配線する。デプロイ前でもシミュレータで全体をローカルテストできる
やさしい版・公式へ
- やさしい版:けいじ板チュートリアル
- 公式:Academy Courses(外部リンク・別タブで開きます)(Phase 3 / Unit 3 / 3.1)
- 公式サンプル:example-bboard リポジトリ(外部リンク・別タブで開きます)
- 関連 docs:Compact リファレンス(外部リンク・別タブで開きます) / Midnight 開発者ドキュメント(外部リンク・別タブで開きます)
つぎに読むページ
➡️ 原文準拠コースの入口へ戻る。このコースについて(次のレッスンは順次追加します)