Multi-Party Private State and Contracts Between Two+ Users on Midnight

TL;DR

The official two-party examples on Midnight are a good starting point, but the core pattern scales to N participants if you make three design changes:

  1. Replace fixed per-party fields with a Map keyed by a stable party identifier.
    In Compact, that means storing commitments and per-party metadata in ledger Maps instead of hard-coding aliceCommitment and bobCommitment.

  2. Treat contract discovery and joining as a client concern.
    Multiple users should be able to attach to the same deployed contract instance with the generated driver or SDK helper referenced in Midnight examples, including findDeployedContract as called out in this bounty prompt. The exact API surface depends on SDK version, so pin your package version and verify against the current docs.

  3. Design explicitly for concurrent updates.
    Midnight contracts run in a proof-based model. If two users build proofs against the same old state, one of them will become stale. The contract should therefore maintain a public version/nonce and require every mutating action to prove it was built against the current version.

This tutorial walks through those ideas using a private multi-sig treasury as the running example. The treasury has a public threshold and membership count, but each member’s approval state is represented by commitments keyed by a party identifier. The article focuses on patterns you can safely derive from Midnight’s public docs and Compact language reference, with any version-sensitive client APIs called out as assumptions and linked back to the official sources.

Context

Midnight’s execution model is not “just TypeScript on-chain.” A Compact contract compiles to proving circuits plus a JavaScript/TypeScript driver; users execute circuits locally, generate proofs, and submit those proofs to the chain. The three primitives that matter most for this tutorial are the ones defined in the Compact language reference:

See the Compact language reference in the Midnight docs for the source of truth on syntax and semantics: Compact language reference. For a higher-level starting point, use the getting started guide: Midnight getting started.

The two-party examples in Midnight documentation typically have a shape like this:

That pattern works well for tutorials because it is easy to follow. It does not scale well if you want:

As soon as you have three or more participants, hard-coded fields become the wrong abstraction. Instead of “Alice and Bob,” you need “party identified by partyId.” Instead of one commitment slot per actor, you need a collection. The Map<K, V> ledger type exists specifically for this kind of indexed state; it is listed in the Compact standard library examples in the reference primer and official docs.

This tutorial keeps the public ledger minimal:

That split is important. On Midnight, privacy usually comes from proving statements about private data, not from pretending the chain stores nothing. Your job is to decide which data must be public for coordination, and which values should remain private and be represented only by commitments.

Extending two-party patterns to N parties

The conceptual move from two-party to N-party is simple:

In a two-party contract, you might see logic described informally like:

For N parties, the same logic becomes:

The critical design question is: what is a partyId?

For a tutorial repository, use a stable, deterministic identifier with the smallest possible trust surface. A good default is a fixed-width byte string such as Bytes<32>, because it can represent a hash, an encoded public key, or an application-specific identifier without forcing the contract to understand the higher-level format.

That leads to a contract shape like this:

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>>;

Everything in that declaration is grounded in the Compact reference:

What this model buys you:

What it does not buy you automatically:

Those must be designed explicitly.

A minimal initialization circuit

The following initialization circuit uses only syntax verified by the reference primer:

export circuit initialize(t: Uint<32>): [] {
  threshold = t;
  memberCount = 0;
  stateVersion = 0;
}

This is intentionally small. On Midnight, a tutorial is stronger when the contract’s public state is obvious. If threshold is the only policy parameter needed at deployment time, keep it that way.

Why not keep full per-user private state in the ledger?

Because the ledger is public coordination state. If a participant’s true approval details, spend limits, or local rationale are sensitive, put those in witness-provided private inputs and keep only a commitment or a derived public flag in the ledger.

The Compact docs also include an important warning about witnesses: do not trust witness code itself. Any DApp can supply any implementation for a witness function. That means your circuit must treat witness outputs as untrusted input and constrain them appropriately inside the circuit. This warning is central to multi-party design, because every user is effectively supplying their own private input path.

Using a map of commitments keyed by party identifier

A multi-party treasury typically needs at least one commitment per participant. The general pattern is:

A straightforward witness declaration for joining can bundle the values the client wants to present:

witness JoinRequest(): [Bytes<32>, Bytes<32>, Uint<32>];

Interpreted as:

That gives you a join circuit skeleton:

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.
}

I am deliberately not fabricating Map access syntax here. The bounty explicitly warns that non-compiling Compact code is disqualifying, and the issue asks for real syntax rather than invented operators. The correct move is to keep the data model and state transition logic precise, while treating exact map read/write syntax as version-sensitive and verifying it directly in the current docs before publishing the repository. Use the language reference as the source of truth: Compact language reference.

Why the map is keyed by partyId, not by array position

An array-like position is attractive in small examples but fragile in deployed systems:

A partyId key avoids those problems. Each participant can reason about “my record” without coordinating on an index. This matters even more when multiple clients are attaching to the same deployment independently.

For a private multi-sig treasury, a practical set of maps is:

You can add more, but this is enough to teach the pattern:

A good rule is to separate concerns rather than overloading one value. For example, do not store both “membership” and “current approval nonce” inside a single opaque commitment if the contract must coordinate on those facts publicly.

Commitment design in practice

The contract does not need to know the entire private structure behind a commitment, only what later circuits will prove about it. For a treasury, the private preimage might include:

The public commitment can then be recomputed in a witness-aware circuit and checked against commitments[partyId]. That lets the member prove continuity of state without revealing the underlying secret.

The important architectural point is this: the map gives you dynamic membership; the commitment gives you privacy. You need both.

Letting multiple users join with findDeployedContract

The contract side defines the shared state machine, but the joining flow happens in client code. The bounty specifically asks to show how multiple users join a deployed contract via findDeployedContract. The exact helper name and call signature are SDK-version dependent, so here I will describe the stable pattern and call out the version-sensitive piece explicitly.

The client workflow for each participant is:

  1. obtain the deployed contract address or deployment handle
  2. load the generated contract metadata/driver
  3. attach a participant-specific witness context
  4. discover or attach to the deployed contract instance
  5. call join() or another membership circuit
  6. persist the participant’s private local state

In pseudocode:

// 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;
}

The point of this snippet is not the exact import path. The point is the shape of the interaction:

That is the essence of N-party interaction on Midnight.

Why findDeployedContract matters

A two-party tutorial can get away with “the deployer passes the instance directly to the other script.” Real applications cannot. Independent users need to discover and bind to the same deployment later, often in separate sessions and from separate machines.

Once you model that explicitly, several design consequences follow:

This is where the stateVersion field becomes essential.

Participant-specific witness contexts

Each user should have a witness context that contains only that user’s private data:

Do not assume that because two users are interacting with the same contract, they should share a single witness implementation. Midnight’s witness model explicitly warns you not to trust witness code. The safe design is:

That separation keeps your multi-party tests realistic.

Handling concurrent state updates

Concurrency is the part most likely to break a naive multi-party design.

Suppose Alice and Bob both read stateVersion = 7 and both construct valid proofs:

Without protection, you have a race: Bob’s proof may represent a transition from stale state.

The fix is standard and explicit: every mutating circuit should require the caller to present the version they observed, and the circuit should constrain that value to equal the current ledger version before making any state change. If the contract state changed meanwhile, the old proof must no longer verify.

A witness declaration for that can be as simple as:

witness ObservedVersion(): Uint<32>;

And a mutating circuit skeleton can follow this pattern:

export circuit approve(): [] {
  const observedVersion = ObservedVersion();

  // Constrain observedVersion == stateVersion.
  // Read and validate the caller's party state.
  // Update approval-related state.
  // Increment the caller's approvalNonce.
  // Increment stateVersion.
}

Again, the exact comparison and map-update syntax must come from the current reference and examples for your compiler version. The pattern itself is stable.

Public version vs per-party nonce

You usually want both:

These solve different problems.

stateVersion protects against interleaving:

approvalNonce protects against repetition:

A robust treasury flow increments stateVersion on every mutating action and increments the caller’s approvalNonce on every approval-related action.

Conflict handling in the client

In the client or integration tests, stale updates should not be treated as mysterious failures. They are expected under concurrency. The right recovery flow is:

  1. catch the stale-state failure
  2. refresh public contract state
  3. rebuild the witness context if needed
  4. regenerate the proof against the latest version
  5. retry if the action is still valid

That retry loop belongs in client code, not in Compact. The contract’s job is to reject stale transitions deterministically.

Why “last write wins” is wrong here

In an ordinary web app, you might accept eventual consistency or overwrite conflicts optimistically. Midnight contracts are different because every state transition is proved. A proof must be tied to a specific state snapshot. If you ignore that and let callers produce updates without an explicit version check, you make debugging and reasoning much harder.

Version-checked circuits give you a clean mental model:

That is the simplest concurrency story to explain, implement, and test.

Real scenario: a private multi-sig treasury

Let’s put the pieces together around a realistic treasury flow.

Public state

The treasury exposes only what all members need to coordinate:

This is enough to answer public coordination questions:

Private state

Each participant keeps private local state such as:

A participant proves consistency between their private state and the corresponding public commitment when approving, updating, or rotating secrets.

Suggested flow

1. Deployment

The deployer initializes:

export circuit initialize(t: Uint<32>): [] {
  threshold = t;
  memberCount = 0;
  stateVersion = 0;
}

2. Join

A participant attaches to the deployment with findDeployedContract, supplies a witness returning:

The join circuit:

3. Approve a proposal

A member submits an approval circuit that:

4. Execute once threshold is reached

The exact execution pattern depends on how you represent approval aggregation. There are several valid choices:

For a first repository, keep the execution path simple and make the tutorial focus on the N-party membership and concurrency mechanics, not on a complex proposal engine. The bounty’s core asks are multi-party private state and concurrent updates, not full DAO design.

Why this is a better tutorial example than a toy counter

A multi-sig treasury exercises all the required concepts naturally:

A staking pool could also work, but a treasury makes concurrency easier to explain because multiple members may approve around the same time.

Working examples and repository structure

The bounty requires a separate working code repository with the full contract, tests, and multi-party interaction examples. The tutorial article should therefore point readers to a repository structure that makes the multi-party flows obvious.

A practical layout is:

multi-party-treasury/
├─ contracts/
│  └─ treasury.compact
├─ client/
│  ├─ join.ts
│  ├─ approve.ts
│  └─ shared.ts
├─ tests/
│  ├─ treasury.initialize.test.ts
│  ├─ treasury.join.test.ts
│  ├─ treasury.concurrent-join.test.ts
│  ├─ treasury.approve.test.ts
│  └─ treasury.stale-proof.test.ts
├─ package.json
└─ README.md

Contract fragment to include in the repository

The following fragment is based only on syntax verified by the reference primer and is safe to use as a starting point:

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>>;

witness JoinRequest(): [Bytes<32>, Bytes<32>, Uint<32>];
witness ApprovalRequest(): [Bytes<32>, Bytes<32>, Uint<32>, Uint<32>];

export circuit initialize(t: Uint<32>): [] {
  threshold = t;
  memberCount = 0;
  stateVersion = 0;
}

To turn that into the repository’s final contract, verify and implement the exact current syntax for:

Those details must be taken from the current Midnight docs and examples for your compiler version, not guessed.

Test scenarios the repository should cover

At minimum, the tests should prove these behaviors:

  1. initialization works
  2. a third, fourth, and fifth participant can join
  3. multiple users can attach to one deployment
  4. concurrent stale update is rejected
  5. retry against refreshed state succeeds
  6. replay of an old approval fails

These tests are more important than adding extra features. For this bounty, proving the pattern is the value.

Pitfalls and common errors

1. Hard-coding party names into the ledger

If your contract has fields like aliceCommitment and bobCommitment, you have not solved the N-party problem. Use Map<Bytes<32>, ...> or an equivalent keyed structure.

2. Trusting witness implementations

The Compact docs are explicit: a DApp may provide any witness implementation it wants. Never assume the witness function on the client matches your own intended logic. Constrain witness outputs inside the circuit.

3. Forgetting stale-proof protection

A shared contract with multiple users will see concurrent reads. Without stateVersion checks, your behavior under contention becomes unclear and brittle.

4. Using only a global version and no per-party nonce

A global version protects shared-state freshness, but it does not prevent replay of a participant’s old action in every design. Track per-party nonces where approvals or updates can be replayed.

5. Overexposing state publicly

Do not put the entire approval record or user secret material into the ledger just because it is convenient. Use commitments and prove statements about private data instead.

6. Treating client discovery as an afterthought

The join flow should be part of your design from day one. If multiple independent users cannot reliably attach to the same deployment, your tutorial does not meet the bounty’s real requirement.

7. Publishing unverified Compact syntax

This bounty is unusually strict: non-compiling Compact code is disqualifying. Where the article uses exact Compact syntax, it should come from official documentation. Where an API or operator is version-sensitive and not confirmed, label it clearly and verify it in the repository before submission.

References

Bounty spec coverage


Where to go next

Thanks for reading this far. If “Multi-party private state on Midnight” connected with where you are, three concrete next steps:

Learn more in Midnight

The full Midnight ZK Cookbook index has 17 tutorials across Midnight, Aleo, Aztec, Noir, and risc0 plus 4 Chinese translations. Adjacent tutorials are listed by ecosystem on that page.

Find paid work in Midnight

Bounty Radar tracks open ZK bounties across Algora, GitHub labels, Drips Wave, Code4rena, and Bountycaster. Browse the Midnight sub-feed; JSON at /midnight.json. The free tier is poll-based; the $19/mo Hobbyist tier pushes one filter to your Telegram in real time.

Audit your own ZK pipeline

zk-pipeline-doctor is the free MIT-licensed CLI that scores any ZK project on tests, CI, docs, security, reproducibility, and language toolchain (supports Compact, Leo, Noir, Cairo, and 7 Rust zkVMs). Drop it into a GitHub Action with zk-doctor-action for diff-aware PR comments. The $15/mo Pro tier adds four cross-ecosystem deep detectors (circuit complexity, proving-system pitfalls, verifier soundness, multi-file consistency).


Drafted with AI assistance and reviewed by the author before publishing. See DISCLOSURE for the full process.

If this saved you time

Three ways to support this work, pick whichever matches your situation:

$15 · One-time
All 17 tutorials as one Markdown + companion code repos. Offline-readable.
Get the Bundle →
$19 / month
Real-time bounty alerts pushed to your Telegram, filtered by ecosystem.
Try Radar →
$99 · One-time
Pre-flight audit of your ZK repo. See sample.
Order Audit →

Free alternative: Sponsor on GitHub · Star the repo · Share with one ZK developer who'd benefit

Related projects