midnight-security

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Midnight Security Skill

Midnight 安全技能

Midnight's privacy model is opt-in privacy on top of a public chain. The ZK proof proves correctness of execution — it does not hide the existence of a transaction, its structure, or anything in
export ledger
. Security on Midnight means being precise about what leaks and designing contracts so that leaks are intentional.
Primary references:
  • docs.midnight.network/concepts/how-midnight-works/keeping-data-private
  • docs.midnight.network/concepts/how-midnight-works/semantics
  • docs.midnight.network/concepts/how-midnight-works/smart-contracts
  • Official security docs: "coming soon" as of 2026 — this skill fills that gap

Midnight的隐私模型是基于公链的可选隐私模式。ZK proof可证明执行的正确性——但它不会隐藏交易的存在性、交易的结构
export ledger
中的任何内容。Midnight的安全性意味着要明确哪些信息会泄露,并设计合约使信息泄露是可控且有意的。
主要参考资料:
  • docs.midnight.network/concepts/how-midnight-works/keeping-data-private
  • docs.midnight.network/concepts/how-midnight-works/semantics
  • docs.midnight.network/concepts/how-midnight-works/smart-contracts
  • 官方安全文档:截至2026年“即将发布”——本技能填补了这一空白

1) The Fundamental Visibility Rule

1) 基础可见性规则

Everything in
export ledger
is public. Everything in
witness
is private. The proof is public. The witness data is not.
More precisely, for every ledger operation:
OperationWhat is revealed
ledger.insert(v)
The value
v
ledger.lookup(k)
The key
k
and the returned value
set.insert(v)
The value
v
set.member(f(x))
The value
f(x)
— not
x
directly
map.insert(k, v)
The key
k
and value
v
counter.increment(n)
The increment amount
n
and new value
merkleTree.insert(v)
Does NOT reveal
v
— only that the tree grew
merkleTree.checkRoot(path)
That someone proved membership — not which entry
Circuit argumentsAll of them — they are part of the public transcript
witness
return values
Nothing on-chain — the ZK proof only proves they existed
The public transcript is what the chain records: ledger reads, writes, and the structure of those operations. Witness values exist only in the proof's private inputs — they are never posted.

export ledger
中的所有内容都是公开的。
witness
中的所有内容都是私有的。Proof是公开的。witness数据则不是。
更准确地说,对于每个账本操作:
操作公开内容
ledger.insert(v)
v
ledger.lookup(k)
k
及返回值
set.insert(v)
v
set.member(f(x))
f(x)
——而非直接公开
x
map.insert(k, v)
k
和值
v
counter.increment(n)
增量
n
和新值
merkleTree.insert(v)
不会公开
v
——仅会显示树的规模有所增长
merkleTree.checkRoot(path)
仅证明存在成员关系——不会公开具体条目
电路参数所有参数——它们属于公开记录的一部分
witness
返回值
链上无任何公开内容——ZK proof仅证明这些值存在过
公开记录是链上存储的内容:账本的读写操作及这些操作的结构。witness值仅存在于proof的私有输入中——永远不会被发布到链上。

2) Privacy Audit Checklist

2) 隐私审计清单

Run this against every contract before shipping.
在合约上线前,针对每个合约执行以下检查。

Ledger fields

账本字段

  • Every
    export ledger
    field: who can read it, and is that acceptable?
  • Is any
    export ledger
    field per-user data that should be local (witness)?
  • Does the field exist only because the frontend needs to read it? If only one user cares, it should not be on-chain.
  • For any
    Map<K, V>
    : is the key
    K
    itself sensitive? (Map keys are public on insert/lookup.)
  • Is a
    Set<T>
    used when anonymity is required? (
    Set.member(v)
    reveals
    v
    — use
    MerkleTree
    instead.)
  • 每个
    export ledger
    字段:谁可以读取它,这是否可接受?
  • 是否存在本该作为本地(witness)数据的
    export ledger
    用户专属字段?
  • 某个字段是否仅因前端需要读取而存在?如果只有单个用户关心,它不应被存储在链上。
  • 对于任何
    Map<K, V>
    :键
    K
    本身是否敏感?(Map的键在插入/查询时是公开的。)
  • 是否在需要匿名性的场景下使用了
    Set<T>
    ?(
    Set.member(v)
    会公开
    v
    ——应改用
    MerkleTree
    。)

Circuit arguments

电路参数

  • Every circuit parameter: is it part of the public transcript? (Yes, all of them.)
  • Is any sensitive value passed directly as a circuit argument instead of through a witness?
  • 每个电路参数:它是否属于公开记录的一部分?(是的,所有参数都是。)
  • 是否有敏感值直接作为电路参数传递,而非通过witness?

Commitments and hashes

承诺与哈希

  • Any
    persistentHash
    over a small value space (e.g., boolean, vote, role)? → brute-forcible → use
    persistentCommit
    with a fresh nonce instead.
  • Any two commitments that reuse the same nonce? → identical inputs produce identical commitments on-chain → linkable.
  • Is
    transientHash
    /
    transientCommit
    used for ledger storage? → does not survive contract upgrades → use
    persistent*
    .
  • 是否对小值域(如布尔值、投票结果、角色)使用了
    persistentHash
    ?→ 可被暴力破解→ 应改用带新鲜随机数(nonce)的
    persistentCommit
  • 是否存在两个承诺重用同一个nonce的情况?→ 相同输入会在链上生成相同承诺→ 可被关联追踪。
  • 是否将
    transientHash
    /
    transientCommit
    用于账本存储?→ 无法在合约升级后保留→ 应使用
    persistent*
    系列方法。

Domain separators

域分隔符

  • Every
    persistentHash
    call: does it include a unique domain separator string?
  • Is the same secret key used to derive multiple public keys without domain separation? → same key, same hash → different purposes become linked.
  • Is the same domain separator used for both a commitment and its nullifier? → potential collision.
  • 每个
    persistentHash
    调用:是否包含唯一的域分隔符字符串?
  • 是否在未使用域分隔的情况下,用同一个私钥派生多个公钥?→ 相同密钥生成相同哈希→ 不同用途的操作会被关联。
  • 是否对承诺及其nullifier使用了相同的域分隔符?→ 存在碰撞风险。

Witness trust

Witness信任

  • Every witness output: is it validated with
    assert
    before being used in logic?
  • Does any circuit assume that a witness returns "honest" data without checking it?
  • 每个witness输出:在用于逻辑处理前是否用
    assert
    进行了验证?
  • 是否有电路在未做检查的情况下,假设witness返回“诚实”数据?

Replay and reuse

重放与重用

  • Any auth pattern using a secret key without a sequence counter or nullifier? → replay attack.
  • Any commitment that can be reused to re-authorize the same action? → missing nullifier.
  • 是否存在未使用序列计数器或nullifier的认证模式?→ 易遭受重放攻击。
  • 是否存在可被重复用于重新授权同一操作的承诺?→ 缺少nullifier。

Transaction structure leaks

交易结构泄露

  • Does calling a specific circuit reveal intent even if data is hidden? (e.g., calling
    submitVote
    reveals that someone voted)
  • Does the number of
    merkleTree.insert
    calls within a circuit reveal anything about the private inputs?

  • 调用特定电路是否会暴露用户意图,即使数据被隐藏?(例如,调用
    submitVote
    会暴露有人进行了投票)
  • 电路中
    merkleTree.insert
    的调用次数是否会泄露关于私有输入的信息?

3) What Leaks Even With Witnesses

3) 即使使用Witness仍会泄露的信息

These things are always visible to any chain observer, regardless of privacy measures:
  • Which contract was called — the contract address is public
  • Which circuit was called — the
    entryPoint
    field in the indexer
  • When it was called — block timestamp
  • That a transaction occurred — existence is always public
  • Circuit argument values — every parameter passed to an
    export circuit
  • All ledger writes — every
    export ledger
    field that changes
  • The shape of the public transcript — how many reads, writes, inserts happened (though not the private values behind them)
Practical implication: If your circuit takes
vote: Boolean
as an argument, the vote is public even though you never write it to the ledger directly. Move it to a witness.

无论采取何种隐私措施,以下内容对所有链上观察者都是可见的:
  • 调用的合约——合约地址是公开的
  • 调用的电路——索引器中的
    entryPoint
    字段
  • 调用时间——区块时间戳
  • 交易已发生——交易的存在性始终是公开的
  • 电路参数值——传递给
    export circuit
    的所有参数
  • 所有账本写入操作——所有被修改的
    export ledger
    字段
  • 公开记录的结构——读写、插入操作的次数(但不会暴露背后的私有值)
实际影响: 如果你的电路将
vote: Boolean
作为参数,即使你从未将其写入账本,投票结果也是公开的。应将其移至witness中。

4) Data Leak Patterns (The Common Mistakes)

4) 数据泄露模式(常见错误)

Pattern 1: Sensitive value as circuit argument

模式1:敏感值作为电路参数

compact
// ❌ vote is a circuit argument → public transcript → everyone sees it
export circuit submitVote(vote: Boolean): [] {
  votes.insert(disclose(makeCommitment(vote)));
}

// ✅ vote comes from witness → stays in proof's private inputs
witness userVote(): Boolean;

export circuit submitVote(): [] {
  const vote = userVote();  // private
  votes.insert(disclose(makeCommitment(vote)));  // only commitment is public
}
compact
// ❌ vote是电路参数 → 公开记录 → 所有人都能看到
export circuit submitVote(vote: Boolean): [] {
  votes.insert(disclose(makeCommitment(vote)));
}

// ✅ vote来自witness → 仅存在于proof的私有输入中
witness userVote(): Boolean;

export circuit submitVote(): [] {
  const vote = userVote();  // 私有
  votes.insert(disclose(makeCommitment(vote)));  // 仅承诺是公开的
}

Pattern 2: Using
Set
when anonymity is required

模式2:在需要匿名性的场景下使用
Set

compact
// ❌ Set.member(v) reveals v — which user's commitment is being checked
export ledger members: Set<Bytes<32>>;

export circuit prove(): [] {
  const pk = publicKey(secretKey());
  assert(members.member(pk), "not a member");  // reveals pk
}

// ✅ MerkleTree proves membership without revealing which entry
export ledger members: MerkleTree<16, Bytes<32>>;
witness findMemberPath(pk: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit prove(): [] {
  const pk = publicKey(secretKey());
  const path = findMemberPath(pk);
  assert(
    members.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "not a member"
  );
  // Only proves: "someone in the tree called this." Does not reveal who.
}
compact
// ❌ Set.member(v)会暴露v —— 会公开正在检查哪个用户的承诺
export ledger members: Set<Bytes<32>>;

export circuit prove(): [] {
  const pk = publicKey(secretKey());
  assert(members.member(pk), "not a member");  // 暴露pk
}

// ✅ MerkleTree可在不暴露具体条目的情况下证明成员关系
export ledger members: MerkleTree<16, Bytes<32>>;
witness findMemberPath(pk: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit prove(): [] {
  const pk = publicKey(secretKey());
  const path = findMemberPath(pk);
  assert(
    members.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "not a member"
  );
  // 仅证明:“树中的某个用户调用了此电路”。不会暴露具体是谁。
}

Pattern 3: Hash without nonce over small value space

模式3:对小值域使用不带nonce的哈希

compact
// ❌ persistentHash over Boolean → 2 possible outputs → trivially brute-forced
export ledger commitment: Bytes<32>;
export circuit commit(vote: Boolean): [] {
  commitment = disclose(persistentHash<Boolean>(vote));
}

// ✅ persistentCommit with fresh nonce → infeasible to brute-force
witness voteNonce(): Bytes<32>;
export circuit commit(vote: Boolean): [] {  // vote still a circuit arg here — see pattern 1
  const nonce = voteNonce();
  commitment = disclose(persistentCommit<Boolean>(vote, nonce));
}
// Combine with pattern 1 to also move vote to witness
compact
// ❌ 对布尔值使用persistentHash → 仅2种可能输出 → 极易被暴力破解
export ledger commitment: Bytes<32>;
export circuit commit(vote: Boolean): [] {
  commitment = disclose(persistentHash<Boolean>(vote));
}

// ✅ 带新鲜nonce的persistentCommit → 无法被暴力破解
witness voteNonce(): Bytes<32>;
export circuit commit(vote: Boolean): [] {  // 此处vote仍是电路参数 —— 参考模式1
  const nonce = voteNonce();
  commitment = disclose(persistentCommit<Boolean>(vote, nonce));
}
// 结合模式1,将vote也移至witness中

Pattern 4: Nonce reuse linking commitments

模式4:重用nonce导致承诺被关联

compact
// ❌ same secret key as nonce → if same value committed twice, identical on-chain
export circuit recordBalance(amount: Uint<64>): [] {
  const sk = secretKey();
  const c = persistentCommit<Uint<64>>(amount, sk);  // reusing sk as nonce
  commitments.insert(disclose(c));
}

// ✅ derive a unique nonce per commitment using round counter or fresh randomness
export ledger round: Counter;
export circuit recordBalance(amount: Uint<64>): [] {
  const sk = secretKey();
  const nonce = persistentHash<Vector<2, Bytes<32>>>(
    [sk, pad(32, "balance-nonce"), round as Field as Bytes<32>]
  );
  const c = persistentCommit<Uint<64>>(amount, nonce);
  commitments.insert(disclose(c));
  round.increment(1);
}
compact
// ❌ 将同一个私钥作为nonce → 如果两次提交相同值,链上会生成相同承诺
export circuit recordBalance(amount: Uint<64>): [] {
  const sk = secretKey();
  const c = persistentCommit<Uint<64>>(amount, sk);  // 重用sk作为nonce
  commitments.insert(disclose(c));
}

// ✅ 使用轮次计数器或新鲜随机数为每个承诺派生唯一nonce
export ledger round: Counter;
export circuit recordBalance(amount: Uint<64>): [] {
  const sk = secretKey();
  const nonce = persistentHash<Vector<2, Bytes<32>>>(
    [sk, pad(32, "balance-nonce"), round as Field as Bytes<32>]
  );
  const c = persistentCommit<Uint<64>>(amount, nonce);
  commitments.insert(disclose(c));
  round.increment(1);
}

Pattern 5: Missing domain separator — key reuse across purposes

模式5:缺少域分隔符——跨用途重用密钥

compact
// ❌ same sk → same hash for auth and for commitment → linkable across contracts
pure circuit publicKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Bytes<32>>(sk);
}

pure circuit commitmentKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Bytes<32>>(sk);  // identical output
}

// ✅ unique domain separator per purpose
pure circuit authPublicKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:auth:v1"), sk]);
}

pure circuit commitmentKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:commit:v1"), sk]);
}
compact
// ❌ 同一个sk → 认证和承诺使用相同哈希 → 跨合约操作可被关联
pure circuit publicKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Bytes<32>>(sk);
}

pure circuit commitmentKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Bytes<32>>(sk);  // 输出相同
}

// ✅ 为每个用途使用唯一的域分隔符
pure circuit authPublicKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:auth:v1"), sk]);
}

pure circuit commitmentKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:commit:v1"), sk]);
}

Pattern 6: Untrusted witness output

模式6:未验证的witness输出

compact
// ❌ trusting that the DApp-provided witness returns the right balance
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const balance = userBalance();  // could return anything
  balances.insert(to, disclose(balance - amount));
}

// ✅ assert all witness outputs before using them
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const balance = userBalance();
  assert(balance >= amount, "insufficient balance");
  assert(balance <= MAX_SUPPLY, "invalid balance");
  balances.insert(to, disclose((balance - amount) as Uint<64>));
}

compact
// ❌ 信任DApp提供的witness返回正确余额
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const balance = userBalance();  // 可返回任意值
  balances.insert(to, disclose(balance - amount));
}

// ✅ 在使用前验证所有witness输出
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const balance = userBalance();
  assert(balance >= amount, "insufficient balance");
  assert(balance <= MAX_SUPPLY, "invalid balance");
  balances.insert(to, disclose((balance - amount) as Uint<64>));
}

5) Defensive Patterns (Full Implementations)

5) 防御性模式(完整实现)

Hash-Based Authentication (ZK Signature)

基于哈希的认证(ZK签名)

Proves knowledge of a secret key without revealing it. The sequence counter prevents replay across rounds.
compact
pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger admin: Bytes<32>;
export ledger round: Counter;

witness secretKey(): Bytes<32>;

constructor() {
  admin = publicKey(secretKey(), 0);
  round.increment(1);
}

export circuit adminAction(): [] {
  const sk = secretKey();
  const currentRound = round as Field as Bytes<32>;
  assert(admin == publicKey(sk, currentRound), "not authorized");
  // ... do the action
  round.increment(1);  // invalidates old proofs against this round
  admin = disclose(publicKey(sk, round as Field as Bytes<32>));
}

pure circuit publicKey(sk: Bytes<32>, roundBytes: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<3, Bytes<32>>>([
    pad(32, "myapp:admin:v1"),  // domain separator
    roundBytes,                  // round prevents replay
    sk,
  ]);
}
在不暴露私钥的情况下证明用户拥有私钥。序列计数器可防止跨轮次的重放攻击。
compact
pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger admin: Bytes<32>;
export ledger round: Counter;

witness secretKey(): Bytes<32>;

constructor() {
  admin = publicKey(secretKey(), 0);
  round.increment(1);
}

export circuit adminAction(): [] {
  const sk = secretKey();
  const currentRound = round as Field as Bytes<32>;
  assert(admin == publicKey(sk, currentRound), "not authorized");
  // ... 执行操作
  round.increment(1);  // 使针对本轮次的旧proof失效
  admin = disclose(publicKey(sk, round as Field as Bytes<32>));
}

pure circuit publicKey(sk: Bytes<32>, roundBytes: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<3, Bytes<32>>>([
    pad(32, "myapp:admin:v1"),  // 域分隔符
    roundBytes,                  // 轮次防止重放
    sk,
  ]);
}

Commitment/Nullifier (Single-Use Anonymous Token)

Commitment/Nullifier(一次性匿名代币)

Proves a token exists in the Merkle tree and has not been spent, without revealing which token.
compact
pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger commitments: HistoricMerkleTree<16, Bytes<32>>;
export ledger nullifiers: Set<Bytes<32>>;
export ledger counter: Counter;

witness secretKey(): Bytes<32>;
witness findCommitmentPath(c: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit addToken(commitment: Bytes<32>): [] {
  commitments.insert(disclose(commitment));
}

export circuit spend(): [] {
  const sk = secretKey();
  const c = commitment(sk);
  const path = findCommitmentPath(c);

  // 1. Prove token is in the tree (without revealing which)
  assert(
    commitments.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "commitment not in tree"
  );

  // 2. Prove token has not been spent
  const nul = nullifier(sk);
  assert(!nullifiers.member(disclose(nul)), "already spent");

  // 3. Record the spend — reveals nullifier, not the commitment
  nullifiers.insert(disclose(nul));
  counter.increment(1);
}

// CRITICAL: commitment and nullifier MUST use different domain separators
// If they shared a domain, the same sk would produce the same hash for both
pure circuit commitment(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:commit:v1"), sk]);
}

pure circuit nullifier(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:nullifier:v1"), sk]);
}
Use
HistoricMerkleTree
instead of
MerkleTree
when: the tree has frequent insertions that would otherwise invalidate outstanding proofs. Use plain
MerkleTree
when proofs are always generated against the current root and items are never removed.
在不暴露具体代币的情况下,证明代币存在于Merkle树中且未被花费。
compact
pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger commitments: HistoricMerkleTree<16, Bytes<32>>;
export ledger nullifiers: Set<Bytes<32>>;
export ledger counter: Counter;

witness secretKey(): Bytes<32>;
witness findCommitmentPath(c: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit addToken(commitment: Bytes<32>): [] {
  commitments.insert(disclose(commitment));
}

export circuit spend(): [] {
  const sk = secretKey();
  const c = commitment(sk);
  const path = findCommitmentPath(c);

  // 1. 证明代币存在于树中(不暴露具体代币)
  assert(
    commitments.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "commitment not in tree"
  );

  // 2. 证明代币未被花费
  const nul = nullifier(sk);
  assert(!nullifiers.member(disclose(nul)), "already spent");

  // 3. 记录花费操作——公开nullifier,而非commitment
  nullifiers.insert(disclose(nul));
  counter.increment(1);
}

// 关键:commitment和nullifier必须使用不同的域分隔符
// 如果使用相同域,同一个sk会为两者生成相同哈希
pure circuit commitment(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:commit:v1"), sk]);
}

pure circuit nullifier(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:nullifier:v1"), sk]);
}
当树频繁插入会使未完成的proof失效时,使用
HistoricMerkleTree
而非
MerkleTree
。当proof始终针对当前根生成且条目不会被移除时,使用普通
MerkleTree

Anonymous Allowlist (Merkle Membership)

匿名白名单(Merkle成员证明)

Authorize a set of users without revealing which one is acting.
compact
pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger allowlist: MerkleTree<16, Bytes<32>>;

witness secretKey(): Bytes<32>;
witness findAllowlistPath(pk: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit addToAllowlist(commitment: Bytes<32>): [] {
  // Admin adds a commitment — reveals commitment (hash of user's key)
  // The commitment itself doesn't reveal who the user is unless brute-forced
  allowlist.insert(disclose(commitment));
}

export circuit allowlistedAction(): [] {
  const sk = secretKey();
  const pk = allowlistKey(sk);
  const path = findAllowlistPath(pk);

  assert(
    allowlist.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "not on allowlist"
  );

  // Action proceeds. Observer knows: "someone on the allowlist did this."
  // Observer does NOT know: which allowlisted user.
}

pure circuit allowlistKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:allowlist:v1"), sk]);
}

在不暴露具体用户的情况下授权一组用户执行操作。
compact
pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger allowlist: MerkleTree<16, Bytes<32>>;

witness secretKey(): Bytes<32>;
witness findAllowlistPath(pk: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit addToAllowlist(commitment: Bytes<32>): [] {
  // 管理员添加承诺——公开承诺(用户密钥的哈希)
  // 除非被暴力破解,否则承诺本身不会暴露用户身份
  allowlist.insert(disclose(commitment));
}

export circuit allowlistedAction(): [] {
  const sk = secretKey();
  const pk = allowlistKey(sk);
  const path = findAllowlistPath(pk);

  assert(
    allowlist.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "not on allowlist"
  );

  // 执行操作。观察者仅知道:“白名单中的某个用户执行了此操作。”
  // 观察者不知道:具体是哪个白名单用户。
}

pure circuit allowlistKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:allowlist:v1"), sk]);
}

6) Transaction Semantics — Security Implications

6) 交易语义——安全影响

Midnight transactions execute in two phases with different failure behavior:
well-formedness check  →  guaranteed phase  →  fallible phase
PhaseFailure behaviorFee behavior
Well-formednessTransaction rejected entirely, not includedNo fee
GuaranteedTransaction rejected entirely, not includedNo fee
FallibleGuaranteed phase effects persist; fallible phase rolled backFees forfeited
Security implications:
Partial success is real. If your contract does work in the guaranteed phase (e.g., burns a token) and then fails in the fallible phase (e.g., a balance check fails), the burn is permanent but the balance isn't updated. Design state transitions so they either succeed atomically or the guaranteed phase does nothing consequential.
Fees are forfeited on fallible failure. An attacker who can force fallible failures can drain DUST from users' wallets. Ensure
assert
conditions in fallible circuits cannot be triggered by external state that an attacker can manipulate between proof generation and submission.
Front-running via public transcript. Because the public transcript (including which circuit was called and all circuit arguments) is visible in the mempool before finalization, an observer can potentially act on that information before the transaction lands. If ordering matters (e.g., a swap), design circuits that include slippage bounds or use commitment reveals to defer sensitive data.

Midnight交易分为两个阶段执行,不同阶段的失败行为不同:
格式校验  →  保证阶段  →  易失败阶段
阶段失败行为手续费规则
格式校验交易被完全拒绝,不被打包进区块不收取手续费
保证阶段交易被完全拒绝,不被打包进区块不收取手续费
易失败阶段保证阶段的操作效果保留;易失败阶段的操作回滚手续费不予退还
安全影响:
部分成功是真实存在的。 如果你的合约在保证阶段执行了某些操作(例如销毁代币),然后在易失败阶段执行失败(例如余额检查不通过),销毁操作会永久生效,但余额不会被更新。设计状态转换时应确保操作要么原子性成功,要么保证阶段不执行任何有实际影响的操作。
易失败阶段失败会损失手续费。 攻击者若能触发易失败阶段的失败,可耗尽用户钱包中的小额资金。确保易失败电路中的
assert
条件不会被攻击者在proof生成与提交之间操纵的外部状态触发。
通过公开记录的抢先交易(front-running)。 由于公开记录(包括调用的电路及所有电路参数)在交易最终确认前就会在内存池中可见,观察者可能会在交易打包前利用这些信息采取行动。如果交易顺序很重要(例如代币交换),应设计包含滑价限制的电路,或使用承诺披露模式延迟敏感数据的公开。

7) What ZK Proofs Do and Do Not Guarantee

7) ZK Proof能保证和不能保证的内容

ZK proofs guarantee:
  • The circuit logic ran correctly with some valid private inputs
  • The public transcript faithfully records what the circuit read/wrote to the ledger
  • The prover knew a witness satisfying all circuit constraints at proof time
ZK proofs do NOT guarantee:
  • That the private inputs were "honest" from the application's perspective (only that they satisfied the circuit's
    assert
    statements)
  • Anything about the prover's identity
  • That the same witness values were used as in a previous call
  • Confidentiality of circuit arguments — those are always public
Practical consequence: Every invariant your contract cares about must be enforced by an
assert
in the circuit. The ZK proof only proves what the circuit checks.

ZK Proof可保证:
  • 电路逻辑在使用有效私有输入的情况下正确执行
  • 公开记录准确记录了电路对账本的读写操作
  • 证明者在生成proof时拥有满足所有电路约束的witness
ZK Proof不能保证:
  • 私有输入从应用角度来看是“诚实”的(仅能证明它们满足电路的
    assert
    语句)
  • 关于证明者身份的任何信息
  • 本次调用使用的witness值与之前调用相同
  • 电路参数的保密性——这些参数始终是公开的
实际结论: 合约关心的每个不变量都必须通过电路中的
assert
来强制执行。ZK Proof仅能证明电路检查过的内容。

8) Merkle Tree Depth Selection

8) Merkle树深度选择

DepthMax leavesCircuit impactUse for
8256MinimalTiny allowlists, tests
101,024LowSmall user sets
1665,536ModerateMedium DApps
201,048,576HigherLarge user sets
32~4 billionSignificantMaximum scale
Use the minimum depth needed. Each additional level increases circuit size and proof time. Overflow (inserting beyond capacity) is a runtime error — size your tree for your expected maximum.
HistoricMerkleTree
stores all past roots — it has higher storage cost than
MerkleTree
. Only use it when you need to prove membership against a past root (e.g., when tree is frequently updated between proof generation and submission).

深度最大叶子数对电路的影响适用场景
8256极小小型白名单、测试场景
101,024小型用户群体
1665,536中等中型DApp
201,048,576较高大型用户群体
32~40亿显著最大规模场景
使用满足需求的最小深度。每增加一个层级都会增大电路规模并延长proof生成时间。溢出(插入数量超过容量)会导致运行时错误——应根据预期最大规模选择树的深度。
HistoricMerkleTree
会存储所有历史根——其存储成本高于
MerkleTree
。仅当需要针对历史根证明成员关系时使用(例如,在proof生成与提交之间树会频繁更新的场景)。

9) Security Quick-Reference Table

9) 安全速查表

RiskBad patternSafe pattern
Sensitive value on-chainCircuit argument for private dataMove to
witness
Membership reveals identity
Set<T>
membership check
MerkleTree<n, T>
path proof
Brute-forcible hash
persistentHash
over small domain
persistentCommit
with fresh nonce
Linked commitmentsSame nonce across multiple commitsRound counter or fresh nonce per commit
Key reuse across purposesSame
sk
→ same hash everywhere
Unique domain separator per purpose
Commitment = nullifier collisionSame domain for bothDifferent
pad(32, ...)
strings
Replay attackAuth without sequence counterInclude round/counter in public key derivation
Double-spendNo nullifier tracking
Set<Bytes<32>>
nullifier registry
Untrusted witnessUse witness output directly
assert
all witness outputs before use
Upgrade breaks commitments
transientHash
in ledger
persistentHash
/
persistentCommit
Partial success trapConsequential work in guaranteed phaseKeep guaranteed phase minimal
Front-runningSensitive args in circuit parametersUse witness + commitment reveal pattern
风险不良模式安全模式
敏感值上链将私有数据作为电路参数移至
witness
成员关系暴露身份使用
Set<T>
成员检查
使用
MerkleTree<n, T>
路径证明
哈希可被暴力破解对小值域使用
persistentHash
使用带新鲜nonce的
persistentCommit
承诺被关联追踪多个承诺重用同一个nonce为每个承诺使用轮次计数器或新鲜nonce
跨用途重用密钥同一个
sk
在所有场景生成相同哈希
为每个用途使用唯一域分隔符
Commitment与nullifier碰撞两者使用相同域使用不同的
pad(32, ...)
字符串
重放攻击认证未使用序列计数器在公钥派生中加入轮次/计数器
双花未追踪nullifier使用
Set<Bytes<32>>
存储nullifier注册表
不可信witness直接使用witness输出在使用前用
assert
验证所有witness输出
合约升级破坏承诺在账本中使用
transientHash
使用
persistentHash
/
persistentCommit
部分成功陷阱在保证阶段执行有实际影响的操作尽量简化保证阶段的操作
抢先交易(front-running)敏感数据作为电路参数使用witness + 承诺披露模式