Sealed-quote RFQ and private OTC on Midnight. Makers post quotes the chain cannot read. Takers prove they filled the best one. Losing prices are never published, and a quote that has been committed cannot be walked back.
OTC is the market this is for. Block trades are negotiated off-book precisely because putting them on one moves the price against you, and the settled trade is the only part anyone else is entitled to see. Senyap keeps the negotiation sealed and the print public, which is the shape an OTC desk already works in — with the maker's quote made binding, which chat windows never managed.
Senyap is Indonesian for hushed — the sound a market makes when nobody can hear your order.
Built for the Midnight Buildathon, Wave 1.
Request-for-quote is how large trades actually happen, and its defining failure is information leakage.
Ask five market makers to price 10,000 ETH and all five now know a large seller exists. Four of them lose the trade and keep the information. They can widen, trade ahead, or simply remember. This is why dark pools exist in traditional finance, and why the largest crypto flow still routes through chat windows rather than on-chain venues.
The second failure is last look. In FX and crypto RFQ a maker may see the taker accept, then decline if the market has moved. The taker carries all the optionality and pays for none of it. It survives because a quote sent over a chat window is not binding.
Senyap addresses both, and the mechanism is the same in each case: a quote is a cryptographic commitment.
- A commitment binds the maker. The price cannot change after the taker accepts, so there is no last look.
- A commitment reveals nothing. The chain stores a hash, so the losing quotes stay sealed forever.
The taker holds the openings for every quote it received, sent off-chain by the makers. It then proves, in circuit:
- Every quote in its book is under the on-chain quote tree — proved by a path, so the chain never learns which leaf.
- The quote it consumed has the best price among them.
- That price satisfies the taker's own limit, and the quote covers the taker's size.
- The quote has not expired and has not already been filled.
- Settlement happens at exactly the committed price.
The chain learns one number: the price that traded.
Midnight splits state in two. Getting the line in the right place is the design.
| Public ledger — on chain, anyone can recompute | Private state — witness, never leaves the machine |
|---|---|
quotes — a Merkle tree of sealed commitments |
quote price and maxSize |
spent — nullifiers of filled quotes |
maker secret key and inventory |
makers — authorised maker ids |
taker order size |
fills — how many trades cleared |
taker limitPrice |
referencePrice, bandBps — the public band |
the openings the taker received |
lastFillPrice — the print, winner only |
which slot the taker chose |
epoch — the expiry clock |
Two decisions in that table are worth calling out.
The price band is checked against a sealed price. postQuote enforces |price − mid| × 10000 ≤ mid × bandBps without the price ever becoming public. The venue proves a quote is honest relative to the public mid while learning nothing about it. This is the clearest example on the contract of doing real work on data you cannot see.
There is no division anywhere in the contract. The band check is written in multiplication form. A division would either need a non-zero proof or silently truncate, and both are soundness bugs rather than style problems.
Private state is modelled as one actor's local vault, and almost every witness is a single read out of it (src/venue.js):
const witnesses = {
makerSecret: (ctx) => [ctx.privateState, ctx.privateState.makerSecret],
quoteToPost: (ctx) => [ctx.privateState, ctx.privateState.quoteToPost],
quoteNonce: (ctx) => [ctx.privateState, ctx.privateState.quoteNonce],
takerOrder: (ctx) => [ctx.privateState, ctx.privateState.takerOrder],
receivedQuotes: (ctx) => [ctx.privateState, ctx.privateState.receivedQuotes],
chosenIndex: (ctx) => [ctx.privateState, ctx.privateState.chosenIndex],
};The one exception earns its place. quotePaths is not read out of the vault at
all — it is derived from the public tree:
quotePaths: (ctx) => [ctx.privateState, ctx.privateState.receivedQuotes.map(
(s) => pathFor(ctx.ledger, pureCircuits.commitmentOf(s.terms, s.nonce)))],That is the right shape for it. A membership path is public information about a
public tree; what is private is which path you hold, and that never leaves the
circuit. A real client does the same thing against queryContractState.
Each call names its actor's vault explicitly. A maker and a taker never share one, which is the point: the maker's price is not reachable from the taker's process, and the chain sees neither.
| Circuit | Who | What it enforces |
|---|---|---|
postQuote |
maker | authorised maker, quote bound to that maker, positive size, unexpired, inside the public band. Emits a sealed commitment. |
takeQuote |
taker | a membership path for every slot, best execution, limit, size coverage, expiry, and a fresh nullifier. Appends the residual. Prints the winning price only. |
cancelQuote |
maker | ownership is proved from the secret key, not asserted. Nullifies rather than deletes. |
registerMaker |
venue | admits a maker id. |
setReference |
venue | publishes the mid and the band. |
tick |
venue | advances the expiry clock. |
The first version kept live quotes in a Set<Bytes<32>> and proved membership
with quotes.member(c). That works, and it leaks. To show its book was real the
taker had to name every commitment it held, so the fill published the losing
quotes as participants even though their prices stayed sealed. Anyone watching
could see which three makers were asked, and that is most of what an RFQ is
trying not to say.
Quotes now live in a HistoricMerkleTree<10, Bytes<32>>. The taker supplies a
path per slot, the circuit recomputes the root, and only the root is disclosed:
assert(quotes.checkRoot(disclose(merkleTreePathRoot<10, Bytes<32>>(paths[0]))),
"slot 0 is not a live on-chain quote");
Three details in that one line took the most work.
Only the root is disclosed, never the path. The obvious spelling is
disclose(path) and it type-checks. It is also wrong: a path carries the
sibling hashes and the leaf's position, and the position says when the quote
was posted. Disclosing the recomputed root instead gives the ledger exactly what
it needs to check and nothing else.
Historic, not plain. A MerkleTree changes its root on every insert, so a
path built when the taker assembled its book would stop verifying the moment any
other maker posted. HistoricMerkleTree keeps recent roots valid, which is what
makes the proof usable by someone who is not the last writer.
Padding had to change shape. Every slot is checked unconditionally — the
short-circuit lesson below still applies — so padding needs a path that
verifies. An all-zero slot has none. Padding is now a copy of a real quote with
live: false: a genuine proof, still unselectable, still losing every price
comparison. The alternative was checking paths only for live slots, which would
publish how many of them there were.
A tree cannot delete, so two things follow. A filled quote's leaf stays where it is and the nullifier does the work — which was always true, the removal was decoration. And a cancel is now a nullifier as well, which means the chain cannot tell a cancelled quote from a filled one.
A taker takes size out of a quote and the circuit appends the rest as a fresh
commitment. The residual nonce is derived, not chosen:
persistentHash<Vector<2, Bytes<32>>>([pad(32, "senyap:residual:v1"), nonce])
so the maker can reconstruct the residual opening from its own records. It never has to hear back from the taker to keep the remainder of its quote sellable.
The residual is inserted whether or not anything is left. A full fill appends
the commitment of a zero-size quote, which no later fill can consume because
takeQuote requires a positive size. Making the insert conditional would have
been cheaper and would have published, on every trade, whether it was partial.
Both were found while writing the tests, and both are the kind that leave a contract looking correct.
Fabricated competition. Best execution originally checked only the quote being consumed. A taker could invent a terrible quote nobody posted, put it in its book, and manufacture a best-execution proof against fictional competition. takeQuote now verifies every live slot against the ledger. Test: a fabricated competing quote is refused.
A padding slot that wins. The book is a fixed three slots, so unused slots are padding. Padding priced at zero would beat every real quote and be selectable. Padding now prices at the Uint<64> ceiling and the chosen slot must be live. Tests: a padding slot can never be chosen, a padding slot never wins the price comparison.
A third was caught by the compiler rather than by us. Writing the ledger check as !live || quotes.member(...) lets short-circuit evaluation skip the read for padding slots, and whether the read happens is itself observable — it leaks how many real quotes were in the book. The disclosure analysis rejected it. All three reads are now unconditional.
The privacy test originally scanned JSON.stringify(state) for the losing prices and found nothing. It found nothing because a ChargedState serialises to a wasm pointer and nothing else. Every assertion in it was vacuously true.
The fix was not a better scan, it was a positive control. The test now asserts the winning price is present in the dump before asserting the losing ones are absent. A scan that cannot see the price that traded fails loudly instead of passing quietly.
The taker's limit gets a different treatment. It has no searchable encoding, so a substring scan would prove nothing. Instead the test runs the same fill twice with different limits and asserts the resulting public state is byte-identical — indistinguishability rather than absence.
The fill size used to get that treatment too, and no longer can: partial fills mean the size moves the residual commitment. The test that claimed otherwise was changed rather than deleted — it now asserts that the difference is confined to that one leaf and every named public field is unchanged. What hides the size after that is the commitment being hiding, which is an assumption, not a demonstration. Saying so is cheaper than a test that quietly stops meaning anything.
Requires the Compact toolchain. On Windows use WSL2; the compiler is Linux-only.
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
compact updateThen:
npm install
npm run build # compact compile src/senyap.compact src/managed/senyap
npm test # 34 tests
npm run demo # the end-to-end walkthrough
npm run build:web && npm run dev # the taker console
npm run verify:web # drives the built page in real Chromenpm run demo prints the private side, the public ledger, a leak scan with its control, and the four refusals.
The web console runs the same compiled circuits in the browser. Nothing on that page is validated in JavaScript first — when the UI says REFUSED, that string is the assert that failed inside the circuit. npm run verify:web drives it in headless Chrome and fails on any console error, because a successful vite build only proves the wasm bundled, not that it executes.
Senyap is deployed to Midnight preprod, and a whole RFQ has been run through it.
| Console | https://senyap.vercel.app |
| Contract | 039be0bd3efb108ed649179e0e3b26666a27140bd4bbf64e7d13e813aa9bf3b6 |
| Fill tx | ad17396ba677bb56f9c7b58ddd098050edef76f34b704406ba47d13c7e2e0f52, block 2,497,257 |
npm run live drives that contract: the venue publishes the band, three makers
register and seal a quote each, and the taker fills. Eight transactions, each
one a real proof against the live ledger. What the chain held afterwards, read
back from the indexer rather than from the process that wrote it:
quote tree root 52343dfb...0f96
leaves 4 three sealed quotes, plus the residual
fills 1
nullifiers 1
lastFillPrice 995 maker B, the best of the three
present 995 maker B - won <- the control
absent 1010 maker A - lost
absent 1030 maker C - lost
Confirm it independently, without trusting anything in this repo:
curl -s -X POST -H 'Content-Type: application/json' -d '{"query":"{contractAction(address:\"039be0bd3efb108ed649179e0e3b26666a27140bd4bbf64e7d13e813aa9bf3b6\"){__typename address transaction{hash block{height}}}}"}' https://indexer.preprod.midnight.network/api/v4/graphqlnpm run deploy puts the contract on a real network. It defaults to preprod
and takes everything else from the environment:
| variable | default |
|---|---|
MIDNIGHT_SEED |
whatever is in .wallet/<network>.seed, generated on first run |
MIDNIGHT_NETWORK |
preprod |
MIDNIGHT_INDEXER |
https://indexer.preprod.midnight.network/api/v4/graphql |
MIDNIGHT_NODE |
wss://rpc.preprod.midnight.network |
MIDNIGHT_PROOF_SERVER |
http://127.0.0.1:6300 |
MIDNIGHT_SYNC_TIMEOUT_MS |
7200000 — a cold sync is long |
MIDNIGHT_STEP_TIMEOUT_MS |
300000 — per phase, so a wait becomes an error |
MIDNIGHT_FEE_MARGIN |
1 — blocks of fee margin |
MIDNIGHT_FEE_OVERHEAD |
1000000000000 — see A balancer that never converges |
MIDNIGHT_BALANCE_KINDS |
unshielded,dust — which sub-wallets balance; for bisecting |
Three things have to be true before it can work, and none of them are code.
A proof server is running. Proving happens on your machine, not on the node.
docker run -p 6300:6300 midnightntwrk/proof-server:8.1.0 midnight-proof-server -vThe wallet holds NIGHT. The preprod faucet is at
https://midnight-tmnight-preprod.nethermind.dev/ and sends 1,000 tNIGHT per
request, behind a Cloudflare Turnstile check, so this step is a human with a
browser. npm run deploy prints the Night address before it does anything
slow, and stops if the balance is zero.
That NIGHT is registered for DUST generation. Fees are paid in DUST, which NIGHT only generates after an explicit on-chain registration of its UTXOs. A funded but unregistered wallet cannot pay for anything.
The circularity here is only apparent — a registration transaction costs a fee the wallet cannot yet pay — and the resolution is worth stating: unregistered NIGHT still accrues projected dust from the moment it lands. So the deploy estimates the registration fee, waits for the projection to cover it, submits the registration, and tells you to come back once DUST has actually accrued. It does this automatically rather than failing somewhere inside transaction balancing with a message about coin selection.
Sync is the slow part, and worth describing because the numbers are not
obvious. The three sub-wallets — shielded, unshielded, dust — scan the chain
independently and isSynced is the AND of all three, so the run prints each
one's progress rather than sitting silent. Preprod is about 1.5M events deep.
The shielded wallet applies them in a couple of minutes; the dust wallet
manages roughly 16k a minute, which is an hour and a half. Raising the sync
batch size from its default of 10 helps the shielded side a great deal and the
dust side barely at all, so the fix is not to do it twice: after a cold sync
every sub-wallet's state is serialised into .wallet/, and later runs restore
and catch up instead. Long syncs also checkpoint every five minutes.
That checkpoint is not a nicety. On a 16GB machine the cold sync does not
finish in one process: the wallet grows as it applies events and the run is
killed for memory somewhere past the million mark, repeatedly. Capping the V8
heap does not help, because the ledger's wasm memory is not part of it.
Shrinking the indexer's in-flight event queue from its default of 10,000 does
help, and helps throughput too — less garbage, less collection — but the run
still dies. What actually gets you to a synced wallet is running npm run deploy again: each attempt advances a couple of hundred thousand events and
checkpoints them, so the sync completes across several processes rather than
one. Restarting is the design, not a workaround.
Nothing is balanced until all three are complete: a half-synced wallet picks coins that were already spent.
Call transactions would not reach the chain, and for five runs it looked exactly like a slow network. It was the wallet SDK's dust balancer: a fixed-point loop with no iteration cap, which selects coins to cover the fee and then recomputes the fee including the cost of the inputs it just selected. Coverage is always one step behind, and on a call transaction — larger than a deploy, because it carries a proof — the fee grows by the cost of each added input, so the loop never terminates.
It runs through Effect.runSync, so it blocks the event loop. No timer fires,
nothing logs, and a stall is indistinguishable from slowness; the per-phase
deadline added for exactly this could never have caught it.
Bisecting the three sub-balancers found it, and each fails differently:
unshielded alone finishes in a second and the node then rejects the unpaid
transaction with Custom error: 138; dust alone panics inside wasm with
unreachable; together they loop. additionalFeeOverhead puts the first
selection above the final fee, so the fixed point is reached immediately. That
is what MIDNIGHT_FEE_OVERHEAD sets.
Two things in cli/deploy.js are not the documented way to do it, and both are
there because the documented way does not work.
The facade gets its own submission service. The SDK's node client closes its
websocket after loading metadata and reopens one per operation. Against preprod
that dance loses the race: .send() rejects with disconnected … 1000:: Normal Closure and nothing reaches the chain, deterministically, on every attempt.
The node itself is fine — a plain WsProvider connects, reports Midnight Preprod, and stays up. So the facade is handed a submission service that holds
one connection open, and the same transaction that failed six times went
Ready → Broadcast → InBlock on the first try.
The private state store is proved to work before anything is submitted.
deployContract writes private state only after the deploy transaction has
succeeded, so a store that rejects its password throws when the contract
already exists — and the address goes with the exception. Two contracts were
lost that way before the password was right. The store now takes a write and a
delete first, while a failure is still free. The password itself is derived
rather than raw: the store demands three of four character classes and a hex
seed is two.
The version matrix cost real time to work out, so here it is.
The compiler you get by default is not the one that deploys. compact update
installs the newest compiler — 0.34.0 at the time of writing — and that emits
runtime-version 0.19.0, which resolves onchain-runtime-v4 and ledger v9. The
only midnight-js that speaks v4 is 5.0.0-beta.7, and the only wallet SDK
that pairs with it is canary-tagged. That is two layers of pre-release under a
deploy.
The stable line is entirely v8:
| version | pulls | |
|---|---|---|
| Compact compiler | 0.31.1 | runtime 0.16.0 |
@midnight-ntwrk/compact-runtime |
0.16.0 | onchain-runtime-v3 |
@midnight-ntwrk/midnight-js |
4.1.1 | ledger-v8, compact-runtime 0.16.0 |
@midnight-ntwrk/wallet-sdk |
1.2.0 | wallet-sdk-facade 4.1.0, ledger-v8 |
The four provider packages — midnight-js-indexer-public-data-provider,
-http-client-proof-provider, -node-zk-config-provider and
-level-private-state-provider — are versioned in lockstep with midnight-js
and are all pinned to 4.1.1 as well.
This build is pinned to that line. The contract compiles under both compilers
with no source change at all, so the pin lives in build.sh rather than in the
.compact file.
Two API differences bite when moving between them, and neither is mentioned in an error message:
createCircuitContexttakes acircuitIdfirst argument in 0.19 and does not in 0.16. Pass the 0.19 argument list to 0.16 and everything shifts one place, surfacing as'contractState' parameter [object Object] has unexpected type.- Circuits are
asyncin 0.19 and synchronous in 0.16. The context they return is nested undercallContextin 0.19 and flat in 0.16.
Also worth knowing: Ubuntu 26.04 minimal ships without unzip, and
compact update then fails with Failed to spawn artifact extraction command,
an error that never names the missing binary. sudo apt install -y unzip.
34 tests, all passing, none skipped or stubbed. Every assert in the contract
has one: the 19 distinct refusal messages in senyap.compact all appear in a
test that expects them.
happy path 4 deploy, maker registration, quotes seal, best quote fills
privacy 3 losing prices absent with a positive control; limit
indistinguishable; fill size confined to a hiding commitment
partial fills 4 residual is sellable, cannot be overdrawn, a full fill leaves
an unfillable residual, a zero fill is refused
refusals 10 fade, not-best, over-limit, undersized, expired, double-fill,
fabricated competitor, padding selection, a path vouching for
the wrong quote, an index past the end of the book
maker guards 9 unauthorised, out-of-band, wrong maker id, priceless, sizeless,
already expired, cancel, cancel by impostor, cancel twice
venue guards 1 a reference price of zero
primitives 3 commitment binding, nullifier domain separation, padding price
- A partial fill moves the public state; a full one does not have to. The residual commitment differs with the size taken, so the byte-identical property now holds only across fills of equal size. The residual is a hiding commitment, so the size is not readable from it, but that is a cryptographic assumption rather than something the leak scan can demonstrate. Stated because the older README claimed the stronger property.
- The book is three slots. ZK circuits need fixed bounds. Widening it is a constant, not a redesign.
- Settlement is not custody. Senyap proves a match is valid and binding. It does not move assets. This is a price-discovery layer, not a DEX.
registerMakeris open andtickis manual. Both are demo scaffolding, marked in the source. Neither is load-bearing for the privacy claim.- The console reads the chain but does not write to it.
npm run livedrives the deployed contract end to end from Node. The hosted console runs the same circuits in the browser against a local simulator and reads the deployed contract's public state; proving from the browser needs a wallet bridge, which is not built.
Stated in advance so progress can be measured against them:
Deploy to Midnight preprod and wire the demo to the deployed contract.Done —npm run live, eight transactions, see On chain.Replace theDone — see From a Set to a Merkle tree.Setmembership check with aMerkleTreeproof, so losing commitments stay unlinkable at fill time.Partial fills via residual commitments.Done — see Partial fills.- A taker-facing frontend over the deployed contract. Half done: https://senyap.vercel.app runs the real circuits in the browser and reads the deployed contract's state from the indexer, but proving against preprod from the browser needs a wallet bridge.
Sealed-quote and dark-pool matching are not new — Renegade on Arbitrum and Penumbra in Cosmos both build in this space. Neither exists on Midnight. What is specific here is the mapping onto Midnight's dual-ledger model: a price band enforced against a sealed price, and best execution proved against on-chain commitments.
Apache 2.0. See LICENSE.
Built on Midnight with the Compact toolchain.
