A privacy-preserving escrow contract built on the Midnight Network. A buyer deposits funds which are released to a seller only when the seller proves knowledge of a secret agreed upon at escrow creation — verified entirely by a Zero-Knowledge Proof, with no trusted intermediary.
The project serves as a reference implementation for developers building privacy-preserving financial primitives on Midnight.
Traditional escrow systems require a trusted third party. This implementation replaces that trust with cryptography.
The contract enforces the following rules in ZK:
- Only the key derived from the buyer's secret key can create or refund an escrow
- Only the key derived from the seller's secret key can accept or release an escrow
- Release is only permitted when the seller can prove they hold the correct
(releaseSecret, nonce, amount)triple that matches the commitment stored on-chain at creation time
No secret values are ever written to the public ledger. All verification happens inside ZK circuits compiled from the Compact contract.
The demo shows:
- Contract deployment on Midnight Preprod
- Escrow creation by buyer (Bob)
- Escrow acceptance by seller (Alice)
- Secret-based conditional release
- Funds claimed by Alice
At creation the buyer commits to persistentCommit([amount_bytes || hash(releaseSecret)], nonce). At release the seller must reproduce this commitment exactly, proving they have the correct secret and nonce — without revealing either value on the ledger.
Participant identities (buyer, seller ledger fields) are derived public keys: persistentHash(["midnight:escrow:key", secretKey]). Raw secret keys are never disclosed on-chain.
The contract enforces a strict four-state machine:
EMPTY → FUNDED → RELEASED
→ REFUNDED
Every circuit asserts the current state before acting, preventing double-spend and invalid transitions.
A full-featured CLI supports all escrow operations with an interactive menu:
- Deploy new escrow contract
- Join existing escrow contract by address
- Create escrow (buyer)
- Accept escrow (seller)
- Release funds with secret proof (seller)
- Refund (buyer)
- Monitor DUST balance
- Show escrow identity (encryption public key)
Each CLI session uses a randomly suffixed LevelDB store name (escrow-private-state-<random>), preventing decryption failures when switching wallet seeds between runs.
.
├── contract/ # Midnight Compact smart contract
│ ├── src/
│ │ ├── escrow.compact # Contract logic (circuits, state machine)
│ │ ├── witnesses.ts # ZK witness definitions (EscrowPrivateState)
│ │ ├── index.ts # Package entry point
│ │ └── test/
│ │ ├── escrow-simulator.ts # In-process contract simulator
│ │ └── escrow.test.ts # 50 unit tests (Vitest)
│ │ └── managed/escrow/ # Compact compiler output (generated)
│ ├── package.json
│ └── tsconfig.json
│
├── counter-cli/ # CLI client
│ ├── src/
│ │ ├── api.ts # Wallet, provider setup, contract calls
│ │ ├── cli.ts # Interactive menu and user flows
│ │ ├── config.ts # Network configs (Preprod, Preview, Standalone)
│ │ ├── common-types.ts # Shared TypeScript types
│ │ ├── logger-utils.ts # Pino logger (file + pretty stdout)
│ │ ├── preprod.ts # Preprod entry point
│ │ ├── preprod-start-proof-server.ts
│ │ ├── preview.ts # Preview network entry point
│ │ ├── preview-start-proof-server.ts
│ │ └── standalone.ts # Standalone / local entry point
│ ├── proof-server.yml # Docker Compose: proof server only
│ ├── standalone.yml # Docker Compose: full local stack
│ └── package.json
│
├── package.json # npm workspace root
├── package-lock.json
└── README.md
Written in Midnight's Compact language. Defines:
- Ledger state:
buyer,seller(derived keys),termsCommitment,state(enum),round(counter) - Witnesses:
secretKey,releaseSecret,nonce,escrowAmount(supplied privately per circuit call) - Circuits:
createEscrow,acceptEscrow,release,refund,getReleaseHash
Defines EscrowPrivateState:
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
}Witness functions feed private state into ZK circuits at proof time.
api.ts— builds the wallet (HD keys → shielded + unshielded + dust sub-wallets), configures providers, and wraps each contract circuit callcli.ts— interactive readline menuconfig.ts—PreprodConfig,PreviewConfig,StandaloneConfigfor different networks
A local Docker service (midnightntwrk/proof-server:7.0.0) that generates ZK proofs. Runs at http://127.0.0.1:6300.
Verifies ZK proofs and stores contract state on-chain. The CLI connects via indexer GraphQL and an RPC node.
createEscrow(sellerPk, amount)
[EMPTY] ──────────────────────────────────► [FUNDED]
│
┌───────────────────┴───────────────────┐
│ │
release() │ refund() │
(seller + ZK proof) (buyer only) │
▼ ▼
[RELEASED] [REFUNDED]
All transitions assert the current state. Invalid transitions (e.g. release on REFUNDED, double-release) throw a circuit assertion error.
On createEscrow, the buyer stores:
termsCommitment = persistentCommit([amount_as_bytes32 || hash(releaseSecret)], nonce)
On release, the seller recomputes:
recomputed = persistentCommit([amount_as_bytes32 || hash(releaseSecret)], nonce)
assert(recomputed == termsCommitment)
This is fully verified inside the ZK circuit; the seller's releaseSecret and nonce are private witness inputs that never appear on the public ledger.
- Node.js (v18+)
- Docker (for the Proof Server)
- Access to the Midnight Preprod faucet
⚠️ Important: Each CLI run creates a fresh isolated LevelDB private state store. Run only one CLI session at a time to avoid port/resource conflicts.
git clone https://github.com/tusharpamnani/midnight-escrow.git
cd midnight-escrow
npm installcd contract
npm run compactThis generates contract/src/managed/escrow/ from escrow.compact.
cd contract && npm run build
cd ../counter-cli && npm run buildcd contract
npm run testExpected output: 50 tests passing across 6 test groups.
cd counter-cli
docker compose -f proof-server.yml upWait for:
Actix runtime found; starting in Actix runtime
Keep this terminal running throughout the session.
cd counter-cli
npm run preprodThe CLI will prompt you to create or restore a wallet, then show the interactive escrow menu.
Alternative networks:
npm run preview # Midnight Preview network npm run standalone # Local Docker stack (requires standalone.yml)
⚠️ For testing only. Never use these seeds on mainnet.
seed: 57bb166cb6bbf3a6cb5e93a26043e3e2d3c830b63b85286fe97619456a2a23f2
Role: deploys the contract, provides escrow identity, accepts and releases funds.
seed: 2b477c42d95b5eb49222b25f9e5267c44cb15bef9646f086248bff24f43e727f
Role: funds the escrow, defines the release secret.
test release secret: 4f8c2a9d7b1e3c5a8d6f2e9a1c4b7d8e5f3a9c2d6b1e4f8a7c9d2e5b6a1f3c4a
npm run preprod
- Choose [2] Restore wallet from seed → enter Alice's seed
- Wait for wallet sync and DUST generation
- Choose [1] Deploy new escrow contract — copy the printed contract address
- Choose [6] Show My Escrow Identity — copy the printed encryption public key
- Exit with [7] Disconnect
npm run preprod
- Choose [2] Restore wallet from seed → enter Bob's seed
- Wait for wallet sync and DUST generation
- Choose [2] Join existing escrow contract → paste Alice's contract address
- Choose [1] Create Escrow (Buyer):
- Paste Alice's escrow public key (encryption key from Phase 1)
- Enter an amount
- Enter the test release secret (64 hex chars)
- The CLI prints a NONCE — share both the nonce and secret with Alice
- Exit with [7] Disconnect
npm run preprod
- Choose [2] Restore wallet from seed → enter Alice's seed
- Choose [2] Join existing escrow contract → paste the contract address
- Choose [2] Accept Escrow (Seller) — verifies Alice is the designated seller
- Choose [3] Release Funds (Seller):
- Enter the amount, secret, and nonce Bob provided
- The ZK proof is generated and submitted — funds are released to Alice
If Alice never accepts, Bob can reclaim the escrow:
[4] Refund
The contract has a comprehensive Vitest test suite with 50 unit tests covering all contract behaviour without requiring a network or proof server.
cd contract && npm run test| Group | Tests | Coverage |
|---|---|---|
| deployment | 4 | Initial state, field defaults, determinism |
| escrow lifecycle | 8 | All state transitions, ledger field correctness, full end-to-end |
| access control | 6 | Buyer/seller/third-party role enforcement |
| secret & nonce validation | 6 | Wrong secret, wrong nonce, wrong amount, getReleaseHash |
| invalid operations | 10 | Double-release, double-refund, cross-state calls, re-creation |
| edge cases | 8 | Zero amount, u64 max, commitment uniqueness, key derivation |
| multi-actor simulation | 8 | Full Alice & Bob protocol, impersonation, Charlie takeover attempts |
This means the local LevelDB store was encrypted with a different wallet seed. This is handled automatically — each CLI run uses a fresh store. If you still see this error, delete any midnight-level-db or midnight-db-* directories in counter-cli/ and restart.
Ensure the previous CLI session has fully exited before starting a new one.
Bob used Alice's wallet address instead of her escrow identity key. Use option [6] Show My Escrow Identity in Alice's session to get the correct encryption public key.
The secret, nonce, or amount does not match the commitment stored at creation. Verify Bob shared the correct values.
Ensure docker compose -f proof-server.yml up is running and you see the Actix runtime found message before starting the CLI.
DUST is generated from tNight (unshielded) tokens. Fund the wallet from the Midnight Preprod faucet and wait for DUST generation to complete before interacting with contracts.
Possible extensions include:
- timeout-based escrow refunds
- multi-party escrow agreements
- private dispute resolution
- integration with privacy-preserving DeFi primitives
- confidential liquidity pools and AMMs
