compact について
Compact Language Skillは、Midnightのスマートコントラクト言語Compactを使う開発者向けのリファレンスと実践ガイドです。回路、公開台帳とプライベートなwitness、型、安全設計、コンパイルを解説します。
主な機能
- 01回路とwitnessのモデル解説
- 02台帳状態とdisclose()の説明
- 03データ型と標準ライブラリの紹介
- 04プライバシーと安全設計のパターン
- 05コントラクトのコンパイルと一般的なエラー
活用シーン
- 01Compactコントラクトの公開・非公開データの設計
- 02コミットメントやMerkle木によるプライバシー設計
- 03回路、witness、型の使い方の確認
- 04コントラクトのコンパイルと一般的なエラーの理解
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill compactインストール後、Claude Code で/compactを入力して読み込まれたか確認します。
詳細
Compact 言語スキル
Compact は Midnight のスマートコントラクト言語です。TypeScript のような構文ですが、ゼロ知識回路にコンパイルされます。暗号処理はすべてコンパイラーが行うため、ビジネスロジックを記述するだけで済みます。
主な参考資料:
compact-book.vercel.app— 構造化された、例を中心とするリファレンス(最も役立つ情報源です)docs.midnight.network/compact— 公式仕様(2026 年時点で、テストとセキュリティのページは「近日公開」です)
1) 基本的な考え方
コードを書く前に押さえておくべき、譲れない 3 つの原則:
1. A circuit is a constraint system, not a function.
It declares relationships that MUST hold. The proof proves they held.
2. The ledger is public. Everything in `export ledger` is plaintext on-chain.
Private data lives in witnesses. It never touches the chain.
3. `disclose()` is a compile-time annotation, not encryption.
It tells the compiler "I am intentionally making this public."
The compiler prevents accidental disclosure. No runtime cost.
2 つの世界:
| 世界 | 場所 | 誰が見られるか | 更新するもの |
|---|---|---|---|
export ledger | すべてのネットワークノード | 全員 | ZK 証明 |
witness(プライベート) | ユーザーのローカルストレージ | 所有者のみ | なし(ローカルに保持) |
2) コントラクトの構造
Compact コントラクトには、必須の要素が 4 つと、任意の要素が 1 つあります。
pragma language_version >= 0.22; // 1. Language version
import CompactStandardLibrary; // optional, needed for hashing, Merkle, etc.
export ledger counter: Uint<64>; // 2. Public state (on-chain, readable by all)
witness callerSecret(): Bytes<32>; // 3. Private input (body lives in TypeScript)
constructor() { // 5. OPTIONAL: runs once on deployment
counter = 0;
}
export circuit increment(): [] { // 4. Logic (compiles to ZK circuit)
counter = disclose((counter + 1) as Uint<64>);
}
| 要素 | キーワード | 用途 |
|---|---|---|
| 公開状態 | export ledger | オンチェーンで、誰でも読み取り可能 |
| プライベート入力 | witness | DApp が提供するコールバック(本体は TypeScript で記述) |
| ロジック | export circuit | ZK 回路にコンパイルされる |
| 初期化 | constructor | デプロイ時に 1 回実行される |
| 整理 | module / import | 名前空間とファイルの管理 |
3) データ型
プリミティブ型
| 型 | 範囲/サイズ | 備考 |
|---|---|---|
Boolean | true / false | truthy/falsy の型変換はありません |
Field | 0 から素数体の位数まで | == と != のみ使用可能です。<、> は使用できません |
Uint<n> | n ビット符号なし整数。オーバーフロー時は折り返します | Uint<8> = 0..255 |
Uint<0..n> | 0 から n-1 までの範囲付き整数 | <、> をサポートします。キャストは実行時に失敗することがあります |
Bytes<n> | 正確に n バイト | 文字列を固定長にするには pad(n, str) を使用します |
Opaque<"string"> | 外部の JS データ | 回路から見えるのはハッシュのみで、内容は見えません。オンチェーンでは秘匿されません |
重要: Field は等価性チェックにのみ使用してください。順序比較(<、<=、>、>=)が必要な場合は、必ず Uint<0..n> を使用してください。
複合型
// Tuple
const pair: [Field, Boolean] = [42, true];
const first = pair[0];
// Vector (homogeneous, fixed-length)
const v: Vector<4, Uint<8>> = [1, 2, 3, 4];
// Struct (nominal typing — shape isn't enough, name matters)
struct Point { x: Uint<32>, y: Uint<32> }
const p = Point { x: 10, y: 20 };
// Enum (first variant = default)
enum State { VACANT, OCCUPIED }
型エイリアス
type Hash = Bytes<32>; // structural alias — fully interchangeable
new type UserId = Bytes<32>; // nominal alias — requires explicit cast
デフォルト値
すべての型にはゼロ値のデフォルトがあります。false、0、ゼロバイト n 個、列挙型の最初のバリアントです。default<T>() で取得できます。
4) Ledger の状態(公開)と disclose()
Ledger の修飾子
ledger val: Field; // private to contract, not exported
export ledger cnt: Counter; // readable from TypeScript/DApp
sealed ledger config: Uint<32>; // write-once (only in constructor)
export sealed ledger mapping: Map<Boolean, Field>;
disclose() のルール
データが witness から ledger への書き込み(または回路の戻り値)に流れる場合、disclose() でラップする必要があります。コンパイラーはすべての操作を通じて witness の汚染を追跡し、汚染されていない値の開示が ledger に到達するとエラーを出します。
// ❌ COMPILER ERROR — undisclosed witness data reaching ledger
export circuit record(): [] {
stored = getSecret();
}
// ✅ CORRECT — explicitly disclosed
export circuit record(): [] {
stored = disclose(getSecret());
}
disclose() は暗号化ではありません。コンパイル時のシグナルです。開示された値はオンチェーンで平文になります。
Ledger ADT の選び方
| 用途 | 型 |
|---|---|
| 単一の変更可能な値 | ledger f: T(Cell) |
| 単調増加するカウンター | Counter(.increment(n) を使用) |
| メンバーシップの追跡(どの要素かを明らかにする) | Set<T> |
| キーごとのストレージ | Map<K, V> |
| 順序付きキュー | List<T> |
| 匿名メンバーシップ証明(現在のルート) | MerkleTree<n, T> |
| 匿名メンバーシップ証明(過去のルート) | HistoricMerkleTree<n, T> |
| ブロック時刻、トークン、コントラクトアドレス | Kernel |
5) 回路と Witness
回路
// Exported = callable from DApp
export circuit post(msg: Opaque<"string">): [] {
message = disclose(msg);
}
// Pure = no ledger access, no side effects
pure circuit publicKey(sk: Bytes<32>, seq: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "domain:"), seq, sk]);
}
// Internal (no export keyword) — not callable from DApp
circuit hashTokenData(owner: Bytes<32>, meta: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([owner, meta]);
}
- 戻り値の型に
[]を指定すると、戻り値のないプロシージャ形式になります assert(condition, "message")= 実行時ガード。condition が false の場合、トランザクションは失敗します- witness の出力は信頼できないため、必ず
assertで検証してください
Witness
witness はプライベート入力のコールバックを宣言します。シグネチャは Compact で、本体は TypeScript で記述します。
// Compact side — declaration only
witness callerAddress(): Bytes<32>;
witness findMerklePath(leaf: Bytes<32>): MerkleTreePath<10, Bytes<32>>;
// TypeScript side — implementation
function callerAddress(context: WitnessContext): Uint8Array {
return context.coinPublicKey; // or wallet-derived address
}
function findMerklePath(
context: WitnessContext,
leaf: Uint8Array,
): MerkleTreePath<Uint8Array> {
return context.ledger.items.findPathForLeaf(leaf)!;
}
Witness のデータがチェーンに送られることはありません。オンチェーンに送られるのは、回路が正しく実行されたことを示す ZK 証明だけです。
6) 標準ライブラリ — 主な関数
import CompactStandardLibrary;
ハッシュ化
| 関数 | 出力 | アップグレード後も保持される? | witness による汚染? | 用途 |
|---|---|---|---|---|
transientHash<T>(v) | Field | いいえ | はい | 一時的な計算 |
transientCommit<T>(v, rand) | Field | いいえ | いいえ | disclose() を使わない一時的な計算 |
persistentHash<T>(v) | Bytes<32> | はい | はい | オンチェーンのキー、ID |
persistentCommit<T>(v, rand) | Bytes<32> | はい | いいえ | オンチェーンで機密値を隠す |
主な違い:
*Hash= 値のみのハッシュです。値の候補が少ない場合、総当たりで特定できます。*Commit= 値とランダムな nonce のハッシュです。値の候補が少なくても、総当たりで特定するのは現実的ではありません。- Persistent = コントラクトのアップグレード後も保持されます。台帳への保存に使用してください。
- Transient = 一時的なものです。アップグレード後は保持されません。
パディングと変換
pad(32, "midnight:domain:separator") // Creates Bytes<32> from string
Merkle ツリー
// Check that a path validates to the current root
assert(
items.checkRoot(merkleTreePathRoot<10, Bytes<32>>(path)),
"invalid merkle path"
);
// TypeScript: get the path
const path = context.ledger.items.findPathForLeaf(leaf); // O(n) scan
const path = context.ledger.items.pathForLeaf(index); // O(1) with index
Maybe / Option
export ledger message: Maybe<Opaque<"string">>;
message = some<Opaque<"string">>(newMessage); // has value
message = none<Opaque<"string">>(); // empty
const val = message.value; // access inner value (only when present)
7) セキュリティパターン
公式のセキュリティドキュメントは「近日公開」です。compact-book の以下のパターンを使用してください。
公開される情報(すべて公開されると想定してください)
| 操作 | 明らかになる情報 |
|---|---|
ledger.insert(v) | 値 v |
ledger.lookup(k) | キー k と返される値 |
set.member(f(x)) | x ではなく f(x) |
merkleTree.insert(v) | v は明らかになりません |
| 回路の引数 | すべて |
witness の戻り値 | 何もありません(ローカルにとどまります) |
パターン 1:コミットメント(値ではなくハッシュを保存)
export ledger balanceCommitments: Map<Bytes<32>, Bytes<32>>;
export circuit commitBalance(value: Uint<64>): [] {
const nonce = freshNonce();
const commitment = persistentCommit<Uint<64>>(value, nonce);
balanceCommitments.insert(disclose(callerAddress()), disclose(commitment));
}
nonce の再利用はプライバシーに壊滅的な影響を及ぼします。 同じ nonce と値を使った 2 つのコミットメントは、オンチェーンで同一になります。必ず新しい nonce を使用してください。
パターン 2:ハッシュベースの認証(ハッシュ化による ZK 署名)
witness secretKey(): Bytes<32>;
export ledger organizer: Bytes<32>;
constructor() {
organizer = disclose(publicKey(secretKey()));
}
export circuit adminAction(): [] {
assert(organizer == publicKey(secretKey()), "not authorized");
// ... do the thing
}
pure circuit publicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>(
[pad(32, "myapp:admin:v1"), sk]
);
}
ドメインセパレーターは非常に重要です。 同じ秘密鍵でも、用途が異なれば異なる公開鍵になります。異なるドメイン間で公開鍵を再利用しないでください。
パターン 3:匿名メンバーシップのための Merkle ツリー
Set<T> はどのコミットメントがチェックされたかを明らかにします。MerkleTree<n, T> では、証明された値が 何らかの値 であることだけが明らかになります。
export ledger allowlist: MerkleTree<16, Bytes<32>>;
witness findPath(leaf: Bytes<32>): MerkleTreePath<16, Bytes<32>>;
export circuit addToAllowlist(commitment: Bytes<32>): [] {
allowlist.insert(disclose(commitment)); // insert still requires disclose
}
export circuit proveAllowlisted(): [] {
const sk = secretKey();
const leaf = publicKey(sk);
const path = findPath(leaf);
assert(
allowlist.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
"not in allowlist"
);
// proof: caller is in the allowlist. Nothing else revealed.
}
深さの目安:レベルごとに回路の深さが約 1 増えます。一般的な深さは 16~20 です。必要最小限の深さを使用してください。
パターン 4:コミットメント/ヌリファイア(1 回限りの匿名トークン)
export ledger commitments: HistoricMerkleTree<16, Bytes<32>>;
export ledger nullifiers: Set<Bytes<32>>;
export circuit spend(): [] {
const sk = secretKey();
const commitment = makeCommitment(sk);
const path = findCommitmentPath(commitment);
// 1. Prove membership
assert(
commitments.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
"not in commitments tree"
);
// 2. Prevent reuse
const nul = makeNullifier(sk);
assert(!nullifiers.member(nul), "already spent");
nullifiers.insert(disclose(nul));
}
// CRITICAL: commitment and nullifier MUST use different domain separators
pure circuit makeCommitment(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:commit:v1"), sk]);
}
pure circuit makeNullifier(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:nullifier:v1"), sk]);
}
証明の検証に過去のルートが必要な場合(つまり、証明の計算後に項目が追加される可能性がある場合)に限り、HistoricMerkleTree を使用してください。それ以外の場合は、通常の MerkleTree を使用してください。
8) コントラクトの完全な例
最小限の掲示板(プライバシーなし)
pragma language_version >= 0.22;
export ledger message: Opaque<"string">;
export circuit post(msg: Opaque<"string">): [] {
message = disclose(msg);
}
所有権付きの掲示板(プライバシーパターン)
pragma language_version >= 0.22;
import CompactStandardLibrary;
export enum State { VACANT, OCCUPIED }
export ledger state: State;
export ledger message: Maybe<Opaque<"string">>;
export ledger sequence: Counter;
export ledger owner: Bytes<32>;
witness localSecretKey(): Bytes<32>;
constructor() {
state = State.VACANT;
message = none<Opaque<"string">>();
sequence.increment(1);
}
export circuit post(newMessage: Opaque<"string">): [] {
assert(state == State.VACANT, "Board occupied");
owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>));
message = disclose(some<Opaque<"string">>(newMessage));
state = State.OCCUPIED;
}
export circuit takeDown(): Opaque<"string"> {
assert(state == State.OCCUPIED, "Board empty");
assert(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>), "Not owner");
const msg = message.value;
state = State.VACANT;
sequence.increment(1);
message = none<Opaque<"string">>();
return msg;
}
pure circuit publicKey(sk: Bytes<32>, seq: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), seq, sk]);
}
なぜシーケンスを使うのですか? リプレイ攻撃を防ぎます。同じ秘密鍵でも、ラウンドごとに異なる公開鍵が生成されます。
NFT コントラクト(コミットメントベースの所有権)
pragma language_version >= 0.22;
import CompactStandardLibrary;
export ledger totalSupply: Uint<64>;
export ledger nextTokenId: Uint<64>;
export ledger tokenCommitments: Map<Uint<64>, Bytes<32>>;
witness callerAddress(): Bytes<32>;
constructor() {
totalSupply = 0;
nextTokenId = 1;
}
circuit hashTokenData(owner: Bytes<32>, metaHash: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([owner, metaHash]);
}
export circuit mint(metaHash: Bytes<32>): [] {
const caller = disclose(callerAddress());
const tokenId = nextTokenId;
const commitment = hashTokenData(caller, metaHash);
tokenCommitments.insert(tokenId, disclose(commitment));
totalSupply = disclose((totalSupply + 1) as Uint<64>);
nextTokenId = disclose((tokenId + 1) as Uint<64>);
}
export circuit transfer(tokenId: Uint<64>, newOwner: Bytes<32>, metaHash: Bytes<32>): [] {
const caller = disclose(callerAddress());
const expected = hashTokenData(caller, metaHash);
const pubId = disclose(tokenId);
assert(tokenCommitments.member(pubId), "Token does not exist");
assert(tokenCommitments.lookup(pubId) == expected, "Not the owner");
const nextCommitment = hashTokenData(disclose(newOwner), metaHash);
tokenCommitments.insert(pubId, disclose(nextCommitment));
}
9) コンパイルとツール
# Compile a contract
compact compile contracts/your-contract.compact contracts/managed/your-contract
# Output structure
contracts/managed/your-contract/
keys/
circuitName.prover # 2–10 MB
circuitName.verifier # ~2 KB
zkir/
circuitName.bzkir # 1–3 KB
TypeScript バインディングも生成されます。管理対象の出力フォルダーから Contract オブジェクトをインポートしてください。
プレビューでの回路サイズ: ProofStation には最小回路サイズ(k ≥ 6)が必要です。エラー prove: no SRS params for k=6 は、回路が小さすぎることを意味します。ダミーの台帳フィールドを追加してパディングし、サイズを増やしてください。
10) よくある間違い(完全版)
開示に関する間違い:
disclose()を使わずに証人データを台帳に書き込む → コンパイルエラー(適切に早期検出されます)disclose()を使わずにexport circuitから証人データを返す → コンパイルエラーdisclose()がデータを暗号化すると考える → 暗号化されず、平文のままです
型に関する間違い:
<、>が必要な場面でFieldを使う → 代わりにUint<0..n>を使います- 実行時に
as Uint<0..n>でキャストする → 値が範囲外の場合、失敗することがあります(コンパイルエラーではなくトランザクションエラー) type Hash = Bytes<32>(構造型で、相互に置き換え可能)とnew type UserId = Bytes<32>(名目的型で、キャストが必要)を混同する
プライバシーに関する間違い:
- 値の種類が少ない場合に
persistentHashを使う(総当たりで推測可能) → 新しい nonce を使ってpersistentCommitを使います - nonce を再利用する → 同じ値のコミットメントは、オンチェーンで同一になります
- 匿名性が必要な場面で
Set<T>を使う →Set.member(v)はvを明らかにします。代わりにMerkleTreeを使います - コミットメントと nullifier で同じドメインセパレーターを使う → 衝突の可能性があります
- 台帳への保存に
transientHashを使う → 一時的な値はコントラクトのアップグレード後に保持されません - ユーザーごとのデータを
export ledgerに入れる → プライバシー漏えいにつながります
台帳に関する間違い:
- witness の出力に
assertを付け忘れる → witness は信頼できないため、検証してください - コンストラクターで行うべきでないロジックを
constructorに書く → コンストラクターは一度実行されると終了します - 不要に
HistoricMerkleTreeを使う → ストレージコストが増えるため、過去のルートが必要な場合を除きMerkleTreeを使います - 内部ヘルパー回路に
exportを付ける → 不必要に公開範囲が広がります
11) キーワード早見表
| キーワード | 用途 |
|---|---|
pragma language_version >= n | 最小言語バージョンを宣言します |
import ModuleName | モジュールをインポートします |
export ledger f: T | オンチェーンで公開される状態フィールド |
sealed ledger f: T | 一度だけ書き込み可能な状態(コンストラクターのみ) |
witness f(): T | プライベート入力のコールバック(本体は TypeScript に記述) |
export circuit f(): T | 呼び出し可能な ZK 回路 |
pure circuit f(): T | 純粋関数(台帳へのアクセスなし) |
constructor() | デプロイ時に一度だけ実行されます |
disclose(v) | 意図的な公開を明示します |
assert(cond, msg) | 実行時のガード(false の場合、トランザクションは失敗します) |
default<T>() | 型 T のゼロ値または空の値 |
pad(n, str) | 文字列から Bytes<n> を作成します |
some<T>(v) / none<T>() | Maybe/Option コンストラクター |
new type A = B | 名目的型エイリアス |
type A = B | 構造型エイリアス |