A confidential decentralized exchange built on Midnight.
Orders are committed on-chain, matched off-chain, and settled on-chain — without ever revealing asset, amount, side, price, or owner identity to a public observer.
Zekura is a continuous limit-order exchange where the chain never learns what is being traded, at what price, or by whom — and the one off-chain party that must see order contents to match them is cryptographically prevented from acting as anyone's owner.
Public order books leak information by construction: price, size, side, and counterparty are visible to anyone watching the chain, which is exactly the data a trader least wants exposed before a fill. Zekura removes that leak at the protocol level rather than papering over it in the UI.
What it is. Zekura is a three-tier system — a Compact smart contract, an off-chain matching engine ("the Matcher"), and a trading UI — that implements a continuous, multi-party, multi-order limit-order exchange on Midnight, a data-protection blockchain that lets applications prove facts about data with zero-knowledge proofs instead of publishing the data itself.
Why it exists. Midnight's own example contracts demonstrate commitment-then-reveal privacy for single-round mechanisms — a sealed-bid auction, a bulletin board, a loan eligibility check. None of them show a continuously operating market: orders created, cancelled, or matched at any time, against any number of counterparties, with replay protection and a persistent per-order state machine. Zekura exists to answer that specific question: can a real limit-order exchange be built this way, and can the off-chain matcher — the one party that legitimately needs to see order contents to do its job — be structurally prevented from abusing that knowledge? See "Product mapping" below for the full comparison against Midnight's official examples.
What problem it solves. On a transparent order book, anyone can watch a large order arrive and front-run it, correlate wallets by trading pattern, or infer a firm's strategy from its resting orders. Zekura's on-chain footprint is a commitment (a hash) and a lifecycle state — nothing else. The order's real contents exist only in the owner's wallet and, for the single instant matching requires it, in the Matcher's off-chain database.
How it differs from a traditional DEX. A traditional on-chain order book
is the public order book — the contract stores price, size, and side
directly, because the contract itself is what matches orders. Zekura inverts
this: exchange.compact is a public commitment registry, never an order
book. It never sees, stores, or matches order contents. The confidential
order book lives entirely off-chain with the Matcher, which matches crossing
orders in memory and submits settle() transactions that only prove a
valid match occurred, without revealing why. The result is observable, not
just claimed: the Trade page's "Verify privacy on-chain" panel fetches an
order's actual live ledger record and shows it side by side with the order's
real private fields, so the public/private split is something you can click
and confirm.
Zekura is a full production candidate, not a demo: 300 automated tests
across all three tiers, an independent security audit with one P0 finding
found and fixed (see AUDIT.md), and live deployments
exercised end to end — including real settlement, real Treasury-backed
protocol fills, and rejected attacks — on two public Midnight networks (see
Deployment.md).
Zekura does not map cleanly onto a single official Midnight example contract — it composes patterns from several of them into one system:
| Pattern | Where Zekura uses it | Closest official reference |
|---|---|---|
| Commitment-then-reveal privacy | createOrder commits; contents are only ever disclosed off-chain |
Private Reserve Auction |
| DApp-specific pseudonymous identity (never a real wallet key) | deriveOwnerId(secretKey) |
Private Reserve Auction's getDappPubKey; also the pattern Midnight's own zk-loan/bboard tutorials use |
| Witness-verified reveal against a stored commitment | cancelOrder/settle re-derive persistentCommit and assert equality |
ZK Loan DApp, Bulletin board DApp |
| Off-chain matching engine + on-chain settlement split | matcher/ vs. exchange.compact |
No single official example — closest ecosystem analogues are orderbook-DEX submissions in the Finance & DeFi category |
If a single closest official product proposal is required, Sealed-Bid Auction is the right anchor to name — Zekura's authorization and commitment-hiding design is a direct extension of that pattern's techniques. But Zekura's actual mechanism (continuous limit-order matching across an arbitrary number of open orders, rather than one sealed-bid round with one reveal) exceeds what that example demonstrates. It is best described as "a Private Reserve Auction pattern, generalized from one sealed round to a continuously operating order book."
| Feature | What it means in Zekura |
|---|---|
| Confidential Orders | Asset, amount, side, price, owner, and expiry never touch the chain. Only a 32-byte commitment and a lifecycle state (OPEN/FILLED/CANCELLED/EXPIRED) are ever public. |
| Commitment-Based Order Model | Orders are created with persistentCommit<OrderDetails>(details, blinding); every later action (cancel, expire, settle) must supply witnesses that recompute the identical commitment before the contract trusts them. |
| Zero-Knowledge Settlement | settle() takes no order data as arguments — it re-derives both orders' private details from witnesses and proves, in zero knowledge, that they satisfy the crossing rules, without disclosing the underlying values. |
| Proactive Matching Engine | The Matcher's price-time-priority engine runs only on order arrival or removal — no poller, no timer, no background scan — and matches in O(1)-bounded operations against a per-asset, per-side price ladder. |
| Protocol-Owned Liquidity (PPM) | When no user counterparty crosses, the Matcher falls back to the Proactive Market Maker, which fills the order out of the Treasury's real, deposited on-chain balance — never minted, never fabricated. See "Proactive Market Maker (PPM)". |
| Non-Custodial | Zekura never holds funds or private keys. Wallets sign and submit their own createOrder/cancelOrder transactions directly; the Matcher only ever submits settle()/settleWithProtocol() for orders it has independently verified against the chain. |
| Wallet Agnostic | Connects through the standard Midnight DApp Connector API. Any wallet extension that implements it is auto-discovered; 1AM and Lace are recognized by name today. |
| Replay Protection | Two independent layers: a one-way OPEN → {FILLED, CANCELLED, EXPIRED} state machine, and a settledPairs nullifier set — a matched pair can never settle twice. |
| Midnight Native | Written in Compact, Midnight's zero-knowledge smart contract language, and integrated through the official midnight-js-* provider stack — no custom cryptography, no bridge, no side-chain. |
| Open Source | MIT-licensed, full source for the contract, matcher, and UI in this repository. |
Zekura does not yet have a hosted public website, whitepaper, or demo video — this section reports that honestly rather than link to something that doesn't exist. What is live today:
| Resource | Link |
|---|---|
| Source code | github.com/wolf1276/ZEKURA |
| Preview contract (Midnight testnet) | 7e6fb224…52984 on Preview explorer |
| Preprod contract (Midnight testnet, default network) | f7080eee…73a949 on Preprod explorer |
| Security audit | AUDIT.md |
| Deployment & live-verification record | Deployment.md |
| X (Twitter) | @zekuraprotcol |
| Whitepaper / hosted app | Not published yet — see "Roadmap" |
To run the full stack yourself against either live testnet, see "Getting Started" and "Wallet Setup".
Captured from a real next dev build running against live Preprod (headless
Chromium, no wallet connected — the pre-connect / empty state every visitor
sees first). The navbar's "Preprod" badge on every page confirms the app's
default network. See "Demo Instructions" to drive a
connected-wallet session yourself.
Landing![]() |
Overview / Dashboard![]() |
Trade![]() |
My Orders![]() |
Activity![]() |
|
The Settings page exists in the app (
web/src/app/settings) but was not captured in this pass. Architecture is documented as diagrams in the next section rather than a screenshot.
- Overview
- Key Features
- Live Demo
- Screenshots
- Architecture Overview
- How It Works
- Proactive Market Maker (PPM)
- Treasury
- Privacy Model
- Technology Stack
- Repository Structure
- Getting Started
- Wallet Setup
- Running the Matcher
- Smart Contracts
- Testing
- CI/CD
- Security
- Performance
- Roadmap
- Contributing
- Documentation
- FAQ
- License
- Acknowledgements
Zekura has five moving parts: the frontend (wallet-facing UI), the wallet layer (the user's own extension plus a local proof server), the Matcher (confidential order book, matching engine, and settlement submitter — which also embeds the PPM, the Matcher's fallback liquidity component), the Treasury (the contract's own protocol-owned liquidity ledger), and the exchange contract (public commitment registry + settlement + Treasury accounting). A Network Manager inside the frontend keeps all of them pointed at the same Midnight network.
flowchart TB
subgraph Browser["Browser — web/ (Next.js)"]
UI["Trade · Orders · Activity · Dashboard · Treasury"]
NM["Network Manager<br/>(networkConfig.ts / NetworkProvider)"]
end
subgraph WalletLayer["Wallet Layer"]
Ext["Wallet Extension<br/>1AM or Lace<br/>(DApp Connector API v4)"]
PS["Local Proof Server<br/>:6300<br/>(fallback when the wallet has no in-browser prover)"]
end
subgraph OffChain["Off-chain — matcher/"]
Matcher["Matcher Server<br/>order book · matching engine · settlement queue"]
PPM["PPM<br/>(ppm/PPMService + PricingEngine)<br/>fallback liquidity, BUY-side only"]
DB[("SQLite<br/>orders / matches / settlements / reservations / treasury events")]
end
subgraph OnChain["Midnight Network"]
Contract["exchange.compact<br/>commitment registry · settlement · Treasury ledger"]
Indexer["Indexer<br/>(public ledger reads)"]
Node["Node"]
end
UI <-->|"connect / sign / submit"| Ext
UI <-->|"REST + WebSocket"| Matcher
UI -.->|"read live ledger record<br/>(privacy-proof panel, Treasury page)"| Indexer
NM -.-> UI
NM -.-> Matcher
Ext -->|"createOrder / cancelOrder"| Contract
Ext -.->|"proof request<br/>(if no in-browser prover)"| PS
Matcher -->|"settle()"| Contract
Matcher -->|"no user match found"| PPM
PPM -->|"reserveLiquidity / settleWithProtocol /<br/>releaseLiquidity"| Contract
Matcher -.->|"proof request"| PS
Matcher --- DB
Contract <--> Node
Indexer <--> Node
Frontend (web/). A Next.js app that never itself holds keys or proves
anything — it delegates signing and proving to the connected wallet
extension, and reads/writes order state through the Matcher's REST/WebSocket
API. See web/src/services/midnight/exchangeContract.ts
for the direct contract calls and web/src/services/matcher/matcherClient.ts
for the Matcher client. The /treasury route (web/src/components/treasury/treasury-page.tsx)
reads live Treasury balance/reservation/risk data and, for allowlisted admin
wallets, exposes the deposit/withdraw funding UI.
Wallet Layer. Any Midnight DApp-Connector-compatible extension. 1AM implements in-browser proving; Lace does not, so the app falls back to a local proof server for Lace sessions only. See "Wallet Setup".
Matcher (matcher/). A standalone Fastify server that owns the actual
confidential order book. It receives full order details off-chain (after the
wallet has already registered the order's commitment on-chain itself),
independently re-verifies that disclosure against the live indexer, matches
crossing orders in memory, and submits settle(). When no user counterparty
crosses, it hands the resting order to its embedded PPM component
(matcher/src/ppm/) as a fallback liquidity provider — see
"Proactive Market Maker (PPM)". See
matcher/ARCHITECTURE.md for the full request
flow and database schema.
Treasury. The contract's own protocol-owned liquidity ledger
(treasuryBalances/treasuryReserved/reservations/treasuryHistory in
contracts/exchange.compact) — funded only by real admin deposits, never
minted. See "Treasury".
Exchange Contract (contracts/exchange.compact). The sole source of
truth on-chain. 13 exported circuits plus 3 exported pure circuits, admin
role gated to Treasury funding only (order creation/cancellation/settlement
remain permissionless/owner-authorized, unchanged) — see
"Smart Contracts".
Network Manager. web/src/network/networkConfig.ts is the single source
of truth for every network Zekura supports (preview, preprod today).
Critically, the app does not choose the active network itself at
runtime — it adopts whatever network the connected wallet reports, since the
DApp Connector's own guidance is that DApps should follow the wallet's
configuration.
Settlement flow. See the sequence diagram in "How It Works" below.
The Matcher always tries a user counterparty first; the PPM is invoked only when the private order book has no crossing order, and acts as a fallback liquidity provider rather than the primary execution engine:
flowchart TD
A["User submits order"] --> B["Matcher validates order"]
B --> C["Search private order book"]
C --> D{"Match found?"}
D -->|Yes| E["User ↔ User settlement<br/>(settle())"]
D -->|No| F["Invoke PPM"]
F --> G["Treasury liquidity check<br/>(PricingEngine.quote)"]
G --> H{"Liquidity available?"}
H -->|Yes| I["Reserve funds<br/>(reserveLiquidity)"]
H -->|No| J["Order remains open"]
I --> K["Execute trade<br/>(settleWithProtocol)"]
K --> L["Settlement"]
L --> M["Release / finalize reservation"]
M --> N["Update Orders"]
N --> O["Update Activity"]
O --> P["Update Treasury"]
P --> Q["Update Dashboard"]
E --> N
If settleWithProtocol itself fails after a reservation was taken, the
Matcher best-effort releases that reservation back to available liquidity
immediately (PPMService.attemptFill's catch path) rather than leaving it
stuck until expiry; a still-open reservation past its expiresAt is
reclaimed by releaseExpiredLiquidity, which is permissionless and swept
periodically by the Matcher (PPMService.sweepExpiredReservations) as
defense in depth. See "Proactive Market Maker (PPM)"
for what "liquidity available" actually checks.
The full lifecycle — wallet → commitment → Matcher → settlement → ledger → UI updates — for one order that gets matched against another user:
sequenceDiagram
actor Trader
participant Wallet as Wallet Extension
participant Chain as exchange.compact
participant Matcher
participant UI as web/ UI (WebSocket)
Trader->>Wallet: Fill in order (asset, side, price, amount)
Wallet->>Wallet: commitment = persistentCommit(OrderDetails, blinding)
Wallet->>Chain: createOrder(orderId, commitment)
Chain-->>Wallet: confirmed — order OPEN, commitment on-chain
Wallet->>Matcher: POST /orders (full OrderDetails + blinding, off-chain)
Matcher->>Chain: read on-chain commitment (via indexer, free)
Matcher->>Matcher: recompute commitment, verify it matches on-chain + submitted
Matcher->>Matcher: search opposite side's book (price-time priority)
alt crossing order found
Matcher->>Matcher: atomically claim both orders (OPEN → MATCHED)
Matcher-->>UI: WS order.matched
Matcher->>Chain: settle(buyOrderId, sellOrderId)
Chain->>Chain: re-derive both orders' details from witnesses,<br/>verify commitments, assert crossing rules
Chain-->>Matcher: confirmed — both orders FILLED
Matcher-->>UI: WS order.filled
else no match yet
Matcher-->>UI: WS order.created (resting, OPEN)
end
UI-->>Trader: order status timeline, order book, and activity feed<br/>update live — no manual refresh
Two properties make this safe without a trusted intermediary:
- The Matcher cannot forge disclosure.
OrderService.submitOrderrecomputes the commitment from the disclosedOrderDetailsand requires it to equal both the client-supplied commitment and the one already recorded on-chain — a party that doesn't actually know an order's true contents cannot get it accepted into the book. - The Matcher cannot cancel an order it merely knows about.
settle()'s witnesses never includeownerSecretKey— that witness is exclusive tocancelOrder, and the Matcher never holds one. See "Privacy Model" for why this specific boundary needed a dedicated audit fix.
The PPM is protocol-owned liquidity, not a second matching engine and not
a market-making bot with its own capital. It is the Matcher's fallback path
(matcher/src/ppm/PPMService.ts) for the one case user↔user matching can't
solve: a resting order with no crossing counterparty yet.
- Liquidity comes entirely from the Treasury. Every unit the PPM ever
quotes or settles is a real balance already sitting in the contract's
treasuryBalancesmap — see "Treasury". - The PPM never mints assets and never creates fake liquidity.
PricingEngine.quote(matcher/src/ppm/PricingEngine.ts) returnsnull— no quote at all — whenever the requested amount exceeds the Treasury's currently available balance (balance − reserved) or its configured per-quote risk limit (maxExposureFraction, 20% of available liquidity by default). There is no code path that fabricates a fill the Treasury can't actually back. - The PPM only uses real deposited funds. The only way
treasuryBalancesbecomes non-zero is an admin'sdepositTreasury()call actually pulling tokens into the contract viareceiveUnshielded— see "Treasury". - The Matcher decides whether a user match exists before invoking the
PPM.
OrderService.submitOrderrunsMatchingEngine.onOrderArrivedfirst; the PPM is only called in theno matchbranch, exactly matching the flow in "How It Works". - The PPM only executes when there is no suitable counterparty — and even
then, only when the order's own limit price actually crosses the PPM's
quoted price (the same
buyPrice >= sellPrice-style crossing checksettle()uses between two user orders, applied against the PPM's single quote instead of a second order). - Treasury liquidity is reserved before execution and released or settled
afterward. A fill is always
reserveLiquidity()→settleWithProtocol(), never a direct debit — see the reserve/execute/release lifecycle in "Treasury" and the flowchart above. - Settlement always occurs on-chain.
settleWithProtocolis a realexchange.compactcircuit; there is no off-chain-only "protocol fill" — every PPM execution has a real transaction id, exactly likesettle().
| User ↔ User matching | PPM (protocol liquidity) | |
|---|---|---|
| BUY | ✅ | ✅ |
| SELL | ✅ | ✅ |
settleWithProtocol moves a real NIGHT payment on both branches (the
Treasury receives it from the buyer on a BUY, pays it to the seller on a
SELL — contracts/exchange.compact's nativeToken() payment leg) and, on
a SELL, also receives the traded asset from the seller
(receiveUnshielded). Because receiveUnshielded always pulls funds from
whoever submits the transaction, and the Matcher's own operator wallet
never custodies user funds, the order's own wallet — not the Matcher —
must submit the final settleWithProtocol call for both BUY and SELL.
PPMService.attemptFill reserves liquidity and returns a pending quote;
the web app's Orders page then shows an "Approve Settlement" button
(hooks/use-order-actions.ts's settleWithProtocol) that submits it from
the order owner's own connected wallet. This is an architectural
consequence of the fix, not a bug: it is the only way a PPM fill can
happen without the Matcher ever holding a user's funds.
The Treasury is the contract's own protocol-owned liquidity pool — the on-chain state the PPM draws on. It has no relationship to any user's funds; Zekura remains non-custodial for trading (see "Privacy Model").
- The Treasury begins empty.
treasuryBalances/treasuryReservedare ledgerMaps with no seeded entries — a never-funded asset simply reads as0(balanceOf/reservedOf's member-check helpers), not an error. - The Treasury is funded only through real deposits.
depositTreasury(assetKey, amount)is the only circuit that increasestreasuryBalances— it pulls real tokens into the contract's own custody viareceiveUnshieldedand is gated to an allowlisted admin (requireAdmin(), checkingderiveAdminId(adminSecretKey())against the on-chainadminsset). - Treasury backs protocol liquidity. Everything the PPM ever quotes comes out of this same balance — see "Proactive Market Maker (PPM)".
- Deposits increase available liquidity; reservations temporarily reduce
it. "Available" is always
balance − reserved, computed live byPricingEngine/OnChainTreasuryReader, never a separately-tracked number that could drift. - Settlement finalizes accounting.
settleWithProtocolmoves the reserved amount out oftreasuryReserved(BUY side: pays the buyer viasendUnshielded) and marks the reservationEXECUTED— the same reserve → finalize shapereserveLiquidity/releaseLiquidityalready establish. - Withdrawals return unused Treasury funds.
withdrawTreasuryis admin-gated and explicitly can't touch anything currently reserved (amount + reserved <= balance) — liquidity held against an open PPM quote can never be pulled out from under it. - Treasury health metrics reflect protocol liquidity in real time. The
Matcher's
GET /ppm/statusand the web/treasurypage compute a coarseempty/healthy/elevated/criticalrisk label directly from livebalance/reserved(viaPricingEngine's inventory-skew utilization, mirrored inmatcher/src/api/treasury.ts'sriskStatus) — never a cached or stale snapshot.
stateDiagram-v2
[*] --> OPEN: reserveLiquidity
OPEN --> EXECUTED: settleWithProtocol
OPEN --> RELEASED: releaseLiquidity (settlement failed / order cancelled)
OPEN --> RELEASED: releaseExpiredLiquidity (past quoteTtlSeconds, permissionless)
EXECUTED --> [*]
RELEASED --> [*]
The contract's only on-chain state is the orders ledger — a
Map<orderId, {commitment, state}> — plus a replay-protection set
(settledPairs) and an append-only event log (eventLog) that carries only
{kind, orderId}. Everything else about an order lives off-chain, in the
owner's wallet and (once disclosed for settlement) the Matcher's database.
Never written to the ledger, never leaves the owner's wallet except to whoever they explicitly disclose it to (the Matcher, off-chain, for settlement):
| Field | Description |
|---|---|
asset |
Which token/pair the order trades |
amount |
Order size |
side (isBuy) |
Buy or sell |
owner |
A DApp-specific pseudonymous identity (deriveOwnerId(secret)) — never the caller's real Zswap public key or wallet address |
price |
Limit price |
expiresAt |
Order expiry (except indirectly, via the public fact that an expireOrder() call succeeded) |
Anyone reading the public ledger, an indexer, or a submitted transaction can see:
- That an order with a given
orderIdexists, and its 32-byte commitment (a hash — reveals nothing about its contents without the preimage). - Its lifecycle state (
OPEN/FILLED/CANCELLED/EXPIRED) and the order of state transitions. - That a
createOrder/cancelOrder/settle/expireOrdertransaction was submitted, by which address, at which block — standard Midnight transaction metadata is not hidden by this contract. - The total count of orders and their commitments (the
ordersmap is fully enumerable), letting an observer infer overall order count — but not size, price, or side.
Validators verify a zero-knowledge proof that a circuit call satisfies the
contract's rules, and apply the resulting public-state transcript — the same
{orderId → commitment, state} update any other observer would eventually
see via the indexer. Validators never see the private witnesses
(OrderDetails, orderBlinding, ownerSecretKey) that produced the proof;
Midnight's zk-SNARK verification confirms the proof is valid without
re-executing the private computation that generated it.
The Matcher is legitimately disclosed a submitting order's full
OrderDetails and blinding factor off-chain — it cannot match or settle
orders without them. It is explicitly not trusted with an order's
ownerSecretKey: settle()'s witness set never calls that witness, so the
Matcher structurally cannot satisfy cancelOrder's owner check for any order
it merely knows the contents of. This boundary is why DELETE /orders/:id
on the Matcher's API can only remove an order from its own book/database —
it can never submit an on-chain cancelOrder().
A wallet creates an order by committing to its private details
(persistentCommit<OrderDetails>(details, blinding)) and submitting only the
resulting 32-byte commitment on-chain via createOrder — nothing about the
order's contents is revealed at creation time. To cancel, the owner's wallet
supplies the same private details and blinding factor back to the
cancelOrder circuit as witnesses; the circuit recomputes the commitment and
checks it against the one on record, proving knowledge without ever writing
the contents to the chain. Disclosure to the Matcher is a separate, explicit,
off-chain act by the owner's wallet (POST /orders) — never something the
contract or the Matcher can compel.
| Actor | Trusted for | Not trusted for |
|---|---|---|
| Order owner's wallet | Its own OrderDetails, blinding factor, and ownerSecretKey. The only party able to cancelOrder its own orders, and the only party that submits createOrder/cancelOrder on-chain. |
Nothing about other orders. |
| Matcher (off-chain) | Disclosed full OrderDetails + blinding for orders it is settling (required to call settle). Its own funded operator wallet, distinct from any user's. |
Any order's ownerSecretKey — structurally never disclosed to it. Cannot cancel orders it did not create. |
| Any network observer | Can read all public ledger state (orders, settledPairs, eventLog) and submit any transaction. |
Cannot forge a commitment preimage or a caller's ownerSecretKey/orderBlinding (never on-chain). |
| The prover (any caller's own frontend) | Controls every witness return value for its own calls. | Must never be trusted as an authorization signal by itself without a cryptographic binding — this is the general form of the audit's P0 finding (see "Security"). |
A network observer cannot reconstruct the order book, cannot see who is trading what against whom, and cannot correlate two orders as belonging to the same owner (the owner field is a domain-separated hash of a per-DApp secret, not a reusable public key).
Observable, not just documented: once an order is submitted through the
web app, the Trade page's "Verify privacy on-chain" panel
(orderVerification.ts +
privacy-proof-panel.tsx)
fetches that order's actual live ledger record from the connected
network's indexer and shows it side by side with the order's real private
fields the app already holds locally.
| Layer | Technology |
|---|---|
| Smart contract language | Compact 0.31.1 (compiler), language version 0.23.0 (pragma requires >= 0.16), runtime 0.16.0 — contracts/exchange.compact |
| Contract tooling | @midnight-ntwrk/compact-js, @midnight-ntwrk/compact-runtime (in-memory circuit execution for tests), midnight-js-contracts / midnight-js-* providers (deployment, indexer, proof, wallet plumbing) |
| Matcher backend | Node.js ≥ 22, TypeScript, Fastify (REST), ws (WebSocket broadcast), Zod (validation), pino (structured logging) |
| Matcher persistence | better-sqlite3 — synchronous SQLite, used for atomic claim transactions |
| Frontend framework | Next.js 16.2.10 (App Router), React 19.2.4 |
| Frontend UI | Tailwind CSS 4, shadcn/ui + Radix primitives, lucide-react, framer-motion, sonner (toasts), lightweight-charts (trading chart) |
| Wallet integration | @midnight-ntwrk/dapp-connector-api — the standard interface every Midnight-compatible wallet extension implements |
| Database | SQLite (Matcher's orders/matches/settlements tables) — the Matcher's local system of record; the chain itself stores no order data |
| Testing | Vitest (matcher, web); a hand-rolled assertion runner exercising compiled circuits directly via @midnight-ntwrk/compact-runtime (contract) |
| Infrastructure | Docker Compose — local devnet (midnight-node, indexer-standalone, proof-server) for local development; a standalone proof server for Preview/Preprod |
| CI | GitHub Actions (.github/workflows/ci.yml) |
zekura/
├── contracts/
│ ├── exchange.compact # The sole production contract — commitment registry + settlement
│ └── managed/exchange/ # Compiled output of `npm run compile` (circuits, keys, zkir, TS bindings)
├── matcher/ # Off-chain confidential order book + settlement engine (npm workspace)
│ ├── src/
│ │ ├── api/ # Fastify REST routes — validate, delegate, serialize
│ │ ├── orderbook/ # Pure in-memory order book (Bucket, AssetBook, OrderBook) — no I/O
│ │ ├── matcher/ # Matching engine + price-time-priority strategy — no I/O
│ │ ├── ppm/ # PPM fallback: PPMService (reserve/settle orchestration), PricingEngine (quoting), TreasuryClient (Treasury circuit access)
│ │ ├── db/ # better-sqlite3 schema + repositories — orders/matches/settlements/reservations/treasury events, the system of record
│ │ ├── settlement/ # settle() client + retry queue
│ │ ├── websocket/ # SocketServer — WS event broadcast
│ │ ├── services/ # Orchestration: OrderService (matching + PPM fallback), SettlementService
│ │ ├── types/ # Domain types
│ │ ├── utils/ # Config, logging, Zod schemas, the commitment codec
│ │ ├── app.ts # Fastify app factory — fully testable, deps injected
│ │ └── index.ts # Composition root — the only file with real SDK wiring
│ ├── tests/ # Mirrors src/, plus integration/ and concurrency/
│ ├── ARCHITECTURE.md # Request flow, database schema, security & concurrency model
│ ├── API.md # Full REST/WebSocket reference
│ └── MATCHER.md # Matching algorithm and settlement lifecycle
├── web/ # Trading UI (standalone package, own lockfile)
│ └── src/
│ ├── app/ # Next.js App Router routes (/, /trade, /orders, /activity, /dashboard, /treasury, /settings)
│ ├── components/ # Page and UI components, organized by feature (incl. treasury/)
│ ├── network/ # Network Manager — single source of truth for network config
│ ├── wallet/ # DApp Connector integration, wallet discovery, connection lifecycle
│ ├── services/
│ │ ├── midnight/ # Direct contract calls, commitment codec, privacy verification
│ │ └── matcher/ # REST/WebSocket client for the Matcher API
│ ├── hooks/, lib/, types/, shims/
│ └── app/api/matcher/ # Same-origin proxy routes to the Matcher REST API
├── src/ # Root-level CLI/deploy/setup scripts
│ ├── setup.ts # Compile + deploy in one step, with faucet-fund polling
│ ├── deploy.ts # Deploy the compiled contract
│ ├── cli.ts # Read-only CLI (look up an order, check balance)
│ ├── check-balance.ts, wallet.ts, wallet-state.ts, network.ts
├── scripts/
│ ├── e2e-check.ts # Smoke check against a deployed contract
│ └── sync-retry.ts # Wraps a command with sync-retry semantics
├── tests/
│ ├── exchange.test.ts # Order registry + settlement test suite (34 tests) — drives compiled circuits directly
│ ├── treasury.test.ts # Treasury/PPM circuit test suite (28 tests) — deposit/withdraw/reserve/release/settleWithProtocol
│ └── tzkr-token.test.ts # tZKR mint authorization + color-stability test suite (6 tests)
├── docs/screenshots/ # README screenshots
├── docker-compose.yml # Local devnet: node, indexer, proof server
├── AUDIT.md # Full security audit of the contract
├── Deployment.md # Deployment history and live end-to-end verification record
└── .github/workflows/ci.yml # CI pipeline
- Node.js ≥ 22
- Docker with Compose v2 (for the local devnet / local proof server)
- The Compact compiler, pinned to the version this project targets:
(
compact update 0.31.1
compact --versionreports the CLI tool's own version — currently0.5.1— which is a different number from the compiler version above; both are documented in "Smart Contracts".)
git clone https://github.com/wolf1276/ZEKURA.git zekura
cd zekura
npm install # installs the root + matcher workspace (matcher is an npm workspace member)
cd web && npm install # web is a standalone package with its own lockfileCopy web/.env.example to web/.env.local and fill in the network(s) you
intend to use:
| Variable | Default | Purpose |
|---|---|---|
NEXT_PUBLIC_EXCHANGE_CONTRACT_ADDRESS_PREVIEW |
(none) | This app's deployed exchange.compact address on Preview. Required to submit orders while on Preview — see the Contract Address table. |
NEXT_PUBLIC_EXCHANGE_CONTRACT_ADDRESS_PREPROD |
(none) | Same, for Preprod — the app's default network. |
NEXT_PUBLIC_PROOF_SERVER_URL |
http://127.0.0.1:6300 |
Local proof server, used only when the connected wallet has no in-browser prover (e.g. Lace). |
NEXT_PUBLIC_MATCHER_WS_URL |
ws://localhost:4000/ws |
Matcher WebSocket feed, called directly from the browser. |
MATCHER_API_URL |
http://localhost:4000 |
Matcher REST base URL, used server-side by the Next.js same-origin proxy routes (app/api/matcher/**). |
NEXT_PUBLIC_ADMIN_ADDRESSES |
(none) | Comma-separated unshielded wallet addresses that see the Treasury page's admin funding UI. Client-side hint only — the real authorization boundary is the Matcher's own MATCHER_ADMIN_ADDRESSES allowlist plus a real wallet signature. Should match (a subset of) MATCHER_ADMIN_ADDRESSES. |
The active network itself (Preview vs. Preprod) is not an env var — it's chosen at runtime by whatever network the connected wallet reports. See "Wallet Setup".
# Terminal 1 — local proof server (only needed for local devnet or a wallet without in-browser proving)
docker compose up -d --wait proof-server
# Terminal 2 — Matcher
cd matcher && npm run dev
# Terminal 3 — web app
cd web && npm run devOpen http://localhost:3000. By default the app targets Preprod — see
"Smart Contracts" for the currently deployed addresses,
and "Wallet Setup" for connecting a wallet.
To run against a fully local network instead of a public testnet:
docker compose up -d --wait # node, indexer, proof server — see docker-compose.yml
npm run setup # compiles the contract and deploys to the local devnetnpm run setup writes the deployed address to .midnight-state.json
(gitignored) and prints it to the console.
- Start infrastructure (proof server, Matcher, web app) as above.
- Connect a wallet (1AM or Lace) via the navbar.
- Submit an order on the Trade page — this signs and submits a real
createOrder(orderId, commitment)call through the connected wallet (exchangeContract.ts), no mocks. - Watch it propagate live: the Trade page's status timeline, Orders, Activity, and Overview all update automatically from the same Matcher WebSocket feed — no manual refresh.
- Use the "Verify privacy on-chain" panel to fetch the order's actual live ledger record and compare it against the private fields the app holds locally.
- To see a match, repeat step 3 with a second, crossing order (opposite
side, crossing price, same asset) from a second wallet/browser profile —
the Matcher matches it against the resting order and submits
settle()automatically.
Zekura connects through the standard Midnight DApp Connector API
(@midnight-ntwrk/dapp-connector-api v4), the same interface every
Midnight-compatible wallet extension implements.
| Wallet | Injection key | Notes |
|---|---|---|
| 1AM Wallet | window.midnight['1am'] |
Recommended. Shielded by default, implements in-browser proving (getProvingProvider()) — no local proof server required. |
| Lace | window.midnight.mnLace |
Does not implement getProvingProvider() — the app falls back to the local proof server (NEXT_PUBLIC_PROOF_SERVER_URL) for Lace sessions only. |
| Any other DApp-Connector-compatible wallet | (any key under window.midnight) |
Auto-discovered generically — nothing about a new wallet needs to be known ahead of time. See walletRegistry.ts. |
Handled end to end by WalletProvider.tsx:
- Connect — via the wallet picker modal; the
connect()call happens synchronously inside the click so the extension's approval pop-up isn't blocked, with a generous timeout as a safety net against a hung extension. - Disconnect — clears local session state and the "reconnect on load" flag.
- Reconnect — if the browser was previously connected, the same wallet is silently reconnected on page load once the Network Manager has settled on a network; a rejected/failed silent reconnect falls back to the manual picker without surfacing an error.
- States —
idle/connecting/connected/unsupported-network/disconnected/unavailable/error, all modeled explicitly byWalletStatus. Connector failures are normalized into aWalletErrorwith a user-facing message.
The DApp Connector v4 API has no push events for network changes, so the app
polls getConnectionStatus() and adopts whatever network the wallet
reports as its own — there is no separate "choose a network in the app"
control. unsupported-network fires only for a wallet-reported id Zekura
has no NetworkConfig for (i.e. anything other than preview/preprod).
| Symptom | Likely cause |
|---|---|
| Wallet picker shows no installed wallets | The extension isn't injecting under a recognized key — confirm it implements the Midnight DApp Connector API and is enabled for the current site. |
unsupported-network after connecting |
The wallet is set to a network Zekura doesn't configure (only preview/preprod exist today — see "Roadmap" for Mainnet). |
| Order submission hangs at the approval pop-up | Using Lace without a running local proof server — start one with docker compose up -d --wait proof-server and confirm NEXT_PUBLIC_PROOF_SERVER_URL points at it. |
| "not deployed on this network" | NEXT_PUBLIC_EXCHANGE_CONTRACT_ADDRESS_PREVIEW/_PREPROD isn't set for the network the wallet is on — see the Contract Address table. |
The Matcher (matcher/) is an npm workspace member of the root package —
it shares node_modules and imports the root's src/wallet.ts/src/network.ts
and the compiled contract artifacts directly rather than duplicating them.
- Everything in "Getting Started" (Node ≥ 22, Docker, the Compact compiler).
- The exchange contract compiled (
npm run compileat the repo root) and deployed to the network you intend to run against. - A local proof server reachable at the target network's proof server URL.
- Its own funded operator wallet — should be a distinct, independently funded wallet on Preview/Preprod, not the contract deployer's.
cd matcher
npm run dev # tsx watch — fast local iteration
npm run build # real tsc build to dist/
npm start # runs the buildBuild output note: because
index.tsimports the root project'ssrc/wallet.ts/src/network.tsdirectly,tsc's inferredrootDirspans bothmatcher/srcand the repo root'ssrc. The compiled entry point lands atdist/matcher/src/index.js, notdist/index.js— this is whatnpm startruns.
By default the Matcher targets whatever network .midnight-state.json
(repo root) has active. Override with environment variables:
| Variable | Default | Purpose |
|---|---|---|
MATCHER_PORT |
4000 |
HTTP + WebSocket port |
MATCHER_HOST |
0.0.0.0 |
Bind address |
MATCHER_DB_PATH |
./data/matcher.db |
SQLite file path (:memory: also works) |
MATCHER_LOG_LEVEL |
info |
pino log level |
MATCHER_PRETTY_LOGS |
true outside NODE_ENV=production |
Human-readable vs. structured JSON logs |
MATCHER_SETTLEMENT_MAX_RETRIES |
5 |
Transient-failure retry budget per settlement |
MATCHER_SETTLEMENT_RETRY_DELAY_MS |
5000 |
Linear backoff base delay |
MATCHER_WALLET_SEED |
falls back to the root project's per-network deployer seed | The Matcher's own operator wallet |
PRIVATE_STATE_PASSWORD |
local-devnet placeholder | Wallet private-state store password (≥16 chars) |
See matcher/README.md, matcher/ARCHITECTURE.md,
and matcher/API.md for the full reference.
| Method | Path | Purpose |
|---|---|---|
POST |
/orders |
Disclose a signed order for matching/settlement |
DELETE |
/orders/:id |
Remove an order from the Matcher's book (never on-chain) |
GET |
/orders/:id |
Fetch one order |
GET |
/orders/open |
All currently OPEN orders |
GET |
/orderbook |
Aggregated bid/ask snapshot for one asset |
GET |
/trades |
Recent fills for one asset |
GET |
/stats |
Rolling-window price/volume stats |
GET |
/health |
Liveness check |
GET |
/treasury/balance |
Live Treasury balance/reserved/available for one asset (public — Treasury state is already public on-chain ledger data) |
GET |
/treasury/history |
Recent Treasury events (DEPOSIT/WITHDRAW/RESERVE/RELEASE/EXECUTE) |
GET |
/ppm/status |
Treasury liquidity plus a coarse empty/healthy/elevated/critical risk label and the active PricingConfig |
POST |
/admin/challenge |
Issues a signed-nonce challenge to an allowlisted admin address (first step of MATCHER_ADMIN_ADDRESSES wallet-signature auth) |
POST |
/admin/treasury/deposit |
Admin-only: submits a real depositTreasury() transaction |
POST |
/admin/treasury/withdraw |
Admin-only: submits a real withdrawTreasury() transaction to a specified recipient |
WS |
/ws |
Live event stream (order.created, order.matched, order.settling, order.filled, order.failed, order.cancelled, order.expired, treasury.reserved, treasury.released, treasury.deposited, treasury.withdrawn) |
Full request/response shapes, error codes, and the WebSocket message
contract are in matcher/API.md.
| Network | Contract Address | Deployed |
|---|---|---|
| Preview | 7e6fb224e13e12736fdfbaed2d80265105f3a942a88d61a494472c5e11152984 (post-audit build) — unverified: no preview entry exists in .midnight-state.json and web/.env.local's preview var is empty; this row may be stale/fictional, confirm via a fresh deploy or live indexer read before trusting it |
2026-07-15 |
| Preprod (default network) | 7d47503a012953623620c02164b5a0893a3dc819df3cc01fb83bedd6529e3a1a (redeploy after the S1 fix build below; not yet documented in Deployment.md — confirm circuit set/changes before relying on this) |
2026-07-21 |
| Preprod — tZKR token | 4ab7fecf77fa9ae81e560aa82fce78973eebfc289b1fef62b756938206f136b6 (contracts/tzkr-token.compact, native unshielded token) — real minted color 40c9bb75b7302c92ca27814fcb744529e5b4eb7d10a5fa5ef8bf6e369c8acd32 |
2026-07-19 |
| Undeployed (local devnet) | not persistent — redeploy via npm run setup |
— |
tZKR now moves through Treasury for real — it was rebuilt as a genuine
unshielded token (mintUnshieldedToken, the same primitive class NIGHT
itself is built from) instead of an OpenZeppelin Compact FungibleToken
composition, which kept balances in a contract-internal ledger the Treasury
could never custody (no C2C calls yet to bridge the two accounting
systems). Full root cause and the redesign that closed it:
docs/ARCHITECTURE_TZKR_UNSHIELDED_MIGRATION.md.
The Preprod Treasury is seeded with real on-chain liquidity for both
assets (1,000,000 NIGHT, 100,000 tZKR at last count — see Deployment.md),
and a live end-to-end trade (both user↔user settle() and a protocol-
liquidity settleWithProtocol fill) has been exercised against it.
This table is hand-maintained and has drifted from the real deployment
record before (two stale addresses were live in this file for two days
undetected). The actual source of truth is the git-ignored
.midnight-state.json / .midnight-tzkr.json the deploy scripts write —
cross-check against those (or a live indexer read) before trusting this
table, especially after any redeploy.
The two networks currently run different builds. Preprod runs the latest
contract (Treasury/PPM plus a real NIGHT payment leg on settleWithProtocol
and SELL-side PPM fills). Preview is stale — it still runs the
pre-Treasury 5-circuit build from 2026-07-15 and has none of the above;
redeploying it is tracked in "Roadmap". See
Deployment.md for the full deployment record (deployer
addresses, funding, and every verification pass) and AUDIT.md
for the security review — which predates the Treasury/PPM module and does
not cover it (see "Security").
npm run compileCompiles contracts/exchange.compact to
contracts/managed/exchange/ — 12 circuits (createOrder, cancelOrder,
expireOrder, settle, addAdmin, removeAdmin, depositTreasury,
withdrawTreasury, reserveLiquidity, releaseLiquidity,
releaseExpiredLiquidity, settleWithProtocol), plus 2 exported pure
circuits (deriveOwnerId, deriveAdminId), compiler 0.31.1, language
version 0.23.0, runtime 0.16.0. (getOrder was dropped 2026-07-17 — see
"Staged deployment" below; order state is read for free via the indexer
instead, exactly like the Treasury getters already were. deriveAssetKey
was dropped 2026-07-19 alongside OrderDetails.asset's simplification to a
plain Bytes<32> — see "tZKR" above and
docs/ARCHITECTURE_TZKR_UNSHIELDED_MIGRATION.md.)
Separately, contracts/tzkr-token.compact
compiles to contracts/managed/tzkr-token/ via npm run compile:tzkr — a
single owner-gated mint(sk, recipient, amount) circuit (plus the
deriveOwnerKey pure helper) that mints a real chain-wide unshielded token
color via mintUnshieldedToken. Real unshielded tokens carry no on-chain
metadata/transfer circuits (a wallet spends its own UTXOs directly once
minted), so this contract is deliberately much smaller than a
name/symbol/balanceOf/transfer-style token contract.
docker compose up -d --wait proof-server # local proof server for the target network
npm run setup -- --network preview # compile + deploy in one stepnpm run setup starts the required services, compiles the contract, and
deploys it. On preview/preprod it prints a faucet URL and wallet address,
then polls for funding before continuing — fund the printed address and it
proceeds automatically. The deployed address is written to
.midnight-state.json (gitignored) and printed as Contract Address: <address>.
npm run cli # read-only CLI: look up an order by ID, check balance
npm run test:e2e # smoke check against the deployed contractTo redeploy fresh — a new network, or after a change that alters a circuit's
verifier key — use npm run setup -- --network <preview|preprod>, then
update web/.env.local's matching NEXT_PUBLIC_EXCHANGE_CONTRACT_ADDRESS_*
variable and this table.
A single deployContract transaction for the full 13-circuit contract
exceeds Midnight Preprod's per-transaction block-weight limit
(Transaction would exhaust the block limits) — registering 13 circuits'
verifier keys in one transaction is simply too much weight, independent of
anything about this contract's logic.
2026-07-17/18 update: the NIGHT-payment-leg/SELL-PPM redeploy hit this
same limit again at 13 circuits. Rather than re-running the staged-deploy
process below, an audit of every circuit's real call sites (grepped across
web/, matcher/, scripts/ — test-file circuit-simulator calls don't
count) found getOrder was never invoked as an actual on-chain transaction
anywhere; the Matcher already reads order state for free via the indexer
(matcher/src/index.ts's onChainReader.getOrder), the same free-read
pattern already used for the Treasury getters below. Dropping it brought the
contract to 12 circuits, which fit in a single ordinary deployContract
transaction — no staging needed for this particular deploy. The staged
process below remains the fallback if a future addition pushes the count
back over the limit and there's no more dead weight left to trim; no
user-visible functionality was removed by dropping getOrder (order state
is still fully readable, just for free instead of via a paid circuit) —
instead the contract is deployed in stages when trimming isn't enough:
- Core circuits are deployed first.
scripts/deploy-staged.tsdeploys a reduced build ofcontracts/exchange.compacttruncated right aftersettle()(i.e. just the 5 pre-Treasury circuits) — every ledger declaration is identical to the full contract, so the on-chain layout this produces is byte-identical to what deploying the full contract would have produced (verified by diffing both builds'contract-info.jsonledger sections before deploying). - Remaining verifier keys are inserted using Midnight's maintenance-authority
mechanism. The remaining 8 Treasury/PPM circuits' verifier keys are then
added one at a time to the already-deployed contract via
submitInsertVerifierKeyTx(@midnight-ntwrk/midnight-js-contracts), signed by the contract maintenance authority (CMA) keydeployContractauto-generates for this purpose. - All 13 circuits are deployed and live once staging completes — from
that point on,
settleWithProtocol,depositTreasury, and every other Treasury circuit are callable exactly as if they'd been part of one deploy transaction, because on-chain they now are part of the same contract at the same address.
This is a real, recurring constraint — any future circuit additions to this
contract will likely need the same staged treatment, not just a one-off fix
for this deploy. See Deployment.md's "Post-Treasury
staged redeploy" entry for the full record, including the exact transaction
sequence and independent on-chain verification.
| Circuit | Authorization | Purpose |
|---|---|---|
createOrder(orderId, commitment) |
None — permissionless registration | Registers a new OPEN order's commitment |
cancelOrder(orderId) |
deriveOwnerId(ownerSecretKey()) == details.owner |
Owner-only cancellation |
expireOrder(orderId) |
Time-based (blockTimeGte(expiresAt)) — callable by anyone once expired |
Marks a past-expiry order EXPIRED |
settle(buyOrderId, sellOrderId) |
Commitment verification + business-rule asserts, no caller-identity check (intentional — callable by the Matcher on behalf of two other parties) | Atomically fills a matching pair |
addAdmin(newAdmin) / removeAdmin(admin) |
requireAdmin() (deriveAdminId(adminSecretKey()) ∈ admins); removeAdmin additionally refuses to remove the last remaining admin |
Rotates the on-chain admin set that gates Treasury funding |
depositTreasury(assetKey, amount) |
Admin-only | Pulls real tokens into the contract via receiveUnshielded, increments treasuryBalances — the only way Treasury liquidity is ever created |
withdrawTreasury(assetKey, amount, recipient) |
Admin-only | Pays out unreserved Treasury balance (amount + reserved <= balance) to any recipient |
reserveLiquidity(quoteId, assetKey, amount, price, expiresAt) |
No caller-identity check (same trust level as settle()) — gated only by the state machine and actual available balance |
Holds Treasury liquidity against a PPM quote before it's used |
releaseLiquidity(quoteId) |
No caller-identity check | Un-reserves a still-open hold (e.g. settlement failed, order cancelled) |
releaseExpiredLiquidity(quoteId) |
Time-based (blockTimeGte(expiresAt)) — callable by anyone once expired |
Permissionless safety valve reclaiming an expired, still-open reservation |
settleWithProtocol(orderId, quoteId, recipient) |
Commitment verification + business-rule asserts, no caller-identity check | Fills one user order against an open PPM reservation instead of a second user order |
deriveOwnerId(secretKey) (pure, exported) |
— | Derives a wallet's DApp-specific pseudonymous identity, so off-chain code can compute the identical value |
deriveAdminId(secretKey) (pure, exported) |
— | Derives an admin wallet's on-chain identity for requireAdmin(), so off-chain admin tooling can compute the identical value |
OrderDetails.asset (the traded, non-NIGHT asset in every order) is a
plain Bytes<32> — the traded asset's real unshielded token color,
identical in shape and value to the Treasury's own assetKey. No hashing
indirection: settle()/settleWithProtocol() compare it directly against
a counterparty order's asset or a reservation's assetKey.
stateDiagram-v2
[*] --> OPEN: createOrder
OPEN --> CANCELLED: cancelOrder (owner only)
OPEN --> EXPIRED: expireOrder (after expiresAt)
OPEN --> FILLED: settle (matched pair)
CANCELLED --> [*]
EXPIRED --> [*]
FILLED --> [*]
All states other than OPEN are terminal — every transition circuit asserts
the current state is OPEN before proceeding, so double-cancellation,
double-settlement, cancel-after-expiry, and expire-after-cancel are all
rejected. See "Security" for the replay-protection layer
underneath this.
Three independent, offline test suites — no devnet, proof server, or wallet required for any of them — cover 300 tests across the whole system:
npm run test # root: 68/68 — contracts/exchange.compact (34 order/settlement + 28 Treasury/PPM) + tzkr-token.compact (6)
npm run test --workspace=matcher # matcher: 213/213 — order book, matching, settlement, PPM, Treasury, API
cd web && npm run test # web: 19/19 — commitment codec, formatting, order-status logic| Suite | Tests | What it covers |
|---|---|---|
Root (tests/exchange.test.ts + tests/treasury.test.ts + tests/tzkr-token.test.ts) |
68 (34 + 28 + 6) | Drives the compiled circuits directly through @midnight-ntwrk/compact-runtime's in-memory CircuitContext. exchange.test.ts covers createOrder/getOrder/cancelOrder positive and negative paths (including the owner-identity regression test for the audit's P0 finding), settle/expireOrder matching, mismatches, replay, atomicity, boundary values, and the privacy invariant test — asserting the ledger never leaks amount, price, owner, asset, isBuy, or expiresAt. treasury.test.ts covers admin-only enforcement on depositTreasury/withdrawTreasury, admin rotation (including the last-admin-cannot-be-removed guard), the full reserveLiquidity → settleWithProtocol/releaseLiquidity/releaseExpiredLiquidity lifecycle for both BUY and SELL (including the payout-redirect regression test for the settleWithProtocol owner-authorization fix), and rejection paths (insufficient balance, duplicate quote id, expired reservation, amount/asset mismatch, replay). tzkr-token.test.ts covers contracts/tzkr-token.compact's mint authorization and color-stability guarantees. |
Matcher (matcher/tests/) |
213, >95% line coverage enforced | The in-memory order book, price-time-priority matching engine, SQLite persistence, settlement retry queue, REST/WebSocket API, commitment verification against on-chain state, and the PPM's PricingEngine (spread/inventory-skew quoting, risk-limit enforcement) and PPMService (quote → reserve → pending-settlement orchestration for both BUY and SELL, expired-reservation sweep) — exercised with real SQLite (:memory:) and real matching/pricing logic; only the seams that face the live network (SettleCircuitCaller, OnChainOrderReader, PpmCircuitCaller, OnChainTreasuryReader) are faked. |
Web (web/tests/) |
19 | The browser-side OrderDetails commitment codec (determinism and no-collision checks on the exact persistentCommit encoding a wallet must reproduce bit-for-bit) and the pure formatting/order-status logic the UI renders from. |
npm run build # root: tsc --noEmit
npm run lint --workspace=matcher # matcher: eslint
npm run build --workspace=matcher # matcher: tsc build
cd web && npm run lint # web: eslint
cd web && npm run build # web: production Next.js buildLatest verified results across all three packages: TypeScript — clean
(root, matcher, and web all typecheck with 0 errors), Lint — clean
(matcher and web both eslint-clean), Production build — passing (web's
Next.js production build succeeds, including the /treasury route).
Coverage: the matcher enforces >95% line/statement coverage in CI via
npm run test:coverage (@vitest/coverage-v8). The contract suite is
hand-rolled and assertion-based (boundary values chosen deliberately — type
maxima, zero bytes, equal-price crossing — not fuzzed); no property-based or
fuzzing harness exists in this project today.
.github/workflows/ci.yml runs on every push
to main and every pull request, split into four independent jobs so
each package reports its own pass/fail status instead of one monolithic
check:
flowchart LR
A["compile-contract<br/>installs the Compact toolchain,<br/>compiles exchange.compact,<br/>uploads the artifact"]
B["contract-tests<br/>root typecheck + 60 tests"]
C["matcher<br/>typecheck + lint + 213 tests"]
D["web<br/>typecheck + lint + 19 tests + production build"]
A --> B
A --> C
A --> D
Root, matcher, and web all statically import types from the compiled
contracts/managed/exchange/contract/index.js (src/cli.ts,
matcher/src/index.ts, web/src/services/midnight/exchangeContract.ts, and
more), so none of them can typecheck, test, or build without it. compile-contract
compiles the contract once and shares the output via
actions/upload-artifact/download-artifact; contract-tests, matcher,
and web then run in parallel, each downloading that artifact.
| Job | Steps |
|---|---|
compile-contract |
Install the pinned Compact toolchain (compact update 0.31.1) → compact compile → upload contracts/managed/exchange as an artifact |
contract-tests |
Download the artifact → npm ci (root) → npm run build (typecheck) → npm run test (60 tests: 34 order/settlement + 26 Treasury/PPM) |
matcher |
Download the artifact → npm ci (root) → npm run typecheck --workspace=matcher → npm run lint --workspace=matcher → npm run test --workspace=matcher (213 tests) |
web |
Download the artifact → npm ci (in web/) → npm run typecheck → npm run lint → npm run test (19 tests) → npm run build (production Next.js build) |
A compilation failure, a type error, a lint violation, a failing test, or a
broken production build in any job fails CI, and PR checks show exactly
which package broke rather than one opaque "build-and-test" status. There is
no separate deployment stage in this pipeline — contract deployment is a
manual, explicit step (npm run setup) documented in
"Smart Contracts", not something CI performs
automatically.
Full findings, methodology, and verification log: AUDIT.md.
One P0 critical authorization bypass was found and fixed: cancelOrder
verified the caller's identity using ownPublicKey(), a witness function
with no cryptographic binding to the actual transaction signer — any party
that legitimately knows an order's committed details (which the Matcher
always does, since wallets disclose full order details to it for settlement)
could have forged cancelOrder on an order it does not own. No other P0/P1
issues were found. Production Readiness Score: 10/10 as of the
2026-07-16 addendum, after both a fresh deployment past the fixed
cancelOrder verifier key and a live end-to-end verification pass on
Preprod (see Deployment.md).
Scope note: this audit predates the Treasury/PPM module (AUDIT.md
contains no mention of either) — its findings and 10/10 score cover the
order registry and settle() only. The Treasury/PPM trust model below
documents that module's own authorization design in the meantime; it has
not yet been through the same independent audit process.
- Non-custodial user trading — unchanged by the Treasury's existence.
Deposits into the Treasury are an explicit admin action
(
depositTreasury), never something a user's trade implicitly does; a user's own funds are never in Treasury custody. - Treasury-backed protocol liquidity — every PPM fill is backed 1:1 by a
real balance in
treasuryBalances; see "Proactive Market Maker (PPM)". - Reservation-based accounting — liquidity is always
reserveLiquidity()'d before it can be used and finalized viasettleWithProtocol()orreleaseLiquidity()/releaseExpiredLiquidity()afterward;available(balance − reserved) is computed live, never a separately-tracked figure that could drift out of sync. - On-chain settlement —
settleWithProtocolis a real circuit call with a real transaction id, exactly likesettle(); there is no off-chain-only notion of a "protocol fill." - Admin-controlled Treasury operations —
depositTreasury,withdrawTreasury,addAdmin, andremoveAdminare the only admin-gated circuits in the contract; every other Treasury/PPM circuit (reserveLiquidity,releaseLiquidity,releaseExpiredLiquidity,settleWithProtocol) intentionally has no caller-identity check, at the same trust level assettle()itself — gated only by the state machine and the actual on-chain balance, not by who submits the transaction. - BUY-side-only protocol liquidity in MVP — see "Proactive Market Maker (PPM)"'s MVP limitation section for why, and why it's a deliberate scope boundary rather than an oversight.
- Comprehensive contract, backend and frontend test coverage — 26
dedicated contract-level Treasury tests (
tests/treasury.test.ts) plus Matcher-levelPricingEngine/PPMServiceunit and integration tests (see "Testing") exercise this module to the same standard as the rest of the system.
Two independent layers:
- State machine (primary).
OPEN → FILLEDis one-way; no circuit ever transitions an order out of a terminal state. A replayedsettle()on an already-filled pair fails before touching anything else. settledPairsnullifier set (defense-in-depth). A hash of[buyId, sellId]in positional order, inserted before a pair can settle. Provably redundant with (1) under the current circuit set — kept as insurance against a future circuit that could re-open an order without going through the same state-transition path.
The contract's sole authentication primitive is
persistentCommit<OrderDetails>(details, blinding) == commitment — a hash
preimage proof, never a signature scheme. Only a party that actually knows an
order's true (OrderDetails, blinding) pair can produce a value that
recomputes to a given on-chain commitment. The Matcher independently
reimplements this same recomputation off-chain
(orderDetailsCodec.ts) and
requires it to match both the client-supplied commitment and the one
already recorded on-chain before trusting a disclosed order.
Witness functions (orderDetails, orderBlinding, ownerSecretKey) are
supplied by whichever party's frontend is calling the circuit — the contract
never trusts a witness's return value as an identity signal on its own. This
is exactly the class of bug the P0 finding was: ownPublicKey() is also a
witness function, and using it directly for authorization let any prover
supply an arbitrary value. The fix replaces it with
deriveOwnerId(ownerSecretKey()), a hash-commitment-based identity check
the caller cannot forge without the real secret — the same pattern Midnight's
own reference contracts (zk-loan, battleship, bboard, private-party) use for
this purpose.
| Circuit | Mechanism |
|---|---|
createOrder |
None — intentional; a commitment carries no privilege until its contents are revealed. |
getOrder |
None — public read. |
cancelOrder |
deriveOwnerId(ownerSecretKey()) == details.owner — the audit's fix. |
expireOrder |
Time-based (blockTimeGte), correctly not identity-gated — expiry is a public fact. |
settle |
Commitment verification (proof of knowledge of both orders) + business-rule asserts; intentionally no caller-identity check, since settle is meant to be callable by the Matcher on behalf of two other parties. |
addAdmin / removeAdmin |
requireAdmin() — deriveAdminId(adminSecretKey()) must be in the on-chain admins set. |
depositTreasury / withdrawTreasury |
Admin-only (requireAdmin()). The only circuits that move Treasury balance in/out of the contract. |
reserveLiquidity / releaseLiquidity / releaseExpiredLiquidity / settleWithProtocol |
No caller-identity check (same trust level as settle), except releaseExpiredLiquidity's implicit time gate (blockTimeGte) — gated by the state machine and actual available balance, not by who submits the transaction; meant to be callable by the Matcher's PPM component on behalf of a resting user order. |
This project does not yet have a formal SECURITY.md or dedicated security
contact. If you find a vulnerability, please open a
GitHub Security Advisory
on this repository (private by default) rather than a public issue, so a fix
can land before public disclosure.
| ID | Risk | Status |
|---|---|---|
| P2-1 | orderId squatting / front-running griefing — createOrder binds no relationship between orderId and the (still-private) owner, so an observer who predicts an orderId can front-run it with a garbage commitment, permanently occupying that ID. No funds or existing orders at risk. |
Accepted — closing it in-contract would require revealing part of the owner's identity in the clear at creation time. Recommended off-chain mitigation: derive orderId unpredictably and bound to the owner (persistentHash(deriveOwnerId(secret), freshNonce)). |
| P3-1 | settle/expireOrder place no bound on expiresAt — a wallet can commit to an order that never expires or expires immediately. Only affects the order's own owner. |
Accepted — flagged for wallet-side input validation. |
| P3-2 | Commitment/blinding-factor hygiene is unenforceable on-chain — a buggy or malicious wallet that reuses a blinding factor across two orders with identical contents produces linkable commitments. | Accepted — a wallet-implementation requirement, not fixable in-contract. |
| P3-3 | getOrder costs a full transaction for a public read that's available for free from the indexer. |
Informational — getOrder is a scoped deliverable; the frontend should prefer the indexer for reads. |
| P3-4 | PPM protocol-liquidity fills (both BUY and SELL) require the order's own wallet to submit the final settleWithProtocol call — the Matcher can no longer auto-execute a fill the way it once did for BUY alone. |
Architectural consequence of the fix, not a bug — see "Proactive Market Maker (PPM)" and the web app's "Approve Settlement" UI (hooks/use-order-actions.ts). |
| P3-5 | AUDIT.md's independent security review predates the Treasury/PPM module and does not cover depositTreasury/withdrawTreasury/reserveLiquidity/releaseLiquidity/releaseExpiredLiquidity/settleWithProtocol/addAdmin/removeAdmin. |
Informational — see the Treasury/PPM trust model above for that module's own (unaudited) design rationale; a follow-up audit pass covering it is tracked in "Roadmap". |
Zekura's performance-relevant design decisions, in order of how directly they affect trade latency:
- The order book is never scanned wholesale.
OrderBookisMap<assetKey, AssetBook>; eachAssetBookholds one sortedBucketper side (best price first for that side, FIFO within a price level for time priority). Every operation is O(1) or bounded to one asset's one bucket —add,has,remove,oppositeBucketnever touch unrelated assets or the opposite side's book. - Matching is event-driven, not polled.
MatchingEngine.onOrderArrivedruns only when a new order arrives or an existing one is removed — there is no timer, no poller, no periodic re-scan.PriceTimePriorityStrategywalks the opposite bucket's natural best-first order and stops at the first non-crossing price level, since sorted iteration guarantees nothing worse could match either. - Claims are atomic without locking overhead. The Matcher is a single
Node.js process; the claim step (verify both orders still
OPEN, flip toMATCHED, insert the match row) runs inside one synchronousbetter-sqlite3transaction with noawaitin between — JavaScript's single-threaded event loop guarantees no other request can interleave, so there's no distributed lock or CAS retry loop needed for the common case.OrderRepository'sPRIMARY KEYconstraint is the actual source of truth for duplicate-id detection; theexists()pre-check is only a fast path. - Settlement never blocks matching.
SettlementQueueis a per-key single-flight retry queue — a match'ssettle()submission and retries run independently of the order book, so a slow or retrying settlement cannot stall new orders from being accepted or matched. - Reads that don't need a proof are free. The Matcher verifies
commitments against the indexer (a public GraphQL read), never a paid
getOrder()transaction — this keeps order submission latency dominated by proof generation and chain confirmation, not by extra on-chain reads. - Settlement retry uses linear backoff with a bounded budget
(
MATCHER_SETTLEMENT_MAX_RETRIES, default 5;MATCHER_SETTLEMENT_RETRY_DELAY_MS, default 5000ms), andSettlementService.recoverPendingSettlements()re-enqueues any match stillMATCHED/SETTLINGafter a process restart — so a Matcher crash mid-settlement doesn't silently drop a match; it resumes from the DB, the durable record of what's still outstanding.
The current architecture is a single-process Matcher — correct and fast
for one operator's order book (bounded, sorted, in-memory structures; a
single synchronous SQLite writer), but not yet horizontally scaled. See
"Roadmap" for what a multi-instance Matcher would require
(a shared order book / leader-election for settlement submission, since
settle() must never be double-submitted for the same match).
- Batched/parallel proof generation for concurrent order submissions.
- A dedicated read-replica or candle/history table for
/stats, which today computes on read from persisted matches on every request. - Horizontal Matcher scaling (see above) once order volume exceeds a single process's throughput.
This roadmap is grounded in what the audit, deployment record, and code already flag as open work — not speculative feature ideas.
- A literal wallet-extension click-through. Every code path the demo
flow exercises has been driven directly (same SDK calls, same providers)
or covered by the 300 automated tests, and the production build renders
every route cleanly — but a real browser session with a funded 1AM/Lace
wallet performing the actual approval pop-up flow has not yet been
recorded (see
Deployment.md's "Not yet exercised" notes). - Redeploy Preview with the Treasury/PPM build. Preview is stale (still the pre-Treasury 5-circuit build) — see "Smart Contracts". The same staged-deploy process already used for Preprod applies directly.
- Off-chain mitigations for the audit's accepted risks (P2-1
orderIdsquatting, P3-1 unboundedexpiresAt, P3-2 blinding-factor hygiene) — wallet-side input validation and unpredictableorderIdderivation. - A formal
SECURITY.mdandCONTRIBUTING.md— neither exists in this repository yet; see "Contributing" and "Security".
- SELL-side PPM protocol liquidity. Requires a secure multi-asset
custody/escrow leg so a user's asset can be taken as a transaction input
during
settleWithProtocol— intentionally out of scope for the MVP; see "Proactive Market Maker (PPM)". - An independent audit pass covering the Treasury/PPM module —
AUDIT.md's existing review predates it (see "Security"'s scope note);depositTreasury/withdrawTreasury/reserveLiquidity/releaseLiquidity/releaseExpiredLiquidity/settleWithProtocol/addAdmin/removeAdminhave not yet been through the same process as the order registry andsettle(). - Horizontal Matcher scaling for order volume beyond a single process (see "Performance").
- A dedicated stats/candle store to avoid recomputing
/statsfrom raw matches on every request. - Additional wallet integrations beyond 1AM and Lace, as more DApp Connector-compatible wallets reach the Midnight ecosystem.
- Mainnet. Out of scope today — no Mainnet
NetworkConfigexists in this repo (src/network.ts/web/src/network/networkConfig.tsdefine onlypreview/preprod). Adding it is intentionally isolated to one new entry innetworkConfig.tsper that file's own documentation. - A published whitepaper, hosted app deployment, and demo video — none exist yet (see "Live Demo").
There is no formal CONTRIBUTING.md in this repository yet — this section
describes the de facto workflow enforced by CI today.
- Fork and clone the repository, then follow "Getting Started".
- Make changes in the relevant package (
contracts/,matcher/, orweb/). - Before opening a PR, run the same checks CI runs for the package(s) you touched:
npm run compile && npm run build && npm run test # root / contract npm run typecheck --workspace=matcher && npm run lint --workspace=matcher && npm run test --workspace=matcher cd web && npm run typecheck && npm run lint && npm run test && npm run build
- Open a pull request against
main. Every push and PR is gated by.github/workflows/ci.yml— see "CI/CD".
- TypeScript throughout,
tsc --noEmit/next typegen && tsc --noEmitclean. - ESLint clean for
matcher/andweb/(eslint.config.js/eslint.config.mjs). - No linter is configured for the root package or the Compact contract
itself — contract changes are reviewed against
AUDIT.md's documented security model and the existing regression suite intests/exchange.test.ts. - New behavior should come with a test in the corresponding suite
(
tests/,matcher/tests/, orweb/tests/) — the matcher enforces >95% line coverage in CI.
This repository's own commit history uses a loose
type(scope): summary convention (e.g. feat(trade): ...,
fix(ci): ..., docs(readme): ...) — follow that pattern for consistency,
though it is not currently enforced by a commit-lint tool.
Open a PR against main; CI must pass (compile, typecheck, lint, test, and
a production build for web) before merge. Any change to
contracts/exchange.compact that alters a circuit's logic changes its
verifier key — call this out explicitly in the PR description, since it
requires a fresh deployment (see "Smart Contracts").
| Document | Covers |
|---|---|
matcher/README.md |
Matcher quick start, tech stack, project layout |
matcher/ARCHITECTURE.md |
Component responsibilities, request flow, database schema, security & concurrency model |
matcher/API.md |
Full REST/WebSocket API reference |
matcher/MATCHER.md |
Matching algorithm and settlement lifecycle in depth |
web/README.md |
Next.js app basics |
AUDIT.md |
Full security audit — findings, fixes, verification log |
Deployment.md |
Deployment history and live end-to-end verification record on Preview/Preprod |
contracts/exchange.compact |
The contract source itself — heavily commented, the ground truth for circuit behavior |
| Midnight developer documentation | Compact language reference, DApp Connector API, network architecture |
There is no separately published whitepaper or hosted API-docs site for
Zekura today — AUDIT.md and the matcher/*.md files above serve that role
for now.
Which wallets does Zekura support? Any wallet implementing the Midnight DApp Connector API. 1AM and Lace are recognized by name; see "Wallet Setup".
Why does Lace need a local proof server but 1AM doesn't?
1AM implements getProvingProvider() (in-browser proving); Lace doesn't, so
Zekura falls back to a local proof server (NEXT_PUBLIC_PROOF_SERVER_URL,
default http://127.0.0.1:6300) for Lace sessions only.
Does Zekura ever see my private keys or hold my funds?
No. Zekura is non-custodial — wallets sign and submit their own
createOrder/cancelOrder transactions directly. The Matcher only submits
settle()/settleWithProtocol() for orders it has independently verified
against the chain, and never holds an order's ownerSecretKey. This applies
equally to PPM fills — the Treasury the PPM draws on is protocol-owned
liquidity, never a user's funds.
What is the PPM, and where does its liquidity come from? The Proactive Market Maker is the Matcher's fallback for a resting order that finds no user counterparty — it fills the order out of the Treasury, the contract's own protocol-owned liquidity pool, funded only by real admin deposits. It never mints or fabricates liquidity; see "Proactive Market Maker (PPM)" and "Treasury".
Does the PPM support both buy and sell orders? Not yet. User↔user matching supports both sides; PPM fills are currently BUY-side only, because the SELL side would require the Treasury to receive a user's asset as part of the settlement transaction, which needs a secure multi-asset custody/escrow leg this MVP doesn't yet have. This is an explicit design decision, not a bug — see "Proactive Market Maker (PPM)"'s MVP limitation section.
What exactly stays private, and what becomes public? See "Privacy Model" — in short, only a commitment and a lifecycle state are ever on-chain; asset, amount, side, price, owner, and expiry never are.
Can the Matcher cancel my order?
No — structurally cannot. cancelOrder requires the owner's
ownerSecretKey witness, which the Matcher is never disclosed. DELETE /orders/:id on the Matcher only removes an order from its own book/database;
it never submits an on-chain cancelOrder(). See "Security".
How does settlement work without revealing order details?
settle(buyOrderId, sellOrderId) takes no order data as arguments — it
re-derives both orders' private details from witnesses supplied by the
caller and proves, in zero knowledge, that they satisfy the crossing rules
(same asset, same amount, buyPrice >= sellPrice, distinct owners), without
disclosing those values on-chain.
Which networks does Zekura support? Preview and Preprod, both public Midnight testnets, plus a local devnet via Docker Compose. Preprod is the default. Mainnet is out of scope today — see "Roadmap".
Is there partial fill support?
No — settle() requires exact amount equality between the buy and sell
order; the matching engine skips any candidate whose amount doesn't exactly
match (see matcher/MATCHER.md).
Has this been audited?
Yes — see AUDIT.md. One P0 finding (an authorization bypass
in cancelOrder) was found and fixed; production readiness score 10/10
after a live end-to-end verification pass on Preprod. That audit predates
the Treasury/PPM module, though — see "Security"'s scope note.
MIT © 2026 Zekura.
- Midnight Network — the data-protection
blockchain, Compact language, and
midnight-js-*/wallet SDK stack Zekura is built entirely on. - Open source libraries that make up the stack: Fastify,
better-sqlite3, Zod,
pino, Next.js, React,
Tailwind CSS, shadcn/ui,
Radix UI, Vitest, and
the rest listed in
package.json,matcher/package.json, andweb/package.json. - Contributors — see the contributor graph.




