Part 3 of building with Midnight. Part 1 | Part 2
Part 2 ended with a provocation:
"The next level is understanding how private state interacts with transfers between users, which requires both parties to update their local private state atomically, coordinated by a proof that neither can fake."
That's not theoretical anymore. The midnight-escrow contract is a working implementation of exactly this problem, and it's a much richer teaching tool than any pseudocode example.
This article is a circuit-level autopsy of that contract. We're going to read the actual Compact code, understand why each line exists from a cryptographic coordination perspective, and extract the general pattern that applies to any private multi-party transfer on Midnight.
The Coordination Problem, Stated Precisely
Before touching code, let's be precise about what makes multi-party transfers hard on Midnight.
In Solidity you write:
balances[alice] -= amount;
balances[bob] += amount;
Both writes happen in one EVM transaction. Atomicity is free because the EVM's state is a single shared namespace: one machine, one write.
In Midnight, the state model is split:
Public ledger (P) → every node on the network, plaintext
Private state (S) → user's local machine, never on-chain
Alice's private balance lives on Alice's machine. Bob's lives on Bob's. There is no shared namespace for private values. And a ZK proof can only attest to a state transition for one private state at a time.
So the question becomes: how do you make two separate private state updates, on two different machines, behave atomically?
The escrow contract answers this with three interlocking mechanisms:
- A commitment scheme that binds the transfer terms cryptographically
- A derived identity system that ties circuit authorization to private keys without exposing them
- A state machine that enforces ordering and makes intermediate exploitation impossible
Let's walk through each.
The Ledger: What's Actually Public
export ledger buyer: Bytes<32>;
export ledger seller: Bytes<32>;
export ledger termsCommitment: Bytes<32>;
export ledger state: EscrowState;
export ledger round: Counter;
Five fields. That's the entire public surface of this contract.
Notice what's not here: no balance, no amount, no secret, no nonce. An observer watching the chain sees two 32-byte identifiers, a 32-byte blob, a state enum, and a counter.
buyer and seller are not wallet addresses: they are derived keys. We'll get to the derivation in a moment, but this is one of the most important design decisions in the contract.
termsCommitment is the cryptographic core. It's a commitment to the transfer terms: the amount and the release secret. The buyer posts it at creation; the seller must open it at release. Everything else in the protocol flows from this one field.
The Witnesses: The Private Side
export type EscrowPrivateState = {
secretKey: Uint8Array; // 32 bytes — identity key
releaseSecret: Uint8Array; // 32 bytes — pre-image of the commitment
nonce: Uint8Array; // 32 bytes — commitment randomness
amount: bigint; // escrow value
};
And the corresponding Compact witness declarations:
witness secretKey(): Bytes<32>;
witness releaseSecret(): Bytes<32>;
witness nonce(): Bytes<32>;
witness escrowAmount(): Uint<64>;
These four values never touch the chain. They live in EscrowPrivateState, which the wallet SDK manages locally. At proof generation time, the witness functions feed them into the circuit, the Compact compiler sees them as private inputs to the ZK proof.
The witness implementation is deliberately simple:
export const witnesses = {
secretKey(
context: WitnessContext<Ledger, EscrowPrivateState>
): [EscrowPrivateState, Uint8Array] {
const state = context.privateState;
return [state, state.secretKey];
},
// ... same pattern for releaseSecret, nonce, escrowAmount
};
Each witness function returns a tuple of [newPrivateState, witnessValue]. In this contract, private state is read-only; the returned state is always identical to the input. This makes sense: both parties hold fixed secrets for the entire duration of the escrow. Neither needs to update their local state between circuit calls.
Identity Without Exposing Keys: deriveKey
circuit deriveKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>(
[pad(32, "midnight:escrow:key"), sk]
);
}
This circuit is one of the most important pieces in the contract and consistently the least explained.
The buyer and seller ledger fields hold derived public keys, not raw addresses or standard ECDSA public keys. Concretely:
derived_key = persistentHash(["midnight:escrow:key", secretKey])
Why not store a standard public key and verify a signature? You could, but you'd need signature verification logic inside the circuit, which adds constraints. The derived key approach is more elegant: secretKey is a private witness, deriveKey(sk) computes a public identifier from it inside the proof, and the circuit asserts the computed value matches what's on the ledger. The ZK proof itself is the authorization, no separate signature scheme needed.
The domain separator "midnight:escrow:key" (padded to 32 bytes) is critical. Without it, the same secretKey used in two different Midnight contracts would produce the same derived key on-chain, creating a linkage across the ecosystem. The separator namespace-isolates key derivation to this contract.
When createEscrow runs:
const sk = secretKey(); // private witness — never leaves the proof
const pk = deriveKey(sk); // computed inside the circuit
buyer = disclose(pk); // only the derived key goes on-chain
The chain learns pk. It learns nothing about sk. But anyone who later calls a circuit with the same sk will produce the same pk, proving identity without revealing the key.
The Commitment Scheme: Binding Terms Without Revealing Them
export circuit createEscrow(
sellerPk: Bytes<32>,
amount: Uint<64>
): [] {
assert(state == EscrowState.EMPTY, "Escrow already exists");
const sk = secretKey();
const pk = deriveKey(sk);
const n = nonce();
const secret = releaseSecret();
const releaseHash = persistentHash<Bytes<32>>(secret);
const termsCommit = persistentCommit<Vector<2, Bytes<32>>>(
[amount as Bytes<32>, releaseHash],
n
);
buyer = disclose(pk);
seller = disclose(sellerPk);
termsCommitment = disclose(termsCommit);
state = EscrowState.FUNDED;
}
Let's trace what gets committed and why each decision was made.
Step 1: hash the release secret first
const releaseHash = persistentHash<Bytes<32>>(secret);
The commitment will contain hash(secret), not secret itself. This is a double layer of hiding: even if someone could theoretically invert persistentCommit, they'd only recover the hash, not the original pre-image.
Step 2: commit to [amount, releaseHash] with the nonce
const termsCommit = persistentCommit<Vector<2, Bytes<32>>>(
[amount as Bytes<32>, releaseHash],
n
);
persistentCommit is binding and hiding. The nonce n ensures two escrows with the same amount and the same secret produce different termsCommitment values on-chain, preventing correlation. Reuse the nonce and two identical commitments would appear on-chain, allowing an observer to link them.
The persistent prefix matters. The Compact docs distinguish: transientCommit outputs are suitable only for within-proof use; persistentCommit outputs are designed for storage in ledger state. Use transientCommit here and the commitment can't be reliably verified later. persistentCommit is the correct choice for anything that goes into export ledger.
Step 3: disclose only the derived values
buyer = disclose(pk);
seller = disclose(sellerPk);
termsCommitment = disclose(termsCommit);
Three disclose() calls. As established in Part 2, each is a deliberate declaration to the compiler: "I know this witness-derived value is going public." The compiler rejects this circuit without them. And each disclosure is intentional; the buyer's identity, the seller's identity, and the commitment all need to be on-chain for the protocol to function.
What's not disclosed: secretKey, releaseSecret, nonce, amount. The chain sees the commitment but cannot reverse it.
The Verification: How the Seller Proves Knowledge of the Secret
This is the cryptographic heart of the contract:
export circuit release(): [] {
assert(state == EscrowState.FUNDED, "Invalid state");
const sk = secretKey();
const pk = deriveKey(sk);
assert(pk == seller, "Only seller can release");
const secret = releaseSecret();
const n = nonce();
const amt = escrowAmount();
const secretHash = persistentHash<Bytes<32>>(secret);
const recomputed = persistentCommit<Vector<2, Bytes<32>>>(
[amt as Bytes<32>, secretHash],
n
);
assert(
recomputed == termsCommitment,
"Invalid release proof"
);
state = EscrowState.RELEASED;
}
Two assertions. Two completely different things being verified.
Assertion 1: Identity
assert(pk == seller, "Only seller can release");
The seller proves they hold the secretKey that produced the seller field at creation. Without the correct sk, deriveKey(sk) produces the wrong hash and the assertion fails. No one else can generate a valid proof for this circuit.
Assertion 2: Knowledge of the secret
assert(recomputed == termsCommitment, "Invalid release proof");
The seller recomputes the commitment from scratch using their private releaseSecret, nonce, and escrowAmount. If any of these don't match what the buyer committed to in createEscrow, recomputed != termsCommitment and the proof fails.
This is the binding. The buyer chose (amount, releaseSecret, nonce) and committed to them. The seller must produce the exact same triple. The only way a seller could have the correct (releaseSecret, nonce) is if the buyer shared them, which is the off-chain handshake step in the protocol. The ZK proof doesn't replace that handshake. It verifies that the handshake happened correctly, without the chain ever seeing what was exchanged.
None of these values appear in the public output of the proof. The chain verifies the ZK proof, updates state to RELEASED, and that's it.
The Full Protocol Flow
Here's how the three circuits sequence for a complete transfer:
Bob (Buyer) Chain Alice (Seller)
│ │ │
│── createEscrow(alicePk, amt) ──▶│ │
│ Proof asserts: │ Ledger after: │
│ - deriveKey(bobSk) == buyer │ buyer = H(bobSk) │
│ - commit([amt,H(secret)],n) │ seller = alicePk │
│ │ terms = commit(...) │
│ │ state = FUNDED │
│ │ │
│◀── share(nonce, secret) off-chain ─────────────────────────────▶│
│ │ │
│ │◀── acceptEscrow() ─────────────│
│ │ Proof: deriveKey(aliceSk) │
│ │ == seller │
│ │ │
│ │◀── release() ──────────────────│
│ │ Proof: │
│ │ - deriveKey(aliceSk)==seller│
│ │ - recomputed==termsCommit │
│ │ state = RELEASED │
The off-chain share(nonce, secret) step is the only coordination that happens outside the chain. Bob hands Alice these two values; the CLI prints them, they copy-paste. The chain never sees this handoff. The ZK proof in release() is what cryptographically verifies Alice received the right values and used them correctly.
acceptEscrow: The Lightweight Identity Check
export circuit acceptEscrow(): [] {
assert(state == EscrowState.FUNDED, "Escrow not funded");
const sk = secretKey();
const pk = deriveKey(sk);
assert(pk == seller, "Only seller can accept");
}
This circuit does one thing: proves Alice is the intended seller. No state change beyond the proof itself.
Why does this exist separately from release? Two reasons.
Confirmation before commitment. Before Alice calls release, she verifies she's looking at the right escrow; the one Bob created for her specifically. acceptEscrow lets her confirm seller == deriveKey(aliceSk) before she's asked to provide releaseSecret and nonce.
Extension point. In a richer system, a swap scenario, for example - acceptEscrow might trigger a state change (FUNDED → ACCEPTED) that signals Bob to release his side of an asset exchange. Keeping it as a separate circuit preserves that hook without changing the release logic.
refund: The Escape Hatch
export circuit refund(): [] {
assert(state == EscrowState.FUNDED, "Invalid state");
const sk = secretKey();
const pk = deriveKey(sk);
assert(pk == buyer, "Only buyer can refund");
state = EscrowState.REFUNDED;
}
No timeout logic. No round check. Bob can call refund at any time while the escrow is FUNDED.
This is a deliberate design choice for the current implementation, and one the repo explicitly flags as a future improvement. In production you'd gate refund on a round counter so Alice has a guaranteed window to call release. The round: Counter ledger field exists precisely for this; it just isn't wired to refund yet.
But even without the timeout, the liveness guarantee is clear: if Alice disappears, Bob isn't stuck. The state machine ensures refund and release are mutually exclusive, once either fires, state is no longer FUNDED and the other becomes impossible at the circuit level.
State Machine: Why Ordering Is a Security Property
enum EscrowState {
EMPTY,
FUNDED,
RELEASED,
REFUNDED
}
Every circuit opens with an assert on state. This isn't defensive programming, it's the atomicity guarantee.
EMPTY ──createEscrow──▶ FUNDED ──release──▶ RELEASED
└──refund───▶ REFUNDED
Once state = EscrowState.RELEASED, the escrow is frozen. release requires FUNDED. refund requires FUNDED. createEscrow requires EMPTY. There is no path back, no double-release, no re-creation over an existing escrow.
Consider what happens without these assertions: the buyer could call createEscrow twice, overwriting termsCommitment with a different nonce mid-flight. The seller could call release twice. The state machine makes all of these impossible; not as application-layer logic, but as ZK-verified constraints inside the proof. The chain will not accept a proof that fails a circuit assertion.
This is the Midnight equivalent of reentrancy protection, enforced by the proof system, not by a mutex.
The Privacy Accounting: What Leaks and What Doesn't
Being precise about what an on-chain observer actually learns:
| Information | Buyer knows | Seller knows | Chain sees | Observer learns |
|---|---|---|---|---|
| Transfer amount | ✓ | ✓ (after sharing) | ✗ | ✗ |
| Release secret | ✓ | ✓ (after sharing) | ✗ | ✗ |
| Nonce | ✓ | ✓ (after sharing) | ✗ | ✗ |
| Buyer identity | ✓ | ✓ | H(buyerSk) |
Pseudonym only |
| Seller identity | ✓ | ✓ |
sellerPk as passed |
Pseudonym only |
| That a transfer occurred | ✓ | ✓ | ✓ | ✓ |
| Transfer direction (who→who) | ✓ | ✓ | ✓ | ✓ |
buyer and seller are on-chain; direction is visible. This is a deliberate tradeoff: escrow semantically requires knowing who holds which role. A symmetric private swap would need a different design where both parties are represented symmetrically.
The termsCommitment field is worth a specific note: it's 32 bytes on-chain, visible to everyone. But it reveals nothing about the underlying values. Two escrows with identical amounts and secrets but different nonces produce completely different commitments, the nonce is what ensures this, and it never leaves the buyer's private state.
The General Pattern: What Escrow Teaches Us
Strip the escrow semantics away and what remains is a reusable coordination primitive for any private multi-party transfer on Midnight:
1. Commit to transfer terms at initiation
termsCommitment = disclose(persistentCommit([value, hash(secret)], nonce));
2. Bind identities to private keys via derivation
const pk = deriveKey(secretKey());
assert(pk == onChainIdentity, "Auth failed");
3. Verify the commitment at completion
const recomputed = persistentCommit([value, hash(secret)], nonce);
assert(recomputed == termsCommitment, "Proof failed");
4. Enforce ordering via state machine
assert(state == ExpectedState, "Wrong state");
// ... logic ...
state = NextState;
Private DEX swaps, confidential lending, sealed-bid auctions; all of them are variations on this structure. They need to bind two parties to agreed terms, verify those terms without revealing them, and enforce who moves first. The escrow contract is the cleanest working example of how Midnight's primitives compose to solve it.
What's Next
The repo explicitly lists its open extensions: timeout-based refunds, multi-party agreements, private dispute resolution. Each is a direct extension of the patterns here. The commitment scheme generalises to more parties by including more values in the committed vector. The state machine grows more states. The identity checks stay identical, deriveKey(secretKey()) works regardless of how many participants are involved.
Part 1: Building a Bonding Curve Token on Midnight
Part 2: You're Probably Using export ledger Wrong
Full source: github.com/sevryn-labs/midnight-escrow
Top comments (0)