Multi-Party Private Contracts on Midnight: From Two to N Users
Every Midnight tutorial starts with two parties. Alice deploys. Bob joins. They exchange private state. Fade to black.
The real world has more than two parties. A staking pool has fifty depositors. A multi-sig treasury has five signers. A private auction has dozens of bidders. When you try to extend the two-party examples in the official docs to handle three or more participants, the scaffolding starts showing its seams.
This article is about what actually changes when you go from two to N. By the end, you will know how to bootstrap multi-party flows by sharing contract addresses, how to model access control for N parties in both Compact and TypeScript, and how to handle the UTXO concurrency problems that only show up when multiple users are transacting against the same contract at the same time.
How Contract Joining Works
Midnight contracts do not have a built-in member registry. There is no joinContract() primitive. What the SDK gives you is findDeployedContract(), which reconnects you to an already-deployed contract from a known address.
The distinction between deploying and joining is entirely on the client side. Both operations produce the same thing: a TypeScript contract object bound to a specific on-chain address. The only difference is who created it.
import { Contract } from './contract/index.cjs';
import type { TreasuryPrivateState } from './contract/index.cjs';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { proofProvider } from '@midnight-ntwrk/midnight-js-proof-provider';
import { WalletBuilder } from '@midnight-ntwrk/wallet';
// Party A: deploys the contract and gets back an address
const deployResult = await Contract.deploy(providers, {
privateState: initialPrivateState,
args: [],
});
const contractAddress = deployResult.deployTxData.public.contractAddress;
console.log('Share this address with other parties:', contractAddress);
// Party B, C, D: joins using a known address
const memberContract = await Contract.findDeployedContract(providers, {
contractAddress, // received off-chain from Party A
privateStateKey: 'treasury',
initialPrivateState: emptyPrivateState(),
});
There is nothing special about the deployer. Once the contract is on-chain, all parties have equal access to it through findDeployedContract. Access control is a matter of what your circuit logic allows each caller to do — not of who deployed the contract.
Bootstrapping the Multi-Party Flow
The first problem you hit in production is: how does Party B get the contract address?
The Midnight SDK does not answer this question. Address distribution is off-chain by design. Your options in practice:
A centralized relay. An API endpoint or database that the deployer posts the address to after deployment, and that other parties read from. Simple, fast, and fine for most applications where a trusted coordinator exists.
A shared channel. For decentralized applications, parties exchange the address through a communication channel outside Midnight — a Signal group, a shared spreadsheet, an email, a Discord DM. Low-tech but effective when the set of parties is known in advance.
A discovery registry. A second Midnight contract (or a public chain registry) that maps some human-readable label to a contract address. The deployer calls the registry contract after deploying the main contract; other parties read the registry first to find the address. This adds complexity but removes any trusted coordinator.
For a private staking pool with five known signers, a centralized relay managed by the deployer is almost always the right call. Do not build a discovery registry to avoid solving a communication problem you have already solved.
Access Control for N Parties
The two-party pattern usually hardcodes two public keys in the contract state at deploy time. For N parties, you need a runtime-flexible membership model.
The canonical approach in Compact is a Map from public key to membership status:
// Simplified Compact — illustrative, not production
ledger {
members: Map<Bytes<32>, MemberStatus>,
signerCount: Counter,
requiredSignatures: Uint<64>,
proposals: Map<Bytes<32>, Proposal>,
signatures: Map<Bytes<32>, Map<Bytes<32>, Boolean>>,
}
struct Proposal {
recipient: Bytes<32>,
amount: Uint<64>,
approved: Boolean,
}
enum MemberStatus {
Active,
Removed,
}
The deployer initializes the members map at deploy time by passing in the founding keys:
// Initialization circuit (called once at deploy)
circuit initialize(
founder1: Bytes<32>,
founder2: Bytes<32>,
founder3: Bytes<32>,
required: Uint<64>
): [] {
members.insert(founder1, MemberStatus.Active);
members.insert(founder2, MemberStatus.Active);
members.insert(founder3, MemberStatus.Active);
signerCount.increment(3 as Uint<64>);
requiredSignatures.value = required;
}
Each circuit that should be member-gated reads own_public_key() and checks the map:
// Access gate used in circuits that require membership
circuit requireMember(): [] {
const caller = own_public_key();
const status = disclose(members.lookup(caller));
assert status == MemberStatus.Active : "not a member";
}
The disclose() call is important here. It pulls a private map value into the public proof context so the ZK circuit can branch on it. Without disclose(), your circuit cannot use the map value in a conditional.
The Multi-Sig Treasury Pattern
A concrete scenario: five signers jointly control a treasury. Any three signers must approve before funds can be disbursed. Here is what the TypeScript looks like.
Setup
import { Contract } from './contract/index.cjs';
import { providers } from './providers';
// Deployer sets up with known signer keys and threshold
const contract = await Contract.deploy(providers, {
privateState: { localKey: deployerSeed },
args: [
hexToBytes(signer1PublicKey),
hexToBytes(signer2PublicKey),
hexToBytes(signer3PublicKey),
hexToBytes(signer4PublicKey),
hexToBytes(signer5PublicKey),
3n, // 3-of-5
],
});
const { contractAddress } = contract.deployTxData.public;
// Share contractAddress with all five signers
Submitting a Proposal
Any member can submit a proposal. The circuit verifies membership and creates the proposal entry:
// Any of the 5 signers can call this
const proposeTx = await memberContract.callTx.proposeTransfer(
hexToBytes(recipientPublicKey),
500_000_000n, // 5 NIGHT in base units
);
await proposeTx.wait();
const proposalId = proposeTx.public.proposalId;
Signing
Each signer independently calls the sign circuit. The contract records each signature in a nested Map<proposalId, Map<signerKey, Boolean>>:
// Each signer does this independently — no coordination needed
const signTx = await memberContract.callTx.signProposal(proposalId);
await signTx.wait();
Execution
Once enough signatures accumulate, any member can trigger execution:
// Check if threshold is met before calling
const state = await memberContract.queryContractState();
const sigCount = state.signatureCounts.get(proposalId) ?? 0n;
const required = state.requiredSignatures;
if (sigCount >= required) {
const execTx = await memberContract.callTx.executeProposal(proposalId);
await execTx.wait();
}
UTXO Race Conditions
Here is the concurrency problem that does not show up in two-party examples but bites you immediately at N parties: multiple signers submitting transactions against the same contract state at the same time.
Midnight's shielded token operations are UTXO-based. Each sendShielded or receiveShielded call consumes a specific UTXO and produces a new one. If two transactions race to spend the same UTXO, only one will succeed. The other will fail with a state conflict error.
For pure ledger-state operations (like map writes), the conflict manifests differently: two transactions built against the same state snapshot will produce incompatible transitions, and one will be rejected during block inclusion.
The failure mode is silent if you are not watching for it:
// This will fail ~50% of the time under concurrent load
try {
const tx = await contract.callTx.signProposal(proposalId);
await tx.wait();
} catch (e) {
// TransactionRejected or StateConflict — not always obvious from the error message
console.error('Transaction failed:', e.message);
}
The Retry Pattern
The correct response to a state conflict is to rebuild the transaction against the latest state and try again. The SDK gives you a fresh view of the contract state each time you call a circuit — the transaction is built against current state at call time, not at some earlier point.
async function signWithRetry(
contract: BoundContract,
proposalId: Uint8Array,
maxAttempts = 5,
): Promise<void> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const tx = await contract.callTx.signProposal(proposalId);
await tx.wait();
return; // success
} catch (e) {
const isConflict =
e.message?.includes('StateConflict') ||
e.message?.includes('TransactionRejected') ||
e.message?.includes('stale');
if (!isConflict || attempt === maxAttempts) throw e;
// Exponential backoff with jitter
const delay = Math.pow(2, attempt) * 100 + Math.random() * 200;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
The key insight: retrying is safe because your circuit logic is idempotent by design. Signing a proposal twice with the same key should either be a no-op (if the contract checks for duplicate signatures) or fail with a business logic error — not silently double-count.
Designing for Concurrency
A few patterns that reduce conflict rates in high-traffic multi-party contracts:
Partition writes by party. If each party writes to its own slot in a map (keyed by their public key), writes from different parties never conflict. The conflict only happens when two parties try to update shared state simultaneously.
Separate the proposal and signature phases. A two-phase design where proposals are written in one transaction and signatures in another reduces contention on the proposal record. Each signer writes only to their own signature slot.
Use optimistic execution with mandatory re-read. Before calling any circuit that modifies shared state, fetch the current contract state and validate that the preconditions still hold. If the state has changed since you last read it, re-evaluate before submitting.
async function signIfNotAlreadySigned(
contract: BoundContract,
proposalId: Uint8Array,
): Promise<void> {
// Re-read state before transacting
const state = await contract.queryContractState();
const callerKey = await getCallerPublicKey();
const alreadySigned = state.signatures
.get(bytesToHex(proposalId))
?.get(bytesToHex(callerKey));
if (alreadySigned) {
console.log('Already signed — no transaction needed.');
return;
}
await signWithRetry(contract, proposalId);
}
Watching for Multi-Party State Changes
In a two-party flow, you usually have a direct communication channel and can tell the other party when something changes. With N parties, you need contract state subscriptions.
The Midnight indexer exposes a WebSocket subscription for contract state changes. In TypeScript, you can watch for updates using the observable pattern in the SDK:
import { filter, distinctUntilKeyChanged } from 'rxjs';
// Subscribe to contract state changes
const stateSubscription = contract
.state()
.pipe(
filter((s) => s !== null),
distinctUntilKeyChanged('latestBlockHeight'),
)
.subscribe((state) => {
const sigCount = state.signatureCounts.get(proposalId) ?? 0n;
console.log(`Proposal ${bytesToHex(proposalId)}: ${sigCount} signatures`);
if (sigCount >= state.requiredSignatures) {
console.log('Threshold reached — ready to execute');
}
});
// Later, when done
stateSubscription.unsubscribe();
This works because contract.state() in the Midnight SDK is an RxJS observable backed by the indexer's WebSocket feed. Each time the contract's public state updates on-chain, the observable emits a new value.
Private vs. Shared State in Multi-Party Contracts
One thing the two-party examples do not make obvious: in Midnight, each party has their own private state, and the contract has shared public state. These are separate things.
The ledger block in a Compact contract holds state visible to the ZK circuit prover — it is technically private in the sense that individual map entries are not trivially readable by observers without the key, but the structure is on-chain. Your contract's Map<Bytes<32>, MemberStatus> is part of the on-chain state commitment.
Each party's local private state (the TypeScript object you pass as privateState when deploying or joining) is never on-chain. It exists only in the party's local environment. This matters for multi-party flows because:
- If a party loses their local private state, they lose the ability to participate in circuits that require it as a witness.
- There is no way to recover another party's private state from the on-chain record — by design.
- Private state is not synchronized between parties. Each party manages their own.
For a multi-sig treasury where the "private state" is just the signing key, the recovery path is to re-derive the key from a seed phrase. For applications where private state carries richer information (accumulated commitments, encrypted notes), make sure your application has an explicit backup and recovery story before onboarding users.
Closing
The jump from two parties to N is less about Midnight and more about system design. The SDK gives you the tools — findDeployedContract, Map-based access control, observable contract state — and the hard work is deciding how parties discover each other, how you handle concurrent writes, and how you protect against data loss when local private state is the only record.
The patterns here — off-chain address sharing, keyed maps for N-party access control, retry with backoff for UTXO conflicts, optimistic re-read before write — compose naturally with each other. You do not need all of them for every contract. Start with the simplest version that works for your specific use case.
If you ran into a multi-party pattern not covered here, or found a better way to handle UTXO race conditions at scale, head over to issue #303 in the contributor-hub — that is where this article's discussion lives.
Building on Midnight? I also maintain a 200-prompt developer pack for ChatGPT — code review, debugging, documentation, architecture decisions. $19 instant download.
Top comments (0)