Midnight 官方的双人示例是个不错的起点,但如果你做三处设计调整,这套核心模式就能扩展到 N 个参与者:
把固定的每方字段替换成用稳定参与方标识符作为 key 的 Map。
在 Compact 里,这意味着把 commitment 和每个参与方的元数据存进 ledger Map,而不是把 aliceCommitment 和 bobCommitment 这种字段硬编码进合约。
把合约发现与加入视为 client 侧职责。
多个用户应该都能通过 Midnight 示例里提到的 generated driver 或 SDK helper 绑定到同一个已部署合约实例,包括这个 bounty prompt 里点名的 findDeployedContract。具体 API 形态取决于 SDK 版本,所以要固定 package 版本,并对照当前文档核实。
显式按并发更新来设计。
Midnight 合约运行在基于 proof 的模型上。如果两个用户基于同一份旧状态构建 proof,其中一个最终会变成 stale。
因此,合约应该维护一个公开的 version/nonce,并要求每个会修改状态的 action 都证明自己是基于当前 version 构建的。
这篇教程会围绕一个私密 multi-sig treasury示例展开。这个 treasury 的 threshold 和成员数量是公开的,但每个成员的 approval 状态会用以参与方标识符为 key 的 commitment 来表示。文章重点讲的是你可以安全地从 Midnight 公共文档和 Compact 语言参考中推导出来的模式;凡是 client API 里和版本强相关的部分,我都会明确标注为假设,并回链到官方资料。
Midnight 的执行模型并不是“把 TypeScript 直接跑在链上”。Compact 合约会编译成 proving circuits 和一个 JavaScript/TypeScript driver;用户在本地执行 circuit、生成 proof,再把这些 proof 提交到链上。本教程最相关的三个 primitive,正是 Compact language reference 里定义的这几个:
语法和语义的权威来源请看 Midnight 文档里的 Compact language reference:Compact language reference。如果你想先从更高层开始,可以先看入门指南:Midnight getting started。
Midnight 文档里的双人示例通常长这样:
这种模式很适合做教程,因为容易理解。但如果你想支持下面这些能力,它就不够扩展了:
只要参与者达到三人或更多,硬编码字段就不是正确抽象了。你不再是处理 “Alice 和 Bob”,而是在处理“由 partyId 标识的 participant”。你不再是给每个 actor 一个 commitment slot,而是需要一个集合。Map<K, V> 这个 ledger 类型就是专门为这类索引状态准备的;在 Compact standard library 示例、reference primer 和官方文档里都能看到它。
本教程会把 public ledger 压到最小:
threshold:需要多少个 approvalmemberCount:当前有多少成员加入stateVersion:单调递增的版本号,用来检测 stale proofpartyId 作为 key 的 Map,存放 commitment 和每个参与方的元数据这个划分很关键。在 Midnight 里,隐私通常来自“证明关于私密数据的陈述”,而不是假装链上什么都不存。你的工作是决定:哪些数据必须公开,方便协作;哪些值应该保持私密,只通过 commitment 表达。
从双人模式走到 N 人模式,概念上其实很简单:
在一个双人合约里,你可能会看到这种非正式逻辑描述:
到了 N 方,同样的逻辑会变成:
partyId 下注册 commitment 来加入这里最关键的设计问题是:partyId 到底是什么?
对于教程仓库来说,建议用一个稳定、确定性的标识符,并尽量缩小信任面。一个很好的默认值是固定宽度字节串,比如 Bytes<32>,因为它既能表示 hash、编码后的公钥,也能表示应用自定义标识符,而且不要求合约理解更高层的格式。
于是,合约大致可以长这样:
import CompactStandardLibrary;
export sealed ledger threshold: Uint<32>;
export ledger memberCount: Uint<32>;
export ledger stateVersion: Uint<32>;
export sealed ledger joined: Map<Bytes<32>, Boolean>;
export sealed ledger commitments: Map<Bytes<32>, Bytes<32>>;
export ledger approvalNonce: Map<Bytes<32>, Uint<32>>;
上面这段声明里的每个元素都能在 Compact reference 里找到依据:
ledger 和 sealed ledger declaration 是标准的 Compact 语法Uint<32>、Boolean、Bytes<32> 都是文档里写明的 primitive typeMap<K, V> 是文档化的 standard-library container type这种模型能带来的好处:
partyId但它不会自动带来下面这些能力:
partyId 的唯一性保证这些都必须显式设计。
下面这个初始化 circuit 只用了 reference primer 里已经确认过的语法:
export circuit initialize(t: Uint<32>): [] {
threshold = t;
memberCount = 0;
stateVersion = 0;
}
这里我故意保持得很小。在 Midnight 上,教程里合约的 public state 越直观越好。如果部署时只需要 threshold 这一个策略参数,那就别加别的。
因为 ledger 是公开的协调状态。如果参与者真实的 approval 细节、spend limit 或本地决策理由是敏感信息,就应该把这些内容放进由 witness 提供的 private input,只在 ledger 里保留一个 commitment 或派生出来的 public flag。
Compact 文档里还有一个关于 witness 的重要警告:不要信任 witness code 本身。任何 DApp 都可以给 witness function 提供任意实现。这意味着你的 circuit 必须把 witness 输出视为不可信输入,并在 circuit 内部对它施加约束。这个警告对多方设计尤其关键,因为本质上每个用户都在提供自己的 private input 路径。
一个多方 treasury 通常至少需要为每个参与者维护一个 commitment。通用模式是:
commitments[partyId]一个直观的 join witness declaration,可以把 client 想提交的值打包起来:
witness JoinRequest(): [Bytes<32>, Bytes<32>, Uint<32>];
可以把它理解成:
Bytes<32>:partyIdBytes<32>:commitmentUint<32>:observedVersion这样你就能得到一个 join circuit 骨架:
export circuit join(): [] {
const [partyId, commitment, observedVersion] = JoinRequest();
// The circuit should constrain observedVersion against stateVersion.
// It should also ensure that the participant has not already joined,
// then write the new values into the ledger maps and increment:
// - memberCount
// - stateVersion
//
// Consult the current Compact map-access documentation for the exact
// syntax of reading and writing Map<K, V> entries.
}
这里我刻意没有去编造 Map 的访问语法。bounty 明确说了,不能编译的 Compact 代码会直接失去资格,而且 issue 要求必须用真实语法,不能自己发明操作符。正确做法是:把数据模型和状态迁移逻辑讲清楚,同时把具体的 map 读写语法视为版本敏感内容,在发布仓库前直接去当前文档核实。权威来源依然是语言参考文档:Compact language reference。
partyId, not by array position在小示例里,用数组下标看起来很顺手;但一旦进到真实部署系统,它会变得很脆弱:
用 partyId 作为 key 可以避开这些问题。每个参与者都只需要关心“我的记录”,而不用和别人协调 index。尤其是当多个 client 独立地绑定到同一个 deployment 时,这一点更重要。
对一个私密 multi-sig treasury 来说,比较实用的一组 map 是:
joined: Map<Bytes<32>, Boolean>
标记该 party 是否是成员。
commitments: Map<Bytes<32>, Bytes<32>>
该成员当前的 commitment。
approvalNonce: Map<Bytes<32>, Uint<32>>
每个 party 自己的 replay-protection counter,用于 approval 或 update。
如果你愿意,还可以继续加更多,但这三个已经足够把模式讲明白:
一个经验法则是:尽量分离关注点,不要把多种语义塞进同一个值里。比如,如果合约需要公开协调“是否为成员”和“当前 approval nonce”这两个事实,就不要把它们都藏进一个 opaque commitment 里。
合约不需要知道 commitment 背后完整的私密结构,它只需要知道后续 circuit 会对哪些内容做证明。以 treasury 为例,private preimage 可能包含:
这样一来,public commitment 就可以在一个理解 witness 的 circuit 里重新计算,并与 commitments[partyId] 做检查。成员于是就能在不暴露底层 secret 的情况下,证明自己状态的连续性。
这里最重要的架构点是:map 负责动态成员管理,commitment 负责隐私。 两者缺一不可。
findDeployedContract合约侧定义的是共享状态机,但加入流程发生在 client 代码里。这个 bounty 特别要求展示多个用户如何通过 findDeployedContract 加入一个已部署合约。helper 的具体名字和调用签名取决于 SDK 版本,所以这里我会描述稳定的模式,并明确指出哪些部分和版本相关。
每个参与者在 client 侧的大致流程是:
join() 或其他成员加入 circuit伪代码如下:
// Assumption: your project uses the generated driver for the Compact contract
// plus the contract-discovery helper referenced by Midnight examples.
// Verify the exact API name/signature against your installed SDK version and
// current Midnight docs before publishing the repository.
import { findDeployedContract } from "your-midnight-client-layer";
import contractInfo from "./artifacts/contract-info.json";
async function attachAsParticipant({
deploymentRef,
witnessContext,
}: {
deploymentRef: string;
witnessContext: unknown;
}) {
const treasury = await findDeployedContract({
deploymentRef,
contractInfo,
witnessContext,
});
await treasury.join();
return treasury;
}
这段代码的重点不在于 import path 的精确写法,而在于交互的形状:
这就是 Midnight 上 N 方交互的本质。
findDeployedContract matters双人教程可以偷个懒:让部署者直接把实例传给另一个脚本就行。但真实应用不能这么做。独立用户需要在稍后的时间、不同 session、甚至不同机器上,发现并绑定到同一个 deployment。
一旦你把这个前提显式建模出来,几个设计后果就很自然了:
这也是为什么 stateVersion 字段会变得必不可少。
每个用户都应该有一个只包含自己私密数据的 witness context:
读到这里很感谢。如果 “Midnight 多方私有状态合约” 这个话题正好契合你目前的工作,下面三条路径任选其一:
完整的 Midnight ZK Cookbook 索引 覆盖 Midnight、Aleo、Aztec、Noir、risc0 共 17 篇英文教程加 4 篇中文翻译,按生态分组列出。
Bounty Radar 聚合 Algora、GitHub labels、Drips Wave、Code4rena、Bountycaster 的开放 ZK bounty。Midnight 子 feed 只列出该生态匹配项,JSON 在 /midnight.json。免费版需轮询;$19/月 Hobbyist 档 会把一个 filter 实时推送到 Telegram。
zk-pipeline-doctor 是 MIT 开源的免费 CLI,对任意 ZK 项目按测试、CI、文档、安全、可复现性、语言工具链六个维度打分(支持 Compact、Leo、Noir、Cairo 和 7 个 Rust zkVM)。用 zk-doctor-action 接进 GitHub Action 可获得 PR diff 评论。$15/月 Pro 档 增加 4 个跨生态深度检测器(电路复杂度、证明系统坑点、verifier soundness、多文件一致性)。
由 AI 辅助起草,作者在发布前逐行审阅。完整流程见 DISCLOSURE。