DEV Community

Cover image for How Midnight Coordinates Two-Party Transfers
Tushar Pamnani
Tushar Pamnani

Posted on

How Midnight Coordinates Two-Party Transfers

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. A commitment scheme that binds the transfer terms cryptographically
  2. A derived identity system that ties circuit authorization to private keys without exposing them
  3. 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;
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

And the corresponding Compact witness declarations:

witness secretKey(): Bytes<32>;
witness releaseSecret(): Bytes<32>;
witness nonce(): Bytes<32>;
witness escrowAmount(): Uint<64>;
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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]
  );
}
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Two assertions. Two completely different things being verified.

Assertion 1: Identity

assert(pk == seller, "Only seller can release");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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              │
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

2. Bind identities to private keys via derivation

const pk = deriveKey(secretKey());
assert(pk == onChainIdentity, "Auth failed");
Enter fullscreen mode Exit fullscreen mode

3. Verify the commitment at completion

const recomputed = persistentCommit([value, hash(secret)], nonce);
assert(recomputed == termsCommitment, "Proof failed");
Enter fullscreen mode Exit fullscreen mode

4. Enforce ordering via state machine

assert(state == ExpectedState, "Wrong state");
// ... logic ...
state = NextState;
Enter fullscreen mode Exit fullscreen mode

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)